tailnet-operator: Verbindungen deklarieren statt Proxys verkabeln

Kubernetes und Headscale sprechen nicht automatisch dieselbe Sprache. Mein tailnet-operator verbindet beide Welten: Services für entfernte Ziele, private HTTPS-Eingänge, abgeleitete Zugriffsregeln und Statusmeldungen, die mehr sagen als ‚Pod läuft‘. Eine Vorstellung anhand des realen Lab-Einsatzes.
Table of contents

Die Datenbank ist erreichbar. Jedenfalls vom Laptop. Aus dem Pod nicht. Der Tunnel steht, die Route ist angekündigt, der Proxy läuft, und trotzdem bleibt die Verbindung hängen. Irgendwo zwischen Kubernetes-Service, Headscale-Policy, Routenfreigabe und DNS fehlt ein Stück — nur gehört dieser Zusammenhang keinem einzelnen System.

Genau für diese Lücke entwickle ich tailnet-operator. Er macht aus Verbindungen zwischen Kubernetes und einem Headscale-Tailnet eigene Ressourcen: Ein Namespace bekommt einen stabilen Service für ein entferntes Ziel. Eine Anwendung bekommt einen privaten HTTPS-Eingang. Und die dazugehörigen Identitäten, Freigaben und Statusinformationen entstehen aus derselben Deklaration.

Nicht noch ein VPN. Sondern die fehlende Betriebslogik zwischen zwei bereits vorhandenen Welten.

Das Problem ist nicht der Tunnel#

Ein einzelner Tailscale-Client im Pod ist schnell gestartet. Ein socat davor ist ebenfalls keine große Aufgabe. Schwieriger wird es, wenn aus diesem Provisorium ein wiederkehrendes Plattformmuster wird.

Eine Anwendung soll eine Datenbank an einem anderen Standort erreichen. Eine zweite braucht einen internen HTTPS-Dienst mit unverändertem Hostnamen. Ein Dashboard soll ausschließlich für eine bestimmte Tailnet-Gruppe erreichbar sein. Dazu kommen Zertifikate, Namespaces, Neustarts und die Frage, wer nach einem Umbau die alte Berechtigung wieder entfernt.

Der eigentliche Aufwand verteilt sich dann über mehrere Ebenen:

EbeneDie Frage dahinter
KubernetesWelcher Service zeigt auf welchen Proxy? Welche Pods dürfen ihn erreichen?
Tailnet-IdentitätUnter welchem Tag und Namen tritt der Proxy auf?
RoutingWird das Zielnetz angekündigt und ist die Route genehmigt?
ZugriffDarf genau diese Identität genau dieses Ziel erreichen?
DNS und TLSWelcher Name zeigt wohin, und zu welchem Namen passt das Zertifikat?
BetriebWelche dieser Voraussetzungen fehlt gerade?

Mein früheres Tailnet-Gateway hat einen wichtigen Teil davon gelöst. Mit wachsender Nutzung blieb aber zu viel Wissen in gemeinsam genutzten Proxys, einzelnen Policy-Zeilen und manuellen Konventionen hängen.

Die Konsequenz war nicht, den Tunnel neu zu erfinden. Sie war, die gewünschte Verbindung selbst zum verwalteten Objekt zu machen.

Drei Ressourcen statt einer Sammlung von Sonderfällen#

Das Modell besteht aus drei Custom Resources:

RessourceBedeutung
TailnetDie administrative Bindung an Headscale: API-Zugang, DNS-Konfiguration, Policy-Basis und Defaults
TailnetEgressEine eigene Identität für Verbindungen aus dem Cluster zu entfernten TCP-Zielen
TailnetIngressEine eigene Identität, über die Tailnet-Teilnehmer ausgewählte Cluster-Services erreichen

Ein Tailnet ist clusterweit. Egress-Ressourcen liegen im Operator-Namespace; sie benennen explizit ihre Consumer-Namespaces. Ingress-Ressourcen können bei den Anwendungen liegen. Im Lab gibt es daneben administrative Ingress-Ressourcen, die Backends in anderen Namespaces referenzieren.

