LirafyBlogWendo ↗
Nederlands

Blog

Hoe we SSH, Mosh en Eternal Terminal in een Flutter-app hebben gestopt

In Wendo is de SSH-client in Flutter niet in Dart geschreven. De app praat via een platform channel met een eigen plugin, packages/wendo_ssh, en elk systeem levert zijn eigen bibliotheek: JSch op Android en NMSSH (libssh2) op iOS. Mosh en Eternal Terminal komen uit geen enkele bibliotheek: beide protocollen hebben we zelf in Kotlin geïmplementeerd, en voorlopig bestaan ze alleen op Android. Hier lees je hoe het in elkaar zit, welke problemen we onderweg moesten oplossen en welke compromissen er nog liggen.

Een SSH-client in Flutter: native plugin met een klein contract

De plugin staat in packages/wendo_ssh, en de Dart-kant is dun: een MethodChannel voor de opdrachten en een EventChannel voor alles wat van de server komt.

De SSH van Wendo van binnen: de app in Dart praat via een platform channel met de plugin wendo_ssh, die op Android JSch, Mosh en ET gebruikt en op iOS NMSSH
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 en disconnect zijn hetzelfde voor alle drie de transporten. De events (onData, onClose, onTransportStatus) komen uit één native stroom die WendoSsh op naam verdeelt. Twee details die prettig zijn: de bytes van de server komen in Dart aan als Uint8List, zonder base64 ertussen, en fouten komen binnen als SshException met een code (ERR_AUTH, ERR_MOSH_UDP, ERR_ET_GONE…) die de interface vertaalt naar een bruikbare melding.

Aan de native kant gaat alles wat blokkeert van de main thread af: op Android met coroutines op Dispatchers.IO en op iOS met een eigen DispatchQueue (wendo.ssh.io). Events komen altijd terug via de main thread, want dat eist het EventChannel. Op iOS moesten we een functie toevoegen die Optional-waarden uitpakt die in een Any zitten, omdat de codec van het kanaal die niet accepteert.

Welke SSH-library je op Android gebruikt: JSch

Op Android gebruiken we de onderhouden fork van JSch (com.github.mwiede:jsch) met BouncyCastle. Android negeert META-INF/versions, dus JSch valt terug op zijn Java 8-klassen, en die laten ed25519, curve25519 en ML-KEM over aan BouncyCastle. Zonder die dependency zouden de ed25519-sleutels die de app genereert niet werken.

Er is een bug die alleen in de release-build opduikt. JSch en BouncyCastle laden klassen op naam, en R8 gooit ze weg omdat het niemand ziet die ze gebruikt. Zoek je op “SSH-client Flutter”, dan kom je hetzelfde probleem tegen op de pagina van het pakket ssh2 (een ClassNotFoundException voor com.jcraft.jsch.jce.Random). In wendo_ssh staan de regels in consumer-rules.pro, zodat een app die de plugin gebruikt ze automatisch erft.

Nog drie details uit SshSession.kt die je in geen enkel voorbeeld ziet:

  • Elke getInputStream() van JSch maakt een nieuwe pipe. Je moet hem opvragen vóór channel.connect(), anders mis je het eerste wat de server stuurt (de prompt, de MOTD).
  • sshd staat 10 kanalen per verbinding toe (MaxSessions), en de shell, de forwards en de agent nemen er al een paar in. Met veel exec-aanroepen tegelijk kregen we “channel is not opened”, dus die worden begrensd.
  • Lezen door elke 20 ms available() te pollen haalde nog geen 100 KB/s, en de chatweergave leest honderden KB aan transcript. We zijn overgestapt op blokkerende reads.

Welke SSH-library je op iOS gebruikt: NMSSH

Op iOS loopt SSH via NMSSH 2.3, dat libssh2 1.8.0 meebrengt. De app declareert het in de Podfile met :modular_headers => true zodat het vanuit Swift te importeren is. Zo’n oude libssh2 heeft gevolgen. Agent forwarding heeft de callback LIBSSH2_CALLBACK_AUTHAGENT nodig, die pas in libssh2 1.9.0 kwam. NMSSHAccessors.m registreert hem alleen als de header hem definieert, en compileert anders naar een lege functie. Om bij het ruwe libssh2-kanaal te komen dat NMSSH in een private property bewaart, leest datzelfde bestand de instance variable uit met ivar_getOffset.

