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:
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:

  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 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.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 einer aktiven Probe: Der Agent öffnet periodisch einen echten 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 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.