Das ist eine wichtige Designentscheidung: Nicht jede Anwendung bringt ihren eigenen Tailscale-Sidecar mit. Die Datenebene wird vom Operator aus der Verbindungsbeschreibung erzeugt.

Control Plane und Datenverkehr bleiben getrennt#

Der in Go geschriebene Operator spricht mit Kubernetes und der Headscale-API. Er erzeugt Deployments, Services, NetworkPolicies und Agent-Konfiguration und gleicht deren Zustand ab.

Der eigentliche Datenverkehr läuft durch eigene Userspace-tsnet-Agenten, jeweils pro Ingress- oder Egress-Ressource. Er läuft nicht durch den Reconciler.

flowchart TB CR["Tailnet / TailnetEgress / TailnetIngress"] --> OP[Operator] OP --> HS[Headscale API und Policy] OP --> RES[Services, Agenten, NetworkPolicies] OP --> DNS[Gerenderte DNS-Daten] subgraph DATA["Datenpfade"] APP[Pod] --> SVC[Kubernetes-Service] SVC --> EG[Egress-Agent] EG --> REMOTE[Entferntes TCP-Ziel] PEER[Tailnet-Teilnehmer] --> IN[Ingress-Agent] IN --> BACK[Anwendungs-Service] end RES -. konfiguriert .-> EG RES -. konfiguriert .-> IN

Für die Anwendung bleibt ein Egress-Ziel eine normale TCP-Verbindung zu einem Kubernetes-Service. Sie muss weder SOCKS sprechen noch einen VPN-Client bedienen. Die Agenten selbst benötigen kein NET_ADMIN und kein /dev/net/tun.

Das gilt ausdrücklich für diese Datenebene, nicht pauschal für die gesamte Tailnet-Infrastruktur. Soll ein entferntes LAN erreicht werden, muss weiterhin ein geeigneter Router dessen Netz ankündigen. Im Lab existiert dafür nach wie vor eine separate Gateway-/Subnet-Router-Schicht.

Auch der Operator verschwindet nicht aus dem Vertrauensmodell. Wer Policy schreiben und Arbeits-API-Schlüssel verwalten darf, besitzt relevante administrative Autorität. Ein Bootstrap-Schlüssel, den der Operator nur liest und nicht verändert, ist dadurch noch kein auf lesende Headscale-Aktionen beschränkter Schlüssel.

Verwendung: erst die administrative Grundlage#

Vor der ersten Anwendung müssen Headscale, der API-Zugang, eine Basis-Policy und die DNS-Einbindung stehen. Für automatische HTTPS-Zertifikate kommt cert-manager mit einem passenden Issuer hinzu.

Der sinnvolle Einstieg ist DryRun. Der Operator berechnet die Policy, schreibt sie aber noch nicht nach Headscale. Das gibt mir einen sichtbaren Übergang zwischen „diese Ressourcen existieren“ und „sie verändern die Zugriffsregeln meines Netzes“.

Die folgenden Beispiele sind anonymisierte Ausschnitte für den Funktionsstand v0.6.0. <API-GRUPPE> ist bewusst ein Platzhalter und muss durch die API-Gruppe der installierten CRDs ersetzt werden. Namen, Dokumentationsadressen und Voraussetzungen sind ebenfalls anzupassen; die Blöcke sind kein vollständiger Bootstrap.
 1apiVersion: <API-GRUPPE>/v1alpha1
 2kind: Tailnet
 3metadata:
 4  name: demo
 5spec:
 6  server: https://control.example.net
 7  apiSecretRef:
 8    name: headscale-bootstrap
 9    key: apiKey
10  dns:
11    hostsConfigMap: tailnet-hosts
12    ingressNameTemplate: "{name}.{namespace}.lab.example.net"
13    ingressAliasTemplate: "{name}.lab.example.net"
14  defaults:
15    tls:
16      issuerRef:
17        name: apps-ca
18        kind: ClusterIssuer
19  policy:
20    baseConfigMap: tailnet-policy
21    mode: DryRun
22    tagOwners: ["group:platform"]
23    routeApprovers: ["tag:router"]
24    ingressSourceAllowlist: ["group:team-a"]