We moesten ook bcrypt_pbkdf in Swift schrijven (BcryptPbkdf.swift, naar de versie van OpenSSH) om OpenSSH-privésleutels met een wachtwoordzin te ontsleutelen. En NMSSH sluit het kanaal voordat het het resultaat van een commando teruggeeft, waardoor libssh2_channel_get_exit_status bijna altijd 0 oplevert. De code kijkt daarom naar de error van NMSSH, zodat een mislukt commando niet als geslaagd overkomt.

De arm64-simulator

De bibliotheken van NMSSH hebben geen arm64-variant voor de simulator, en die gebruikt een Mac met Apple Silicon. De oplossing zit in de post_install van de Podfile: met WENDO_SSH_SIM_LIBS=1 vervangt die de .a-bestanden van NMSSH door libssh2 1.11.1 en OpenSSL 3.4.0, gecompileerd voor arm64-ios-simulator en te vinden in packages/wendo_ssh/ios/libs, en sluit hij x86_64 uit voor alle targets.

(cd ios && WENDO_SSH_SIM_LIBS=1 pod install) && WENDO_SSH_SIM_LIBS=1 fvm flutter build ios --simulator --debug

Die bibliotheken hebben de agent-callback wel, dus in de simulator werkt forwarding. Op een echt toestel worden ze niet gelinkt: daar bouw je zonder de variabele.

Mosh in Flutter: geschreven in Kotlin, alleen op Android

