Wie wir SSH, Mosh und Eternal Terminal in eine Flutter-App gebracht haben

In Wendo ist der SSH-Client für Flutter nicht in Dart geschrieben. Die App spricht über einen Platform Channel mit einem eigenen Plugin, packages/wendo_ssh, und jedes System bringt seine eigene Bibliothek mit: JSch auf Android und NMSSH (libssh2) auf iOS. Mosh und Eternal Terminal stammen aus keiner Bibliothek. Beide Protokolle haben wir selbst in Kotlin implementiert, und vorerst gibt es sie nur auf Android. Hier erzählen wir, wie das aufgebaut ist, welche Probleme wir unterwegs lösen mussten und welche Kompromisse bleiben.
Einen SSH-Client in Flutter bauen: natives Plugin mit kleinem Vertrag
Das Plugin liegt in packages/wendo_ssh, und die Dart-Seite ist schlank: ein MethodChannel für Befehle und ein EventChannel für alles, was vom Server kommt.
static const _methods = MethodChannel('wendo_ssh');
static const _events = EventChannel('wendo_ssh/events');
Future<ConnectResult> connect(ConnectOptions options) async {
final map = (await _call<Map>('connect', {'options': options.toMap()}))!;
...
}
connect, write, resize, exec, forwardPort und disconnect sind für alle drei Transporte gleich. Die Events (onData, onClose, onTransportStatus) kommen aus einem einzigen nativen Stream, den WendoSsh nach Namen verteilt. Zwei Details, über die wir froh sind: Die Bytes vom Server erreichen Dart als Uint8List, ohne Base64 dazwischen, und Fehler kommen als SshException mit einem code an (ERR_AUTH, ERR_MOSH_UDP, ERR_ET_GONE…), den die Oberfläche in eine brauchbare Meldung übersetzt.
Auf der nativen Seite läuft alles, was blockiert, abseits des Main Threads: auf Android mit Coroutines in Dispatchers.IO, auf iOS mit einer eigenen DispatchQueue (wendo.ssh.io). Die Events kommen immer über den Main Thread zurück, so verlangt es der EventChannel. Auf iOS mussten wir eine Funktion ergänzen, die Optional-Werte innerhalb eines Any auspackt, weil der Codec des Channels sie nicht annimmt.
SSH auf Android: die Bibliothek JSch
Auf Android nutzen wir den gepflegten Fork von JSch (com.github.mwiede:jsch) mit BouncyCastle. Android ignoriert META-INF/versions, also landet JSch bei seinen Java-8-Klassen, und die reichen ed25519, curve25519 und ML-KEM an BouncyCastle weiter. Ohne diese Abhängigkeit würden die ed25519-Schlüssel, die die App erzeugt, nicht funktionieren.
Es gibt einen Fehler, der nur im Release-Build auftaucht. JSch und BouncyCastle laden Klassen über ihren Namen, und R8 entfernt sie, weil es niemanden sieht, der sie benutzt. Wer nach „Flutter SSH Client“ sucht, stößt auf der Seite des Pakets ssh2 auf dasselbe Problem (eine ClassNotFoundException für com.jcraft.jsch.jce.Random). In wendo_ssh stehen die Regeln in consumer-rules.pro, sodass jede App, die das Plugin nutzt, sie erbt, ohne etwas anzufassen.
Drei weitere Details aus SshSession.kt, die in keinem Beispiel auftauchen:
- Jedes
getInputStream()von JSch erzeugt eine neue Pipe. Man muss es vorchannel.connect()anfordern, sonst geht verloren, was der Server zuerst schickt (der Prompt, die MOTD). sshderlaubt 10 Kanäle pro Verbindung (MaxSessions), und Shell, Weiterleitungen und Agent belegen schon einige davon. Mit vielen gleichzeitigenexeckam „channel is not opened“, deshalb sind sie begrenzt.available()alle 20 ms abzufragen, schaffte nicht einmal 100 KB/s, und die Chat-Ansicht liest Hunderte KB Transkript. Wir sind auf blockierende Lesevorgänge umgestiegen.
SSH auf iOS: die Bibliothek NMSSH
Auf iOS läuft SSH über NMSSH 2.3, das libssh2 1.8.0 mitbringt. Die App deklariert es in ihrem Podfile mit :modular_headers => true, damit es sich aus Swift importieren lässt. Die alte libssh2-Version hat Folgen. Agent-Forwarding braucht den Callback LIBSSH2_CALLBACK_AUTHAGENT, der mit libssh2 1.9.0 kam; NMSSHAccessors.m registriert ihn nur, wenn der Header ihn definiert, und kompiliert sonst zu einer leeren Funktion. Um an den rohen libssh2-Kanal zu kommen, den NMSSH in einer privaten Property hält, liest dieselbe Datei die Instanzvariable mit ivar_getOffset.
Außerdem mussten wir bcrypt_pbkdf in Swift schreiben (BcryptPbkdf.swift, nach dem Vorbild von OpenSSH), um mit Passphrase geschützte private OpenSSH-Schlüssel zu entschlüsseln. Und NMSSH schließt den Kanal, bevor es das Ergebnis eines Befehls zurückgibt, deshalb liefert libssh2_channel_get_exit_status fast immer 0. Der Code prüft den error von NMSSH, damit ein fehlgeschlagener Befehl nicht wie ein Erfolg aussieht.
Der arm64-Simulator
Die Bibliotheken von NMSSH enthalten keine arm64-Variante für den Simulator, und genau die braucht ein Mac mit Apple Silicon. Die Lösung steckt im post_install des Podfile: Mit WENDO_SSH_SIM_LIBS=1 tauscht es die .a-Dateien von NMSSH gegen libssh2 1.11.1 und OpenSSL 3.4.0 aus, gebaut für arm64-ios-simulator und abgelegt in packages/wendo_ssh/ios/libs, und schließt x86_64 aus allen Targets aus.
(cd ios && WENDO_SSH_SIM_LIBS=1 pod install) && WENDO_SSH_SIM_LIBS=1 fvm flutter build ios --simulator --debug
Diese Bibliotheken bringen den Agent-Callback mit, im Simulator funktioniert das Forwarding also. Auf einem Gerät werden sie nicht gelinkt: Dort baut man ohne die Variable.
Mosh in Flutter: in Kotlin geschrieben, nur auf Android
Mosh nutzt SSH nur zum Start. Wendo öffnet eine Verbindung mit JSch und startet den Server so (MoshBootstrap.command):
sh -lc 'PATH="$PATH:/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin"; export PATH; echo "WENDO_SSH_CONNECTION=$SSH_CONNECTION"; exec mosh-server new -s -c 256 -l LANG=en_US.UTF-8 --'
Alles geht in einfachen Anführungszeichen an sh, weil die Shell des Nutzers fish sein kann. Die Zeile mit $SSH_CONNECTION ist für die Option „Server-IP für UDP verwenden“ da. Hat der Server die Locale en_US.UTF-8 nicht, wird es mit C.UTF-8 erneut versucht. Aus der Ausgabe lesen wir MOSH CONNECT <Port> <Schlüssel>, und ab da läuft alles über UDP.
Der Client implementiert das Protokoll von Mosh 1.4 in Kotlin und hält sich dabei an network.cc und die Transportdateien: AES-128-OCB-Pakete mit BouncyCastle (MoshCrypto.kt), die vier nötigen Protobuf-Nachrichten von Hand geschrieben (MoshProto.kt), Fragmentierung nach MTU, Acks, Neuübertragungen und Port-Hopping. Gibt es eine Weile keinen Roundtrip, öffnet der Client einen neuen Socket mit einem anderen Quellport, und der Server folgt, sobald Pakete von der neuen Adresse ankommen. So übersteht die Sitzung den Wechsel von WLAN auf Mobilfunk.
Der Bildschirm ist der Teil mit den meisten Fallstricken. Mosh schickt nicht den Bytestrom der Anwendung, sondern die Differenz zwischen zwei Bildschirmen, und diese Differenz gilt nur für den Zustand, von dem sie ausgeht. MoshScreen.kt hält einen Schattenbildschirm, der nur den Dialekt interpretiert, den Display::new_frame ausgibt, ohne ein vollständiger Emulator zu sein. Darüber liegt MoshPrediction.kt, unsere Implementierung des vorausschauenden Echos (PredictionEngine) in den Modi adaptive, always und never. Vor jedem Diff des Servers müssen die Vorhersagen entfernt und danach im selben Block neu gezeichnet werden, damit xterm nichts Halbfertiges zeigt.
Zwei Details noch. mosh-server geht davon aus, dass das Terminal die Pfeiltasten im Application-Modus sendet, also macht cursorKeysToSs3 aus ESC [ A ein ESC O A; sonst erkennen vim oder tmux sie nicht. Und kommt das erste UDP-Paket nicht an (6 s mit Auto, 12 s, wenn Mosh von Hand gewählt ist), wird der gerade gestartete mosh-server beendet, und die Fehlermeldung rät, die Firewall zu prüfen.
Die JVM-Tests (MoshServerTest.kt) sprechen mit einem echten lokalen mosh-server 1.4, auch über eine simulierte Verbindung mit 120 ms pro Richtung, um zu prüfen, dass xterm nach dem Tippen genau den Bildschirm des Servers zeigt.
Eternal Terminal und eine App, die das System beendet
Eine Mosh-Sitzung stirbt mit dem Prozess der App, und kein Design verhindert das. Der Server hebt nichts auf, um den Client wieder auf Stand zu bringen, und die OCB-Nonce ist die Sequenznummer des Pakets, die in jedem Prozess bei 0 beginnt. Eine Wiederaufnahme würde Nonces mit demselben Schlüssel wiederholen, und bei OCB verrät das den Klartext und bricht die Authentifizierung. Deshalb speichert Wendo den Zustand von Mosh nie.
Eternal Terminal 7.0 lässt sich dagegen wieder aufnehmen, und auch dieser Client ist Kotlin: EtClient.kt, mit der Verschlüsselung crypto_secretbox (XSalsa20-Poly1305) auf Basis von BouncyCastle. Bricht TCP ab, verbindet er sich jede Sekunde neu, und jede Seite meldet, wie viele Pakete sie gelesen hat, damit die andere die fehlenden erneut schickt. Damit die Sitzung übersteht, dass Android die App beendet, legt EtStateStore.kt in den privaten Dateien der App die ID und den Schlüssel der Sitzung ab, versiegelt mit einem AES-256-GCM-Schlüssel aus dem Android Keystore, dazu ein Protokoll jedes verschlüsselten Pakets, geschrieben, bevor es den Socket verlässt. Die ET-Nonce ist ein fortlaufender Zähler ohne Lücken, und dieses vorab geschriebene Protokoll garantiert, dass die nächste Nonce stimmt. Öffnest du die App wieder, gleicht restoreRecoverableSessions() ab, was die native Seite gespeichert hat und welche Karten in Dart liegen, und die Sitzungen tauchen wieder unter Start auf. In der App ist ET weiterhin als experimentell markiert.
Mosh und ET brauchen außerdem einen SSH-Kanal für exec und Portweiterleitungen. SideConnection.kt behält die Verbindung vom Start, prüft sie aber vor jeder Nutzung. Ist sie beim Netzwechsel abgerissen, öffnet es eine neue mit denselben Fingerprints und stellt die Weiterleitungen auf denselben lokalen Ports wieder her.
Was die Dart-Seite macht
lib/core/sessions.dart speichert die Ausgabe jeder Sitzung (bis zu 512 KB), auch wenn kein Terminal offen ist, damit sie beim Zurückkehren neu gezeichnet werden kann. Bei Mosh wird das Bisherige verworfen, sobald ein vollständiger Bildschirm (reset) ankommt. Das Event onTransportStatus liefert silentMs und rebindInMs, und damit warnt die Oberfläche, dass der Server nicht antwortet, statt die Sitzung zu schließen. Schaltest du „Im Hintergrund aktiv halten“ aus, werden SSH-Sitzungen geschlossen, sobald die App in den Hintergrund geht. Mosh- und ET-Sitzungen bleiben, weil ein Trennen die entfernte Sitzung beenden würde.
Der Auto-Modus steckt in openSession(). Er probiert Mosh, dann ET nur, wenn ein Port konfiguriert ist oder auf 2022 etwas antwortet (damit sich keine langen Wartezeiten aufaddieren), und zuletzt SSH. Mit Jump Host oder Agent-Forwarding geht er direkt zu SSH, und ein Authentifizierungs- oder Fingerprint-Fehler führt nicht zum nächsten Transport, weil SSH genauso scheitern würde.
Die Kompromisse, die bleiben
- Auf iOS gibt es nur SSH. Wer Mosh oder ET anfordert, bekommt
ERR_MOSH_UNSUPPORTEDoderERR_ET_UNSUPPORTED, und Auto geht direkt zu SSH. - Weder Mosh noch ET unterstützen einen Jump Host.
- Mosh synchronisiert nur, was auf dem Bildschirm sichtbar ist. Für den Verlauf nach oben arbeitest du in tmux, wie es die Dokumentation erklärt.
- Mosh und ET gehören zu Wendo Pro; ohne Pro lässt
limitToPlandie Verbindung bei SSH.
Wenn du nicht wissen willst, wie das gebaut ist, sondern einen Transport wählen möchtest, um Claude Code vom Handy aus zu verfolgen, findest du den praktischen Vergleich unter Mosh oder SSH auf dem Handy und die Felder jeder Verbindung in der Dokumentation zu Sitzungen, die Abbrüche überstehen. Wie die App dasselbe SSH ohne eigenen Server für die Benachrichtigungen nutzt, erzählen wir in eine mobile App ohne Backend.