Die referenzierten Gruppen, Secrets, ConfigMaps und der Issuer müssen existieren. DryRun erteilt die berechneten Berechtigungen noch nicht. Erst nach Prüfung des Ergebnisses wird die verantwortliche Installation auf Apply umgestellt.

Installiert wird der Operator über sein Helm-Chart. Im Lab sind Chart-Version und Image-Digest gepinnt; Flux hält die Installation und die Ressourcen im gewünschten Zustand. Mir ist diese überprüfbare Installationskette wichtiger als ein spektakulärer Einzeiler, der unbemerkt die jeweils neueste Version lädt.

Beispiel 1: ein entferntes Ziel wird ein normaler Service#

Angenommen, ein Namespace braucht PostgreSQL an einem anderen Standort und eine dortige HTTPS-API. Dann beschreibt eine Egress-Ressource die Ziele und die zugelassenen Consumer:

 1apiVersion: <API-GRUPPE>/v1alpha1
 2kind: TailnetEgress
 3metadata:
 4  name: team-a
 5  namespace: tailnet-system
 6spec:
 7  tailnetRef:
 8    name: demo
 9  consumerNamespaces: [team-a]
10  targets:
11    - name: remote-db
12      address: 192.0.2.10
13      port: 5432
14    - name: remote-api
15      address: 198.51.100.20
16      port: 443
17      aliases: [api.site.example.net]

Für das erste Ziel entsteht ein Service namens remote-db im Operator-Namespace. Die Anwendung verbindet sich beispielsweise mit:

1remote-db.tailnet-system.svc.cluster.local:5432

Dahinter übernimmt der Agent den Weg durch das Tailnet. Die Consumer-Namespace-Liste begrenzt über NetworkPolicies, welche Pods diesen Zugang nutzen dürfen. Das setzt ein CNI voraus, das diese Regeln tatsächlich durchsetzt.

Die Dokumentationsadressen im Beispiel repräsentieren Ziele hinter einem entsprechend konfigurierten Router. Der Operator kann eine fehlende Netzankündigung nicht dadurch ersetzen, dass er einen Service anlegt.

HTTPS braucht mehr als eine erreichbare IP#

Beim zweiten Ziel ist der Alias interessant. Würde die Anwendung statt api.site.example.net einfach einen beliebigen Service-Namen benutzen, könnten TLS-SNI, Zertifikatsname oder HTTP-Host nicht mehr zum entfernten Dienst passen.

Der Alias lässt den ursprünglichen Namen auf den lokalen Egress-Service zeigen. Der Client verwendet weiter die erwartete URL, und der TCP-Pfad bleibt transparent. Der Operator muss das TLS der entfernten Anwendung dafür nicht terminieren.

Aber: Er ist kein DNS-Server. Er rendert die benötigten Einträge in eine ConfigMap. CoreDNS beziehungsweise die zuständige DNS-Infrastruktur muss sie auch ausliefern. Ein korrektes Alias-Feld ohne diese Einbindung ist nur eine korrekt beschriebene Absicht.

Im Lab ist der belegte Egress-Anwendungsfall derzeit ein begrenzter Management-Endpunkt als Evaluations- und Regressionstest. Die externe Datenbank oben ist ein unterstütztes Nutzungsmuster, keine erfundene produktive Lab-Datenbank.

Beispiel 2: zwei Weboberflächen, ein privater HTTPS-Eingang#

In der Gegenrichtung bekommt ein Dienst einen Eingang aus dem Tailnet. mode: http bedeutet dabei: HTTPS wird am Agenten terminiert, dahinter folgt HTTP oder ausdrücklich konfiguriertes HTTPS zum Backend.

 1apiVersion: <API-GRUPPE>/v1alpha1
 2kind: TailnetIngress
 3metadata:
 4  name: portal
 5  namespace: team-a
 6spec:
 7  tailnetRef:
 8    name: demo
 9  allowFrom: ["group:team-a"]
