Es gibt diesen einen Moment beim Remote-Support, den jeder kennt: Jemand sitzt vor einem kaputten Terminal, beschreibt am Telefon umständlich, was auf dem Schirm steht, und tippt dann doch wieder das Falsche. Was ich in dem Moment will, ist keine 478-MB-Electron-Bildschirm-Sharing-Software — ich will sein Terminal sehen, am besten mitschreiben können, und zwar in dem Augenblick, in dem es brennt. Genau dafür gibt es tmate . tmate ist großartig. tmate ist aber auch ein Stück Software, dem ich nicht mehr blind vertrauen wollte. Dieser Beitrag erzählt, warum ich mir stattdessen mein eigenes Werkzeug gebaut habe — choom1 —, was es kann, wie man es benutzt, und wo seine ehrliche Grenze liegt.
Was tmate so genial macht
tmate („teammate") löst ein Problem, das technisch unangenehmer ist, als es klingt: zwei Menschen sollen sich dasselbe Terminal teilen, ohne dass einer von ihnen einen offenen Port, eine feste IP oder VPN-Gefummel braucht. Das ist die NAT-Hölle in Reinform — beide Seiten sitzen hinter Routern, die keine eingehenden Verbindungen durchlassen.
tmate löst das mit einem zentralen Relay. Beide Parteien verbinden sich ausgehend zu einem öffentlichen Server (tmate.io betreibt einen), und der Server vermittelt zwischen ihnen. Kein Port-Forwarding, kein NAT-Traversal-Voodoo, keine Installation beim Gegenüber: tmate druckt einen ssh-Befehl, der Andere fügt ihn in sein Terminal ein, fertig. Der Viewer braucht nichts außer einem stinknormalen ssh-Client — den hat jeder.
Die Anwendungsfälle, für die ich es jahrelang geliebt habe:
- Pair-Programming über Kontinente hinweg, in echtem Terminal-Tempo statt durch eine matschige Videokonferenz.
- Remote-Support: read-only teilen, der Andere schaut zu — oder read-write, und ich greife selbst ein.
- Schnelles „schau mal hier" ohne tmux-Server, ohne Screenshare, ohne dass jemand seinen Desktop spiegelt.
Das Bedienkonzept — ausgehende Verbindung zum Relay, Viewer per stock ssh, read-only/read-write per Link — ist schlicht richtig. Daran habe ich nie etwas auszusetzen gehabt.
Warum ich tmate trotzdem nicht mehr wollte
Das Problem ist nicht das Konzept. Das Problem ist, was tmate ist und wie es das tut.
tmate ist kein Sharing-Werkzeug, das nebenbei tmux spricht — es ist ein kompletter Fork von tmux. Der ganze Multiplexer: Panes, Windows, Copy-Mode, Status-Zeile, Config-Sprache, das gesamte HTML5-Rendering. Rund 50.000 Zeilen C, abgezweigt von einer tmux-Version, die längst nicht mehr aktuell ist. Wer tmate einsetzt, schleppt einen veralteten tmux-Fork mit — samt aller Eigenheiten, die seit dem Fork-Punkt im echten tmux gefixt wurden und hier nicht.
Dazu die Abhängigkeiten: tmate hängt an libssh, libevent und msgpack — drei C-Bibliotheken, die alle Teil der Angriffsfläche sind, jede mit eigener CVE-Historie. Und der für mich entscheidende Punkt:
root mit SYS_ADMIN. Um jede Session in einen eigenen Namespace zu sperren (Session-Jailing), braucht der Server erweiterte Capabilities. Ein öffentlich erreichbarer Dienst, der fremde Verbindungen annimmt und dafür root-Rechte plus CAP_SYS_ADMIN hält — das ist exakt die Sorte Ziel, vor der man sonst warnt. Selbst betreiben mochte ich das nicht.Wenn ich die Liste zusammenziehe: alte Codebasis, alter tmux-Fork, große C-Angriffsfläche, privilegierter Relay. Für ein Werkzeug, das ich auf einem öffentlichen Server laufen lasse und Fremden den Verbindungsbefehl gebe, ist mir das schlicht zu viel Vertrauen in zu viel Code.
Die schlanke Python-Reimplementierung — mate
Die Einsicht ist nicht neu. Schon vor einer ganzen Weile habe ich mir gedacht: Ich brauche von tmate eigentlich nur den Sharing-Kern. Panes? Macht tmux selbst, wenn ich es innerhalb der geteilten Shell starte. Windows, Copy-Mode, Config-Sprache? Alles Multiplexer-Features, die mit dem Teilen eines Terminals nichts zu tun haben.
Also habe ich eine schlanke Reimplementierung in Python gebaut — asyncssh für den SSH-Transport, pyte als Terminal-Emulator für das serverseitige Screen-Modell, aiohttp für die Browser-Ansicht. Sie behält genau eine Sache: einen Host teilt seine Shell über ein Relay, Viewer hängen sich per ssh dran. Kein tmux-Fork, keine C-Bibliotheken, ein paar hundert Zeilen statt fünfzigtausend. Diese Python-Version (die 1.x-Linie) lief, tat ihren Dienst und ist bis heute als funktionaler Referenzpunkt erhalten.
Aber Python hat seinen Preis: ein Interpreter plus Dependencies als Laufzeit, keine schöne Single-Binary, und für ein sicherheitskritisches Stück Netzwerk-Infrastruktur, das fremde Verbindungen annimmt, wollte ich am Ende stärkere Garantien als „dynamisch getypt und hoffentlich keine Race-Condition im async-Code".
Der Schritt nach Rust: choom 2.0
Also habe ich die Reimplementierung nach Rust portiert — und das ist kein Eins-zu-eins-Port, sondern ein Neuschnitt mit drei kompromisslosen Zielen:
- Sicherheit zuerst. Memory-Safety durch die Sprache, ein unprivilegierter Relay (kein root, keine Capabilities, kein Namespace-Jailing — der Relay führt nie fremden Code aus, er schiebt nur Bytes), und ein bewusst kleiner Krypto-Footprint:
russhmit demring-Backend statt der defaultaws-lc-rs, EC-only (ed25519/ecdsa, kein RSA), kein zlib. Das wirft allein die ~720 KB große C-Bibliothekaws-lc-sysraus. - Schlanke Codebasis. Vier kleine Crates mit klaren Grenzen statt eines Monolithen — Protokoll, Server, Host, CLI. Was ein tmux-Fork an Masse mitbringt, existiert hier gar nicht erst.
- Kleine Binary. Eine einzige statische musl-Binary, release-Profil auf Größe getrimmt (
opt-level = "z", LTO,strip,panic = "abort"). Ergebnis: rund 3,6 MB Binary, die alsscratch-Container etwa 4 MB wiegt — nichts drin außer dem Programm, laufend als uid10001.
choom gegen tmate, nüchtern
| choom | tmate | |
|---|---|---|
| Kern | nur Sharing-Relay | tmux-Fork (voller Multiplexer) |
| Implementierung | Rust (eine statische Binary) | ~50k LOC C |
| Server-Abhängigkeiten | keine besonderen (reiner Byte-Relay) | libssh, libevent, msgpack |
| Server-Privileg | unprivilegiert | root + SYS_ADMIN (Session-Jailing) |
| Viewer | stock ssh / Browser | stock ssh / Browser |
| Panes / Windows / Copy-Mode | nein — tmux innen laufen lassen | ja |
| Web-Ansicht | read-only | read-only + read-write |
Der bewusste Verzicht ist das Feature: choom kann kein tmux. Wer Panes und Windows will, startet tmux innerhalb der geteilten Shell — dann teilt choom eben ein Terminal, in dem tmux läuft. Der Multiplexer gehört nicht ins Sharing-Werkzeug.
Wie es funktioniert
Drei Rollen, eine Binary (choom server|host|clients), Viewer nutzen stock ssh:
host relay (choom server) viewer
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ choom host │ SSH │ sessions │<───────>│ stock ssh │
│ │<════════════>│ + avt screen │ tok/PTY │ (ro / rw) │
│ $SHELL in │ postcard │ + recorder │ └──────────────┘
│ a local PTY │ │ + web │ WS ┌──────────────┐
└──────────────┘ │ │────────>│ browser RO │
└──────────────────┘ └──────────────┘
Der entscheidende Satz: Die Shell läuft auf dem Host. Das Relay leitet nur Bytes weiter — es führt nie eine Shell aus, braucht also weder chroot noch Namespaces und läuft unprivilegiert. Das ist der ganze Grund, warum chooms Relay ohne root auskommt und tmates nicht.
Der Host öffnet eine SSH-Verbindung zum Relay und darüber genau einen Kanal, auf dem ein typisiertes, längen-delimitiertes Frame-Protokoll läuft (postcard
-kodiert): Output (Shell → Relay), Input (Keystrokes von read-write-Viewern → Host), Control (Hello/Resize/Join/Record/…). Viewer verbinden sich mit dem Token als SSH-Username — die Rolle entscheidet sich am Username: new/resume/query sind Host-Operationen (Public-Key-Auth), alles andere ist ein Viewer-Token.
Damit ein Viewer, der mitten in einer vim-Session dazustößt, nicht auf einen kaputten Bildschirm schaut, füttert jede Session den Host-Output durch ein avt
-Screen-Modell (asciinemas Terminal-Emulator). Beim Join rendert das Relay einen sauberen ANSI-Snapshot, schickt den zuerst und streamt dann live weiter. Dasselbe avt-Modell speist auch die Aufnahme und die Web-Ansicht — alle drei stimmen per Konstruktion überein.
Benutzung
Teilen (Host)
Eine Shell auf dem Standard-Relay teilen — der Host druckt die Links:
1choom host
Welcome, Choomba, your shell is now shared!
Share a link below; viewers connect with their ssh client.
Read-only: ssh <ro-token>@choom.sh
Read-write: ssh <rw-token>@choom.sh (full shell access!)
Web (RO): https://choom.sh/s/<ro-token>
Der Banner druckt den exakten ssh-Befehl. Zwei Tokens: ein read-only-Link (der Andere schaut nur zu) und ein read-write-Link (voller Shell-Zugriff — sparsam herausgeben!). Dazu ein Web-Link für reine Mitleser im Browser.
Zuschauen (Viewer)
Der Viewer braucht nichts zu installieren — stock ssh reicht:
1ssh <rw-token>@choom.sh
ssh, kein Wrapper. Terminal-ssh-Wrapper, die beim Verbinden die Remote-Shell bootstrappen — allen voran kitty’s ssh-Kitten — funktionieren als Viewer nicht: sie laden beim Start terminfo/Shell-Integration durch das Terminal hoch und nehmen an, sie kontrollierten den Start der Remote-Shell — was ein Relay (das eine bereits laufende Shell weiterreicht) nicht bieten kann. choom erkennt solche Clients und trennt diesen einen Viewer mit einem Hinweis, statt den Host fluten zu lassen. Ist dein ssh auf das Kitten aliased, ruf die echte Binary auf (\ssh oder /usr/bin/ssh).Die geteilte Größe richtet sich nach dem kleinsten read-write-Viewer, damit jeder den ganzen Schirm sieht. Read-only- und Web-Viewer verkleinern den Host nie — so kann kein Mitleser per winzigem Fenster den Host-Schirm zusammenschrumpfen (kein Resize-DoS).
Wer schaut zu?
Aus einem zweiten Terminal auf dem Host:
1choom clients
Listet die Viewer der laufenden Session — Modus (ro/rw), Transport (ssh/web), IP und Terminalgröße.
Browser-Ansicht
Wer keinen ssh-Client zur Hand hat, öffnet den Web-Link. Die Ansicht ist read-only und wird von einem selbst-gehosteten asciinema-player
live gerendert (vendored, kein CDN). Strukturell output-only: der WebSocket ignoriert jeden eingehenden Frame, ein Mitleser im Browser kann also gar nicht tippen.
Aufnehmen
Sessions lassen sich als asciicast v3 mitschneiden — opt-in pro Session, sofern das Relay Speicher dafür hat:
1choom host --record
Heruntergeladen wird die Aufnahme unter /s/<ro-token>/cast, abgespielt mit asciinema play. Auf der Web-Ansicht taucht ein Download-Button auf, sobald eine Aufnahme existiert — und die bleibt auch nach Session-Ende abrufbar (über einen persistenten Token→Aufnahme-Index), bis sie per Retention von der Platte fällt. Dateien werden 0600 geschrieben und nach Alter/Größe gepruned.
Robust gegen Wackler
Fällt die Host-Verbindung kurz weg, überlebt die Session eine Grace-Period (60 s): Tokens bleiben gültig, Viewer bleiben dran, die Shell läuft weiter. Der Host fährt eine Reconnect-Schleife und nimmt mit seiner reconnect_id (an den ursprünglichen Owner-Key gebunden) den Faden wieder auf. Viewer sehen währenddessen einen Hinweis statt eines eingefrorenen Schirms.
Den öffentlichen Server nutzen: choom.sh
Ich betreibe einen öffentlichen Relay unter choom.sh. Der Clou: er lauscht auf Port 22, dem Standard-SSH-Port. Das hat zwei angenehme Folgen.
Erstens „funktioniert" choom host ohne jede Konfiguration — choom.sh ist der eingebaute Default-Server, und weil er auf Port 22 läuft, sind die ausgedruckten Share-Links portlos:
1choom host # zielt automatisch auf choom.sh:22
Zweitens braucht der Viewer kein -p:
1ssh <token>@choom.sh # kein -p nötig — Port 22 ist der Default
choom.sh auf 22. Ein selbstgehosteter Relay auf 2200 wird also als ssh -p 2200 <token>@host geteilt; bei choom.sh fällt das -p weg. Tippt jemand ssh <token>@host ohne den richtigen Port, landet er auf dem gewöhnlichen sshd auf Port 22 und bekommt eine Passwort-Abfrage — genau das, was die Port-22-Wahl bei choom.sh vermeidet.choom.sh vertraut dem gebündelten Default-Key — jeder mit dem Standard-Client kann dort hosten. Das ist der Convenience-Modus eines öffentlichen Relays, und genau hier muss ich über das Vertrauensmodell ehrlich sein.
Lieber den eigenen Relay
Wer etwas Sensibles teilt, hostet besser auf einem privaten Relay, der nur die eigenen Keys zulässt:
1# privat: Hosts per eigenem Key gaten
2cat ~/.ssh/id_ed25519.pub >> authorized_keys
3choom server -H relay.example.com --no-default-key -a authorized_keys
4choom host -s relay.example.com --no-default-key -i ~/.ssh/id_ed25519
Das Relay ist eine einzige unprivilegierte Binary — kein root, keine Capabilities, kein Zustand auf Platte außer optionalen Aufnahmen. Es läuft als scratch-Container (~4 MB), als bare Binary oder als gehärteter NixOS-systemd-Dienst (DynamicUser, ProtectSystem=strict, keine Capabilities, MemoryDenyWriteExecute). Auf einen öffentlichen Relay gehört zwingend TLS vor den Web-Port — der read-only-Web-Link trägt das Token in der URL, das darf nie im Klartext über die Leitung.
Die ehrliche Grenze: trust the operator
Jetzt der Teil, den ich nicht wegmoderiere, weil er bauartbedingt ist.
Das ist keine Nachlässigkeit, sondern eine direkte Folge der Funktionsweise. Damit ein Viewer stock ssh benutzen kann — der Komfort, der das ganze Werkzeug erst nützlich macht —, ist das Relay sein SSH-Server. Es terminiert dessen Transport und sieht damit zwangsläufig Klartext. Solange auch nur ein Viewer mit gewöhnlichem ssh teilnimmt, muss das Relay Klartext produzieren. Komfort und E2E stehen hier in direktem Widerspruch.
Was choom heute leistet, ist deshalb die Härtung innerhalb dieses Modells: read-only-Viewer können nicht ausbrechen (Input verworfen, TCP/Unix/X11-Forwarding und exec/Subsystem verweigert, Web strukturell output-only), Viewer-Tokens sind 128-bit und nicht zu erraten (kein Session-Hijacking), pro-IP-Rate-Limit und Session/Viewer-Caps, und der Host kann den Host-Key-Fingerprint des Relays gegen MITM verifizieren (--fingerprint SHA256:…). Das schützt vor anderen Viewern und vor dem Netz — nicht vor dem Operator.
Ausblick: ein nativer Client mit echtem E2E
Die saubere Antwort auf „trust the operator" ist nicht, das Vertrauensmodell schönzureden, sondern es optional aufzubrechen. Genau dafür habe ich einen nativen Client mit echter Ende-zu-Ende-Verschlüsselung entworfen — das Relay wird dabei zum blinden Weiterleiter, der nur Chiffretext sieht, den er weder lesen noch fälschen kann.
Die Kernidee: E2E ist pro Session und schließt stock ssh aus. Sobald irgendein gewöhnlicher ssh-Viewer dranhängt, müsste das Relay Klartext erzeugen — kein E2E-Gewinn. Eine choom host --e2e-Session emittiert deshalb nur Chiffretext, und ihre Viewer müssen alle den nativen choom view-Client benutzen, der den Schlüssel hält.
host (choom host --e2e) relay (blind forwarder) viewer (choom view)
PTY ─> avt + AEAD encrypt ──> opaque blobs (+keyframe cache) ──> AEAD decrypt ─> terminal
PTY <── AEAD decrypt <─────── opaque blobs <─────────────────── local keys ─> AEAD encrypt
Die Eckpunkte des Designs:
- Transport bleibt SSH. Der native Client verbindet sich wie jeder Viewer (Token-als-Username) — das wiederverwendet Auth, Rate-Limiting und Framing des Relays. Der Relay entschlüsselt zwar wie immer den SSH-Transport, aber die Payload darin ist E2E-Chiffretext, also für ihn opak. Kein neuer Port, kein neuer Transport.
- Schlüssel im Link-Fragment. Der Host würfelt pro Session einen frischen 256-bit-Schlüssel (OS-RNG) und packt ihn ins URL-Fragment:
choom://<token>@relay.sh#k=<base64url-key>. Wer den Link hat, kann entschlüsseln — dasselbe Vertrauensmodell wie die heutigen Tokens —, und das Relay sieht das Fragment nie (der Client parst es lokal, es geht nie über die Leitung). - AEAD aus reinem Rust. ChaCha20-Poly1305 von RustCrypto — keine neuen C-Abhängigkeiten, die Binary bleibt schlank. 96-bit-Nonce als per-Richtung-Zähler (nie wiederverwendet), eine Sequenznummer als AAD, damit der Empfänger Reordering/Replay erkennt, und getrennte Sub-Keys (HKDF) für Output und Input, damit die Richtungen nicht spleißbar sind.
- Keyframes statt Relay-Rendering. Ein E2E-Relay kann kein
avtrendern (es hat keinen Klartext). Stattdessen läuft dasavtlokal auf dem Host, der periodisch ein verschlüsseltes Keyframe (Vollbild-Repaint) emittiert; das Relay cached nur das letzte Keyframe-Chiffrat plus die folgenden Deltas und spielt sie einem neu dazustoßenden Viewer vor.
Das Relay hält damit keinen Schlüssel → es kann den Stream weder lesen noch Input fälschen (Input ist AEAD-authentifiziert). Es bleibt ihm nur, Daten zu droppen — also Denial-of-Service, was jedem Forwarder inhärent ist und sich nicht wegkryptografieren lässt.
Geplant ist das in Phasen: zuerst read-only natives E2E (validiert Krypto und Keyframes Ende-zu-Ende), dann der verschlüsselte read-write-Input-Pfad, zuletzt Web-E2E per WebCrypto mit Schlüssel aus dem URL-Fragment. Implementiert ist es noch nicht — es ist ein durchdachter Entwurf, kein Versprechen. Aber es ist die Richtung, in die choom geht: erst der schlanke, ehrliche Kern, dann die kryptografische Garantie obendrauf — ohne je den alten Ballast mitzuschleppen, vor dem ich bei tmate weggelaufen bin.
Nachtrag · 2026-06-16: der eigene Client fängt an — choom view, plus ein Härtungs-Durchgang
Kaum war dieser Artikel draußen, sind zwei Releases nachgekommen — 2.3.0 und 2.3.1 (beide 2026-06-16). Sie treffen genau die Stellen, über die ich oben geschrieben habe, deshalb der Nachtrag.
Der wichtigste Punkt zuerst: choom view ist da — und damit der erste Baustein des eigenen Clients aus dem Ausblick.
choom view ist der Startschuss für den nativen Client — E2E folgt darauf. In dieser ersten Stufe ist choom view <token | token@host> bewusst bodenständig: ein Client, der den passenden -p <port> selbst einsetzt und das echte ssh-Binary exect — was nebenbei die Shell-Wrapper umgeht, an denen stock-ssh-Viewer scheitern (kitty’s ssh-Kitten). Noch terminiert der Relay den Transport, sieht also weiter Klartext. Aber der Einstiegspunkt steht jetzt: An genau diesem choom view hängt sich als nächste Phase die Ende-zu-Ende-Verschlüsselung aus dem Abschnitt oben — eigener Client zuerst, blinder Relay danach.Was sonst in 2.3.0 kam und meine Abschnitte oben ergänzt:
- Read-only-Sessions (
choom host --read-only): der Relay gibt nur den RO-Link aus, der RW-Token wird gar nicht erst beworben und auf RO-Zugriff gemappt — selbst ein geleakter RW-Token kann dann nur zuschauen. Eine harte Garantie, nicht bloß Nicht-Veröffentlichung. - Benannte Sessions (
--name/-n): ein menschenlesbares Label im Host-Banner, in den Relay-Logs und beichoom clients. - Live-Viewer-Status: ein Join/Leave-Hinweis mit aktueller Viewer-Zahl bei attachtem Terminal, ein In-Place-Statusblock im
--detach-Modus undchoom clients --watch, das den Roster wiewatch(1)aktualisiert. GET /healthzam Web-Listener:200im Betrieb,503sobald der Relay drainet (SIGTERM) — als HTTP-Readiness/Liveness-Probe.
Der Härtungs-Durchgang über die Web-Fläche (das systematische Abklopfen, das ich mir vorgenommen hatte):
- Stored-XSS-Schutz auf dem
/download-Index: Dateinamen werden HTML-escaped, und/download/{file}-Namen sind auf[A-Za-z0-9._-]whitelisted — das blockt in einem Aufwasch Path-Traversal undContent-Disposition/CR-LF-Header-Injection. - Downloads streamen von der Platte (
/s/<token>/cast,/download/{file}) statt komplett in den RAM gelesen zu werden, mit gedeckeltem Concurrency-Pool — die Web-Ansicht lässt sich so nicht als Memory-/Bandbreiten-Amplifier missbrauchen. - CSP verschärft:
object-src/base-uri/frame-ancestors 'none'. - Rate-Limiter-Map begrenzt: veraltete per-IP-Einträge werden weggekehrt, damit ein Wechsel-Schwall von Quell-IPs sie nicht unbegrenzt wachsen lässt (langsamer Memory-DoS).
/metricsist endgültig vom öffentlichen Web-Port runter auf einen operator-internen Listener (--metrics-addr, default aus). Wer scrapt, muss jetzt dorthin zeigen — und die unauthentifizierte Exposition am Web-Port ist damit weg.
2.3.1 hat dann den In-Browser-Replay geradegezogen: Aufnahmen werden jetzt als asciicast v2 geschrieben (nicht mehr v3) — der eingebettete asciinema-Player ist ein v2-Build und konnte v3 aus einer URL nicht parsen, der Replay drehte sich endlos. asciinema play liest beides. Und die Viewer-Seite entscheidet live-vs-replay nicht mehr über eine Wegwerf-Probe-WebSocket (die den Host-Roster mit Join/Leave-Geflacker durchschüttelte), sondern über ein einzelnes GET /s/<token>/status → { live, recording }.
Unterm Strich: der ehrliche Kern aus dem Artikel steht unverändert, drumherum ist die Bedienung runder (view, benannte und read-only Sessions, Live-Status) und die Web-Fläche deutlich dichter. Der eine große offene Punkt bleibt derselbe — echtes E2E. Nur ist er jetzt kein reiner Entwurf mehr: choom view ist der Haken, an dem er hängen wird.
choom ist Rust, GPL-3.0-or-later, und lebt in meinem Radicle-Seed
. Eine einzige Binary für alle drei Rollen, ein ~4-MB-Container, ein Relay ohne root. Wer mitspielen will: choom host, Link teilen, fertig — choom.sh steht.
Der Name ist gewandert. Die Python-Version hieß noch mate — tmate ohne das tmux, also schlicht „mate". Aus mate wurde dann choom: Cyberpunk-2077-Slang (von choomba/choombatta) für einen Freund oder Kumpel — passend für ein Werkzeug, dessen einziger Zweck es ist, jemanden an deine Shell zu lassen. ↩︎
