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: { url: "https://bridges.torproject.org/bridges/en?transport=obfs4" }
10 minHealthy: 2
11 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 lehnt 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 einem aktiven Probe: Der Agent öffnet periodisch eine echte 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 eine mitgelieferte 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 ehrlichste 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 eine mitgelieferte 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.