10  exposes:
11    - name: web
12      dnsLabel: portal
13      alias: true
14      mode: http
15      port: 443
16      backend:
17        service: frontend
18        port: 8080
19    - name: admin
20      dnsLabel: admin
21      mode: http
22      port: 443
23      backend:
24        service: admin
25        port: 8443
26        tls:
27          serverName: admin.team-a.svc.cluster.local
28          caConfigMapRef:
29            name: backend-ca
30            key: ca.crt
31      timeouts:
32        request: 600s
33        backendRequest: 60s

Mit dem oben gezeigten Namensschema entstehen portal.team-a.lab.example.net und admin.team-a.lab.example.net. Der erste Eintrag erhält zusätzlich den kurzen Alias portal.lab.example.net.

Beide Namen teilen eine Tailnet-Identität und deren Zugriffsregeln. Genau das macht Virtual Hosting effizient. Genau deshalb sollte man Dienste mit unterschiedlichen Berechtigungsgruppen in getrennte Ressourcen legen. Zwei Hostnamen sind keine zwei Sicherheitsgrenzen.

Ein Zertifikat deckt die HTTP-Namen und Aliasse derselben Ressource ab. Es kann aus einem vorhandenen TLS-Secret kommen oder über cert-manager erzeugt werden. Stimmen Zertifikat und Konfiguration nicht, darf daraus kein stiller Klartext-Ersatzweg werden.

Backend-TLS ist eine zweite Prüfung#

Das öffentliche beziehungsweise Tailnet-seitige Zertifikat beantwortet nur die Frage, ob der Client mit dem richtigen Ingress spricht. Es sagt nichts darüber, ob der Ingress anschließend das richtige Backend erreicht.

Deshalb hat backend.tls eine eigene Vertrauenskette: expliziter Servername, explizites CA-Bundle, verifiziertes HTTPS. Es gibt hier keinen gewollten „bei Problemen eben ohne Zertifikatsprüfung“-Fallback.

Ebenso sind Request- und Backend-Zeitbudgets verschiedene Dinge. Ein längerer Upload und das Warten auf die Backend-Antwort brauchen bewusste Grenzen. Diese Optionen sind kein Versprechen, jeden Timeout eines vorherigen Proxys eins zu eins nachzubilden; insbesondere sind etablierte WebSocket-Verbindungen ein eigener Lebenszyklus.

Policy nicht nebenbei mitschreiben, sondern besitzen#

Der schwierigste Teil eines solchen Operators ist nicht das Erzeugen eines Deployments. Es ist die Frage, wem die Headscale-Policy gehört.

Mein Modell hat eine menschlich gepflegte Basis in Git. Dort stehen Gruppen, grundlegende Regeln und Policy-Tests. Daraus und aus den Custom Resources ergänzt der Operator unter anderem:

  • Eigentümer der benötigten Tags;
  • automatische Routenfreigaben für geeignete IP-Ziele;
  • TCP-Grants zu Egress-Zielen;
  • Ingress-Grants aus allowFrom.

Die administrative ingressSourceAllowlist begrenzt, welche Quellen eine Ingress-Ressource überhaupt anfordern darf. Eine Anwendung soll nicht einfach durch ein großzügiges allowFrom die Plattformregeln umgehen können.

Pro Headscale-Server darf genau eine Installation die Policy schreiben. Mehrere Cluster können nicht unabhängig jeweils „ihren kleinen Teil“ als vollständige Policy hochladen und darauf hoffen, dass die Teile erhalten bleiben. Apply bedeutet Ownership des gerenderten Gesamtdokuments, nicht kooperatives Patchen durch beliebig viele Writer.

Manuelle Änderungen am laufenden Headscale-Dokument sind deshalb ebenfalls kein dauerhafter Konfigurationsweg. Der nächste Abgleich kann sie überschreiben. Gewollte Änderungen gehören in die Basis oder in die deklarativen Ressourcen.

