Tor als CRD: ein eigener Operator statt Sidecar-Bastelei

Warum ich den inoffiziellen tor-controller durch einen eigenen, in Rust geschriebenen Operator ersetzt habe — ein gemeinsamer Tor-Daemon pro Namespace, Onion-Services und Onion-Proxies dynamisch über den Control-Port, plus kuratierte obfs4-Bridges
Table of contents

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 .onion neu 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.

flowchart TB subgraph CP["Control Plane (tor-operator)"] REC["Reconciler<br/>OnionService · OnionProxy · TorDaemon"] end subgraph POD["Daemon-Pod (ein tor pro Namespace)"] AGENT["tor-agent<br/>(Supervisor + Reconcile-Loop)"] TOR["tor<br/>Control :9051 · SOCKS :9050"] AGENT -->|"ADD_ONION / DEL_ONION<br/>SETCONF (Bridges)"| TOR end REC -->|"schreibt Desired State"| CM[("&lt;daemon&gt;-state ConfigMap<br/>+ onion-keys Secret")] CM -->|"gemountet"| AGENT AGENT -->|"/bridges /onions /targets<br/>(Health)"| REC TOR -->|"Onion-Service (rein)"| NET(("Tor-Netz")) NET -->|"SOCKS5 → .onion (raus)"| TOR

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:

KindRichtungZweck
TorDaemonDer geteilte tor pro Namespace. Optional — wird auto-provisioniert.
OnionServiceeingehendEinen Cluster-Service als v3-.onion veröffentlichen.
OnionProxyausgehendStabile 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:

  1. Er liest den Seed (dein Secret, GitOps-gepflegt, wird nie evictet) und den zuvor kuratierten Satz.
  2. Er liest seine eigene Per-Bridge-Gesundheit — dreiwertig: healthy / dead / unknown, abgeleitet aus Tors ORCONN-Events.
  3. Er evictet nur Nicht-Seed-Bridges, die er aktiv als dead beobachtet hat (länger als evictGraceSeconds). Leerlaufende, aber erreichbare Reserven bleiben — kein Wegräumen gesunder Spares.
  4. Sind weniger als minHealthy Bridges nicht-tot, holt er (rate-limitiert) neue vom HTTPS-Distributor, parst dessen HTML/Entities defensiv und füllt bis maxBridges auf.
  5. Er wendet den kuratierten Satz per SETCONF auf 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.Published spiegelt einen echten HS_DESC-Upload des Descriptors an die HSDirs — die Onion ist also wirklich auffindbar, nicht bloß intern registriert.
  • OnionProxy.TargetsReachable kommt 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.
  • TorDaemon trägt TorBootstrapped, einen dreiwertigen status.bridges-Block pro Bridge und onionsPublished.

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.