Tor-Konnektivität im Cluster hatte ich bisher zusammengesteckt: pro Pod ein client-only tor plus ein oder zwei socat-Sidecars, die lokale TCP-Ports in Onion-Services übersetzten — genau das Setup, das ich im IRC-Bouncer-Beitrag
beschrieben habe. Das funktioniert, skaliert aber nicht als Muster: Jeder Workload, der eine .onion erreichen oder selbst eine anbieten wollte, brachte seinen eigenen Tor-Prozess und seine eigene Klebeschicht mit. Also habe ich das Ganze zu dem gemacht, was es im Lab sein sollte: einer Custom Resource. tor-operator ist ein eigener, in Rust geschriebener Kubernetes-Operator, der Tor-Konnektivität in beide Richtungen als CRD anbietet.
Warum überhaupt ein Rewrite?#
Es gibt bereits einen inoffiziellen Operator, bugfest/tor-controller , und ich habe ihn ernsthaft evaluiert. Zwei Dinge haben mich am Ende doch zum eigenen Bau bewogen.
Erstens das Wartungssignal. Im Repo liegt seit Ende 2024
Issue #81
offen und unbeantwortet — ein automatisch aufgemachter Advisory, dass das genutzte gcr.io/kubebuilder/kube-rbac-proxy-Image deprecatet ist und die GCR-Registry ab März 2025 verschwindet. Das ist für sich genommen kein dramatischer Bug, aber genau die Art Grundrauschen, das sich in einem Projekt ansammelt, das niemand mehr aktiv steuert: ein Deployment, das perspektivisch bricht, weil ein Basis-Image wegfällt, und keine Reaktion darauf. Auf so etwas will ich in meinem GitOps-Pfad nicht bauen.
Zweitens — und das war der eigentliche Grund — der architektonische Zuschnitt. Der bestehende Controller denkt in „ein Onion-Service, ein Tor-Pod": Jede Custom Resource bekommt ihr eigenes Deployment mit eigenem Tor-Prozess. Das ist sauber gekapselt, aber für mein Lab die falsche Granularität. Ich wollte drei Dinge, die das Modell so nicht hergibt:
- Einen geteilten Tor pro Namespace statt eines Prozesses pro Onion — Tor braucht einige Minuten, um zu bootstrappen und Guards aufzubauen; das für jede einzelne
.onionneu zu bezahlen ist Verschwendung. - Beide Richtungen. Der bestehende Controller veröffentlicht Services als Onion (eingehend). Meinen
socat-Anwendungsfall — von einem Workload hinaus zu fremden.onions tunneln — deckt er nicht ab. - Kein Pod-Neustart für triviale Änderungen. Eine Onion hinzufügen, eine Bridge austauschen — das sollte den laufenden Tor nicht durchstarten und alle bestehenden Circuits wegwerfen.
Das lässt sich nicht nachrüsten, ohne das Kernmodell umzudrehen. Also: Rust, kube-rs , von Grund auf.
Die Grundidee: ein Daemon, dynamisch umkonfiguriert#
Der Dreh- und Angelpunkt ist Tors Control-Port. Ein laufender Tor lässt sich zur Laufzeit steuern: Hidden Services kommen mit ADD_ONION dazu und mit DEL_ONION wieder weg, Konfiguration ändert man mit SETCONF — alles ohne den Prozess anzufassen. Genau darauf baut der Operator.
Pro Namespace läuft ein tor. Jede OnionService- und OnionProxy-Resource im Namespace bindet an diesen Daemon, und der Operator aggregiert alle gebundenen CRs zu einem Desired State, den ein Data-Plane-Agent im selben Pod auf den lebenden Tor konvergiert — über den Control-Port, ohne Pod pro CR, ohne Neustart beim Hinzufügen oder Entfernen.
Die Aufteilung ist bewusst zweigeteilt, ganz im Sinne der Operator-Trennung, die ich auch bei ZeroClaw
mag: Die Control Plane (tor-operator) spricht mit der Kubernetes-API, kennt aber Tor nur über eine serde-Datenstruktur. Die Data Plane (tor-agent) supervisiert Tor, spricht den Control-Port und meldet Gesundheit über einen kleinen HTTP-Endpunkt zurück — hat dafür aber keinerlei kube-Abhängigkeit. Der Kontrakt dazwischen ist eine ConfigMap (Topologie) plus ein Secret (Onion-Keys); mehr müssen die beiden nicht voneinander wissen.
Beide Binaries stecken in einem Image, gebaut auf
dockurr/tor
(bringt tor und /usr/bin/lyrebird für obfs4 mit). Operator und Agent sind derselbe Container mit unterschiedlichem command.
Drei Custom Resources#
Alles lebt unter der API-Gruppe tor.fr0st.dev/v1alpha1:
| Kind | Richtung | Zweck |
|---|---|---|
TorDaemon | — | Der geteilte tor pro Namespace. Optional — wird auto-provisioniert. |
OnionService | eingehend | Einen Cluster-Service als v3-.onion veröffentlichen. |
OnionProxy | ausgehend | Stabile TCP-Endpunkte, die zu fremden .onions hinaustunneln. |
OnionService — einen Service als .onion veröffentlichen#
Der häufigste Fall braucht keinen TorDaemon. Man wirft eine OnionService in den Namespace, und der Operator provisioniert den tor-Daemon bei Bedarf selbst:
1apiVersion: tor.fr0st.dev/v1alpha1
2kind: OnionService
3metadata:
4 name: my-service
5spec:
6 ports:
7 - virtualPort: 443
8 backend: { service: my-service, port: 8443 }
9 # privateKeySecret: { name: my-onion-key } # optional: feste / Vanity-Adresse
10 # daemonRef: { name: tor } # optional: default = Namespace-'tor'
1kubectl get onionservice my-service
2# NAME HOSTNAME READY AGE
3# my-service abcd…wxyz.onion true 2m
Ein Detail, auf das ich stolz bin: Die .onion-Adresse steht fest, bevor Tor überhaupt läuft. Eine v3-Onion-Adresse ist reine Ableitung aus dem ed25519-Public-Key — der Operator erzeugt das Schlüsselpaar und rechnet die Adresse selbst aus, statt Tor booten zu müssen, um sie zurückzulesen:
1// onion = base32(PUBKEY ‖ CHECKSUM ‖ VERSION).onion
2// CHECKSUM = SHA3-256(".onion checksum" ‖ PUBKEY ‖ VERSION)[..2]
Der Operator legt den Key in einem verwalteten Secret ab (oder liest einen vorhandenen aus privateKeySecret — so pinnt man eine per
mkp224o
vorberechnete Vanity-Adresse). Der Agent führt beim ADD_ONION dann noch einen Selbst-Check durch: Stimmt die von Tor zurückgegebene ServiceID nicht mit der erwarteten Adresse überein, wird die Onion sofort wieder entfernt, statt unter einer falschen Adresse zu publizieren.
OnionProxy — hinaus zu fremden .onions#
Das ist der direkte Ersatz für die socat-Sidecars von früher. Ein Workload, der kein SOCKS spricht (ein IRC-Bouncer etwa), bekommt stabile clusterinterne TCP-Endpunkte, die über Tor zu einer Upstream-.onion tunneln:
1apiVersion: tor.fr0st.dev/v1alpha1
2kind: OnionProxy
3metadata:
4 name: upstreams
5spec:
6 targets:
7 - name: libera
8 onion: libera75jm6of4wxpxt4aynol3xjmbtxgfyjpu34ss4d7r7q2v5zrpyd.onion
9 port: 6697
10 listenPort: 6697
Der Workload verbindet sich dann schlicht gegen upstreams.<namespace>.svc:6697. Der Operator legt dafür einen Service an, der auf den Daemon-Pod zeigt; die eigentlichen Listener sind native TCP→SOCKS5-Brücken im Agenten — kein socat mehr. Plain TCP rein, Tor raus. Damit sich zwei Proxies im selben Daemon nicht auf denselben listenPort setzen (sie teilen sich einen Netzwerk-Namespace), gilt clusterweit first-wins, und Kollisionen tauchen als Condition am CR auf.
TorDaemon — nur wenn man ihn braucht#
Meist deklariert man ihn gar nicht. Explizit wird er nur, um Resources oder Log-Level zu setzen — oder um Bridges zu konfigurieren.
Bridges: kuratiert vom Daemon selbst#
Bridges sind standardmäßig aus — der Daemon verbindet sich direkt ins Tor-Netz. Man braucht sie nur dort, wo das Netz Tor filtert; dann geht Tor über obfs4 (via lyrebird) hinein statt über ein öffentliches Relay. Konfiguriert wird das einmal pro Namespace am TorDaemon:
1apiVersion: tor.fr0st.dev/v1alpha1
2kind: TorDaemon
3metadata: { name: tor }
4spec:
5 bridges:
6 transport: obfs4
7 seed: { name: tor-bridges, key: bridges } # dein sops/GitOps-gepflegter Seed
8 source: # optional: automatischer Nachschub
9 https:
10 { url: "https://bridges.torproject.org/bridges/en?transport=obfs4" }
11 minHealthy: 2
12 maxBridges: 6
Das Modell dahinter ist gegenüber einem ersten Wurf deutlich einfacher geworden. Ursprünglich gab es dafür eine eigene BridgePool-CRD; die habe ich in den TorDaemon gefaltet, weil ein Namespace ohnehin genau einen Bridge-Satz hat. Der Daemon kuratiert ihn jetzt bei jedem Reconcile selbst:
- Er liest den Seed (dein Secret, GitOps-gepflegt, wird nie evictet) und den zuvor kuratierten Satz.
- Er liest seine eigene Per-Bridge-Gesundheit — dreiwertig:
healthy/dead/unknown, abgeleitet aus TorsORCONN-Events. - Er evictet nur Nicht-Seed-Bridges, die er aktiv als
deadbeobachtet hat (länger alsevictGraceSeconds). Leerlaufende, aber erreichbare Reserven bleiben — kein Wegräumen gesunder Spares. - Sind weniger als
minHealthyBridges nicht-tot, holt er (rate-limitiert) neue vom HTTPS-Distributor, parst dessen HTML/Entities defensiv und füllt bismaxBridgesauf. - Er wendet den kuratierten Satz per
SETCONFauf den laufenden Tor an — kein Neustart.
Zwei Realitäten, die dieses Design prägen: Der Seed bleibt deiner — es gibt bewusst kein operator-geschriebenes Bridges-Secret, das mit deiner Source of Truth konkurriert. Und BridgeDB ist IP-sticky und rate-limitiert: Von einer einzigen Cluster-Egress-IP liefert ein Re-Fetch oft dieselben Bridges und wirft irgendwann ein CAPTCHA. Deshalb fetcht der Daemon sparsam und stützt sich für die akute Erholung auf den Seed. Bekommt er statt Bridges eine CAPTCHA-Seite, parst der defensive Parser daraus schlicht null Bridges — was als Fetch-Fehler behandelt wird, nicht als „alle Bridges weg".
Status, der etwas aussagt#
Jedes Kind meldet Readiness als Kubernetes-Conditions, sodass kubectl describe genau sagt, was (nicht) geht. Wichtig ist mir dabei, dass „ready" nicht „ein Pod läuft" bedeutet, sondern tatsächliche Erreichbarkeit:
OnionService.Publishedspiegelt einen echtenHS_DESC-Upload des Descriptors an die HSDirs — die Onion ist also wirklich auffindbar, nicht bloß intern registriert.OnionProxy.TargetsReachablekommt aus einer aktiven Probe: Der Agent öffnet periodisch einen echten SOCKS5-Circuit zur Ziel-.onion, statt nur zu prüfen, ob ein Listener gebunden ist.TorDaemonträgtTorBootstrapped, einen dreiwertigenstatus.bridges-Block pro Bridge undonionsPublished.
Dazu exportiert der Agent Prometheus-Metriken (tor_agent_bootstrapped, tor_agent_onions_published, tor_agent_bridge_state{fingerprint}, tor_agent_target_reachable{proxy,target}), die ein mitgelieferter ServiceMonitor einsammelt.
Betrieb#
Installiert wird per kustomize — CRDs, RBAC und der Operator im Namespace tor-system:
1kubectl apply -k deploy
Das Image ist mit cosign signiert, im selben Stil wie meine übrige Supply-Chain ; vor dem Deploy lässt es sich verifizieren:
1cosign verify --key cosign.pub oci.this-is-fine.io/tor/operator:v2.0.0
Gebaut und gepusht wird radicle-nativ über meinen CI-Broker
: .radicle/native.yaml fährt ci-build, das den Container baut, pusht und cosign-signiert. Die Data-Plane-Pods laufen unprivilegiert (non-root uid, alle Capabilities gedroppt); Tors DataDirectory ist ein Unterverzeichnis eines beschreibbaren emptyDir, das der Agent vor dem Start selbst anlegt und besitzt — Tor verweigert sonst ein Verzeichnis, das ihm nicht gehört.
Fazit#
Aus „jeder Workload bringt seinen eigenen Tor und ein bisschen socat mit" ist ein deklaratives kubectl apply geworden. Der Gewinn ist nicht nur weniger YAML: Ein geteilter Daemon bootstrappt einmal statt pro Onion, Änderungen laufen über den Control-Port statt über Pod-Neustarts, und beide Richtungen — rein wie raus — leben unter derselben API. Dass ich dafür den bestehenden inoffiziellen Controller nicht weiterverwendet habe, lag weniger an einem einzelnen Bug als am Gesamtbild: ein Projekt, dessen offene Deprecation-Advisories niemand mehr abräumt, und ein Datenmodell, das nicht zu dem passt, wie ich Tor im Cluster laufen lassen will. Manchmal ist der richtige Weg, es selbst zu schreiben — klein, in Rust, und genau auf das eigene Lab zugeschnitten.
Nachtrag (1. Juli 2026): v2.0.1 und Monitoring#
Kaum stand der Operator im Lab, holte mich beim Verdrahten der Metriken ein Namenskonflikt ein — und mit ihm ein Punkt, an dem sich Image- und Lab-Repo sauber ergänzen.
Image: <daemon>-metrics statt <daemon> (v2.0.1). Der Daemon legte seinen Metrics-Service unter seinem eigenen Namen an — also tor für den Standard-Daemon. Nur: Auch eine OnionProxy benennt ihren Service nach der CR. In einem Namespace mit einem TorDaemon und einer gleichnamigen OnionProxy (beide tor) überschattete der Proxy-Service den Metrics-Service, und ein ServiceMonitor fand nichts zum Scrapen. Der Fix: Der Metrics-Service heißt jetzt <daemon>-metrics, der Selektor zeigt unverändert auf den Daemon-Pod. Die Kollision ist damit strukturell weg, nicht nur im Einzelfall vermieden.
Lab: lieber gleich am Pod scrapen. Im Cluster habe ich daraus die konsequentere Lehre gezogen und statt eines ServiceMonitor einen PodMonitor eingezogen. Er selektiert über app.kubernetes.io/managed-by: tor-operator und scrapt den metrics-Port (9080) jedes Daemon-Pods direkt — namespace-übergreifend, ohne den Umweg über einen Service, der sich theoretisch wieder verschatten ließe:
1apiVersion: monitoring.coreos.com/v1
2kind: PodMonitor
3metadata:
4 name: tor-operator
5spec:
6 namespaceSelector: { any: true }
7 selector:
8 matchLabels: { app.kubernetes.io/managed-by: tor-operator }
9 podMetricsEndpoints:
10 - { port: metrics, path: /metrics, interval: 30s }
Damit ist die frühere Aussage im Abschnitt oben — „die ein mitgelieferter ServiceMonitor einsammelt" — überholt: Der mitgelieferte ServiceMonitor funktioniert weiterhin, im Lab scrape ich aber bewusst die Pods.
Dazu kam ein kleines Grafana-Dashboard für den Operator, das genau auf den Metriken des Agenten sitzt: tor_agent_bootstrapped pro Namespace, sum by (namespace) (tor_agent_onions_published), die Zahl gesunder Bridges als count by (namespace) (tor_agent_bridge_state == 1) sowie tor_agent_bridge_state und tor_agent_target_reachable als Zeitreihe und Tabelle. Auf einen Blick sieht man so pro Namespace, ob Tor gebootstrappt ist, wie viele Onions publiziert sind und welche Bridges gerade tragen — dieselbe dreiwertige Health, die der Daemon ohnehin führt, nur eben sichtbar.