Wird eine neue Policy abgelehnt, bleibt die bisherige aktiv. Das ist sicherer als eine halb angewendete Freigabe, verlangt aber ehrlichen Status: Eine geänderte Ressource ist dann noch nicht erfolgreich wirksam. Und für DNS-Namen als Egress-Ziele erzeugt der Operator nicht automatisch dieselben Ziel-Grants wie für IP-Ziele; diese Berechtigungen müssen passend in der Basis vorhanden sein.

Status ist hier kein dekoratives Extra#

Ein Pod kann gesund sein, während seine Aufgabe vollständig kaputt ist. Gerade bei Netzproblemen ist „Deployment available“ deshalb eine ziemlich schwache Antwort.

Der Operator unterscheidet mehrere Ursachen, die von außen sonst alle wie ein Timeout aussehen:

Zustand oder ReasonWas er mir sagt
NotAdvertisedFür das Ziel fehlt eine passende angekündigte Route.
NotApprovedDie Route ist angekündigt, aber nicht genehmigt.
NoGrantDie genehmigte Route fehlt in der Sicht dieses Knotens; eine Zugriffsfreigabe fehlt.
ProbeFailedDer Agent konnte nicht belastbar abgefragt werden.
PolicyDeniedDie angeforderte Quelle verletzt die administrative Allowlist.
DNSNameConflictEin anderer Besitzer beansprucht den Namen bereits.
TLSCertificatePendingDie Zertifikatsausstellung ist noch nicht abgeschlossen.

Damit kann ich gezielter anfangen:

1kubectl get tailnet,tailnetegress,tailnetingress -A
2kubectl -n tailnet-system describe tailnetegress team-a
3kubectl -n team-a describe tailnetingress portal

Diese Conditions, Prometheus-Metriken und die mitgelieferten Monitoring-Regeln gehören für mich zum Produktkern. Ein Verbindungsoperator sollte nicht nur Verbindungen erzeugen, sondern ihren fehlenden Voraussetzungen einen Namen geben.

Ready ist kein vollständiger Ende-zu-Ende-Test. Ein gültiges Zertifikat am Agenten beweist nicht, dass ein fremder Client der CA vertraut. Ein erreichbares Backend beweist nicht, dass Split-DNS auf jedem Client richtig eingerichtet ist. Wo der Operator etwas nicht prüfen kann, muss diese Beobachtungsgrenze sichtbar bleiben.

Was davon bereits im Lab verwendet wird#

Die Beispiele sind keine reine API-Demo. Im öffentlichen GitOps-Baum ist Version v0.6.0 mit mehreren realen Anwendungen eingebunden:

  • Kubernetes- und Talos-API werden als getrennte TCP-Eingänge veröffentlicht. Transportzugang ersetzt dabei weder Kubernetes-RBAC noch Talos-Clientidentitäten.
  • Grafana, Prometheus, Alertmanager und Pyrra teilen einen HTTPS-Agenten mit vier Virtual Hosts. Gleiche Zugriffsgruppe, getrennte Namen, keine vier fast identischen Proxy-Deployments.
  • Garage S3 und die Administrationsoberfläche verwenden verifiziertes HTTPS auch zum Backend. Gerade bei S3 muss der Proxy außerdem Nutzdaten unverändert transportieren.
  • Paperless und ZeroClaw besitzen eigene HTTPS-Eingänge mit bewusst längeren Request-Budgets.
  • ZNC und Syncthing verwenden ebenfalls geprüfte Backend-TLS-Verbindungen; weitere Dashboards hängen am gleichen Ressourcenmodell.

„Verwendet“ bezeichnet hier den veröffentlichten Konfigurations- und dokumentierten Migrationsstand, nicht eine frische Live-Prüfung jedes Endpunkts während des Schreibens.

Die Ablösung der alten Wege erfolgte nicht als großer Schalter für alles. Zuerst kamen neue Eingänge hinzu, dann anwendungsspezifische Abnahmeschritte, und erst danach verschwanden alte Routen. Bei Paperless sind Anmeldung und Originaldownload relevant, bei S3 die Objektbytes und bei einer Monitoring-Oberfläche nicht bloß die anonyme Loginseite.

Genau diese Unterschiede verschwinden zu leicht hinter einem einheitlichen grünen Ready.

