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:
| Ebene | Die Frage dahinter |
|---|---|
| Kubernetes | Welcher Service zeigt auf welchen Proxy? Welche Pods dürfen ihn erreichen? |
| Tailnet-Identität | Unter welchem Tag und Namen tritt der Proxy auf? |
| Routing | Wird das Zielnetz angekündigt und ist die Route genehmigt? |
| Zugriff | Darf genau diese Identität genau dieses Ziel erreichen? |
| DNS und TLS | Welcher Name zeigt wohin, und zu welchem Namen passt das Zertifikat? |
| Betrieb | Welche 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:
| Ressource | Bedeutung |
|---|---|
Tailnet | Die administrative Bindung an Headscale: API-Zugang, DNS-Konfiguration, Policy-Basis und Defaults |
TailnetEgress | Eine eigene Identität für Verbindungen aus dem Cluster zu entfernten TCP-Zielen |
TailnetIngress | Eine 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.
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“.
<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 Reason | Was er mir sagt |
|---|---|
NotAdvertised | Für das Ziel fehlt eine passende angekündigte Route. |
NotApproved | Die Route ist angekündigt, aber nicht genehmigt. |
NoGrant | Die genehmigte Route fehlt in der Sicht dieses Knotens; eine Zugriffsfreigabe fehlt. |
ProbeFailed | Der Agent konnte nicht belastbar abgefragt werden. |
PolicyDenied | Die angeforderte Quelle verletzt die administrative Allowlist. |
DNSNameConflict | Ein anderer Besitzer beansprucht den Namen bereits. |
TLSCertificatePending | Die 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:
- TCP-Pfade in beide Richtungen: zunächst die grundlegende Verbindung und ihre Identität.
- DNS-Rendering: Namen nicht mehr getrennt von ihren Services und Tailnet-Knoten pflegen.
- Policy Ownership: Tags und Freigaben aus derselben Absicht ableiten, inklusive Nachholen verpasster Routenfreigaben.
- Native HTTPS-Eingänge: Zertifikate und HTTP-Verarbeitung direkt am Agenten statt eines zusätzlichen gemeinsamen Umwegs.
- Backend-TLS und Zeitbudgets: reale Anwendungen brauchen mehr als einen simplen Reverse Proxy.
- 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
.