Mosh gebruikt SSH alleen om op te starten. Wendo opent een verbinding met JSch en start de server zo (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 gaat tussen enkele aanhalingstekens naar sh, omdat de shell van de gebruiker fish kan zijn. De regel met $SSH_CONNECTION is er voor de optie “Server-IP voor UDP gebruiken”. Heeft de server de locale en_US.UTF-8 niet, dan probeert hij het opnieuw met C.UTF-8. Uit de uitvoer wordt MOSH CONNECT <poort> <sleutel> gelezen, en vanaf dat moment gaat alles over UDP.

De client implementeert het protocol van Mosh 1.4 in Kotlin en volgt daarbij network.cc en de transportbestanden: AES-128-OCB-pakketten met BouncyCastle (MoshCrypto.kt), de vier benodigde protobuf-berichten met de hand geschreven (MoshProto.kt), fragmentatie op MTU, acks, hertransmissies en port hopping. Komt er een tijdje geen retour, dan opent de client een nieuwe socket met een andere bronpoort, en de server volgt zodra hij pakketten van het nieuwe adres ziet binnenkomen. Zo overleeft de sessie de overstap van wifi naar mobiele data.

Het scherm is het deel met de meeste valkuilen. Mosh stuurt niet de bytestroom van de applicatie, maar het verschil tussen twee schermen, en dat verschil klopt alleen ten opzichte van de toestand waar het van uitgaat. MoshScreen.kt houdt een schaduwscherm bij dat alleen het dialect interpreteert dat Display::new_frame uitstuurt, zonder een volledige emulator te zijn. Daarbovenop zit MoshPrediction.kt, onze implementatie van de voorspellende echo (PredictionEngine) in de modi adaptive, always en never. De voorspellingen moeten weg vóór elke diff van de server wordt toegepast en daarna opnieuw getekend worden, in hetzelfde blok, zodat xterm nooit iets half laat zien.

Nog twee details. mosh-server gaat ervan uit dat de terminal pijltjestoetsen in applicatiemodus stuurt, dus cursorKeysToSs3 zet ESC [ A om naar ESC O A; anders herkennen vim en tmux ze niet. En als het eerste UDP-pakket niet aankomt (6 s met Auto, 12 s als je Mosh zelf kiest), wordt de net gestarte mosh-server afgeschoten en zegt de foutmelding dat je de firewall moet nakijken.

De JVM-tests (MoshServerTest.kt) praten met een echte lokale mosh-server 1.4, inclusief een gesimuleerde verbinding met 120 ms vertraging in elke richting, om te controleren dat xterm na het typen precies het scherm van de server toont.

Eternal Terminal en een app die het systeem afschiet

Een Mosh-sessie sterft samen met het proces van de app, en geen enkel ontwerp voorkomt dat. De server bewaart niets om je weer bij te werken, en de OCB-nonce is het volgnummer van het pakket, dat in elk proces bij 0 begint. Hervatten zou nonces met dezelfde sleutel herhalen, en bij OCB lekt dat de tekst en breekt het de authenticatie. Daarom slaat Wendo de Mosh-toestand nooit op.

Eternal Terminal 7.0 kun je wel hervatten, en ook die client is Kotlin: EtClient.kt, met de versleuteling crypto_secretbox (XSalsa20-Poly1305) bovenop BouncyCastle. Valt de TCP-verbinding weg, dan verbindt hij elke seconde opnieuw en meldt elke kant hoeveel pakketten hij gelezen heeft, zodat de ander de ontbrekende opnieuw stuurt. Om te overleven dat Android de app afschiet, bewaart EtStateStore.kt in de privébestanden van de app het id en de sleutel van de sessie, verzegeld met een AES-256-GCM-sleutel uit de Android Keystore, plus een log van elk versleuteld pakket, weggeschreven voordat het de socket uit gaat. De ET-nonce is een opeenvolgende teller zonder sprongen, en precies dat vooraf geschreven log garandeert dat de volgende nonce de juiste is. Open je de app opnieuw, dan legt restoreRecoverableSessions() wat de native kant bewaart naast de kaarten die in Dart zijn opgeslagen, en verschijnen de sessies weer op Start. In de app staat ET nog als experimenteel aangemerkt.

Mosh en ET hebben daarnaast een SSH-kanaal nodig voor exec en port forwards. SideConnection.kt houdt de verbinding van het opstarten vast, maar controleert die vóór elk gebruik. Is ze bij de netwerkwissel weggevallen, dan opent hij een nieuwe met dezelfde fingerprints en zet hij de forwards terug op dezelfde lokale poorten.

Wat de Dart-kant doet

lib/core/sessions.dart bewaart de uitvoer van elke sessie (tot 512 KB), ook als er geen terminal open is, zodat hij opnieuw getekend kan worden als je terugkomt. Bij Mosh wordt alles van daarvoor weggegooid zodra er een volledig scherm (reset) binnenkomt. Het event onTransportStatus levert silentMs en rebindInMs, en daarmee meldt de interface dat de server niet reageert in plaats van de sessie te sluiten. Zet je “Actief houden op achtergrond” uit, dan worden SSH-sessies gesloten als de app naar de achtergrond gaat; Mosh- en ET-sessies blijven, want ze verbreken zou de sessie op de server doden.

De Auto-modus zit in openSession(): eerst Mosh, dan ET alleen als er een poort is ingesteld of iets op 2022 antwoordt (om geen lange wachttijden op te stapelen), en als laatste SSH. Met een Jump Host of agent forwarding gaat hij direct naar SSH, en een authenticatie- of fingerprintfout laat hem niet doorvallen naar het volgende transport, omdat SSH dan net zo goed zou falen.

De compromissen die overblijven

  • Op iOS is er alleen SSH. Vraag je om Mosh of ET, dan krijg je ERR_MOSH_UNSUPPORTED of ERR_ET_UNSUPPORTED, en Auto gaat meteen naar SSH.
  • Mosh en ET ondersteunen geen Jump Host.
  • Mosh synchroniseert alleen wat op het scherm staat: wil je terug kunnen scrollen, werk dan binnen tmux, zoals de documentatie uitlegt.
  • Mosh en ET horen bij Wendo Pro; zonder Pro houdt limitToPlan de verbinding op SSH.

Wil je vooral een transport kiezen om Claude Code vanaf je telefoon te volgen, en niet weten hoe het gebouwd is, dan staat de praktische vergelijking in Mosh of SSH op je telefoon en vind je de velden van elke verbinding in de documentatie over sessies die onderbrekingen overleven. Hoe de app datzelfde SSH gebruikt voor de meldingen, zonder eigen server, vertellen we in een mobiele app zonder backend.