Die Entwicklung: immer weniger Wissen außerhalb der Ressource#

Der Weg zum heutigen Stand verlief in nachvollziehbaren Schritten:

  1. TCP-Pfade in beide Richtungen: zunächst die grundlegende Verbindung und ihre Identität.
  2. DNS-Rendering: Namen nicht mehr getrennt von ihren Services und Tailnet-Knoten pflegen.
  3. Policy Ownership: Tags und Freigaben aus derselben Absicht ableiten, inklusive Nachholen verpasster Routenfreigaben.
  4. Native HTTPS-Eingänge: Zertifikate und HTTP-Verarbeitung direkt am Agenten statt eines zusätzlichen gemeinsamen Umwegs.
  5. Backend-TLS und Zeitbudgets: reale Anwendungen brauchen mehr als einen simplen Reverse Proxy.
  6. Virtual Hosts in v0.6.0: mehrere Namen bündeln, wenn Identität und Berechtigungen tatsächlich zusammengehören.

Eine besonders lehrreiche Korrektur lag zwischen den größeren Features: Backend-Antworten durften nicht automatisch dekomprimiert werden. Für einen Browser kann eine transparente Transformation harmlos wirken. Für einen Objektspeicher ist „ungefähr derselbe Inhalt“ kein akzeptabler Transportvertrag.

Das ist der Nutzen des Labs als Entwicklungsumgebung: Nicht nur Echo-Server hinter eine neue API hängen, sondern Dienste mit sehr verschiedenen Anforderungen über denselben Pfad betreiben.

Wofür ich ihn heute einsetzen würde — und wofür nicht#

Der passende Einsatzfall ist eine Kubernetes-Plattform mit Headscale, die ausgewählte Dienste zwischen Cluster und Tailnet sauber verbinden will. Der beschriebene Release setzt Kubernetes ab 1.31, Headscale 0.29.x und ein NetworkPolicy-fähiges CNI voraus.

Er ist dagegen kein vollständiger Ersatz für alle Netzwerkkomponenten:

  • Kein allgemeiner Manager für Headscale und die gesamte Router-Infrastruktur.
  • Kein Pod-Mesh zwischen allen Clustern.
  • Kein UDP-Service-Forwarding in diesem Funktionsumfang.
  • Keine automatische Anwendungsanmeldung oder OIDC-Schicht.
  • Keine behauptete harte Isolation gegeneinander bösartiger Mandanten.
  • Keine Garantie unterbrechungsfreier Upgrades: Agenten laufen als Einzelinstanzen mit Recreate.

Im Vergleich zum früheren gemeinsamen Gateway wird Verantwortung anders zugeschnitten, nicht jede Abhängigkeit beseitigt. DNS, Routing, Zertifikatsvertrauen und Anwendungsautorisierung bleiben reale Aufgaben. Der Operator macht ihren Zusammenhang beschreibbar und prüfbarer.

Der eigentliche Gewinn#

Die interessante Verbesserung ist nicht, dass sich weniger YAML tippen lässt. Sie ist, dass eine Verbindung endlich einen Besitzer, eine deklarierte Absicht und einen erklärbaren Zustand bekommt.

Ich möchte bei einer kaputten Verbindung nicht zuerst erinnern müssen, in welchem von vier Repositories noch eine zusätzliche Ausnahme stand. Ich möchte die Ressource ansehen und erfahren, ob die Route fehlt, die Freigabe abgelehnt wurde oder das Zertifikat noch nicht bereitsteht.

Dafür ist tailnet-operator gebaut. Aus „da steht irgendwo ein Proxy“ wird ein Teil der Plattform, den ich mit denselben Werkzeugen beschreiben, prüfen und versionieren kann wie die Anwendung selbst.

Quellenstand: Funktionsumfang des Releases v0.6.0 und die öffentliche Lab-Konfiguration Anfang Oktober 2026. Die praktischen Integrationen stehen im Lab-Repository unter applications/tailscale-system/tailnet-operator/. Die beiden ergänzenden Artikel zeigen Paperless als Anwendung und ZeroClaw als lesenden Triage-Client .