LirafyBloggWendo ↗
Svenska

Blogg

Så byggde vi in SSH, Mosh och Eternal Terminal i en Flutter-app

I Wendo är SSH-klienten i Flutter inte skriven i Dart. Appen pratar med ett eget plugin, packages/wendo_ssh, över en plattformskanal, och varje system bidrar med sitt bibliotek: JSch på Android och NMSSH (libssh2) på iOS. Mosh och Eternal Terminal kommer inte från något bibliotek. Båda protokollen har vi implementerat själva i Kotlin, och de finns än så länge bara på Android. Här beskriver vi hur det är uppbyggt, vilka problem vi fick lösa på vägen och vilka kompromisser som återstår.

En SSH-klient i Flutter: native-plugin med ett litet kontrakt

Pluginet ligger i packages/wendo_ssh, och Dart-sidan är tunn: en MethodChannel för kommandon och en EventChannel för allt som kommer från servern.

Wendos SSH inifrån: appen i Dart pratar över en plattformskanal med pluginet wendo_ssh, som använder JSch, Mosh och ET på Android och NMSSH på iOS
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 och disconnect är desamma för alla tre transporterna. Händelserna (onData, onClose, onTransportStatus) kommer från ett enda native-flöde som WendoSsh fördelar efter namn. Två detaljer vi är glada för: bytes från servern når Dart som Uint8List, utan base64 på vägen, och fel kommer som SshException med en code (ERR_AUTH, ERR_MOSH_UDP, ERR_ET_GONE…) som gränssnittet översätter till ett användbart meddelande.

På native-sidan flyttas allt som blockerar bort från huvudtråden: på Android med korutiner på Dispatchers.IO och på iOS med en egen DispatchQueue (wendo.ssh.io). Händelserna kommer alltid tillbaka via huvudtråden, eftersom EventChannel kräver det. På iOS behövde vi lägga till en funktion som packar upp Optional som ligger inuti en Any, eftersom kanalens codec inte tar emot dem.

SSH-biblioteket på Android: JSch

På Android använder vi den underhållna forken av JSch (com.github.mwiede:jsch) med BouncyCastle. Android ignorerar META-INF/versions, så JSch hamnar på sina Java 8-klasser, och de delegerar ed25519, curve25519 och ML-KEM till BouncyCastle. Utan det beroendet skulle ed25519-nycklarna som appen genererar inte fungera.

Det finns en bugg som bara visar sig i release-bygget. JSch och BouncyCastle laddar klasser efter namn, och R8 tar bort dem eftersom den inte ser att någon använder dem. Söker du på ”SSH-klient Flutter” kommer du att läsa om samma problem på sidan för paketet ssh2 (en ClassNotFoundException för com.jcraft.jsch.jce.Random). I wendo_ssh ligger reglerna i consumer-rules.pro, så en app som använder pluginet ärver dem utan att röra något.

Tre andra detaljer som finns i SshSession.kt men inte i något exempel:

  • Varje getInputStream() i JSch skapar ett nytt rör. Du måste anropa det före channel.connect(), annars försvinner det första servern skickar (prompten, MOTD).
  • sshd tillåter 10 kanaler per anslutning (MaxSessions), och skalet, vidarebefordringarna och agenten tar redan några. Med många samtidiga exec dök ”channel is not opened” upp, så de begränsas.
  • Att läsa genom att polla available() var 20:e ms gav inte ens 100 KB/s, och chattvyn läser hundratals KB transkript. Vi gick över till blockerande läsningar.

SSH-biblioteket på iOS: NMSSH

På iOS går SSH via NMSSH 2.3, som har libssh2 1.8.0 inbyggt. Appen deklarerar det i sin Podfile med :modular_headers => true för att kunna importera det från Swift. Den gamla libssh2-versionen får följder. Agentvidarebefordran kräver callbacken LIBSSH2_CALLBACK_AUTHAGENT, som kom i libssh2 1.9.0. NMSSHAccessors.m registrerar den bara om headern definierar den, och annars kompileras den till en tom funktion. För att komma åt den råa libssh2-kanalen som NMSSH sparar i en privat egenskap läser samma fil instansvariabeln med ivar_getOffset.

Vi fick också skriva bcrypt_pbkdf i Swift (BcryptPbkdf.swift, efter förlagan i OpenSSH) för att dekryptera privata OpenSSH-nycklar skyddade med lösenfras. Och NMSSH stänger kanalen innan resultatet av ett kommando returneras, så libssh2_channel_get_exit_status ger nästan alltid 0. Koden tittar på NMSSH:s error så att ett misslyckat kommando inte ser ut som en framgång.

arm64-simulatorn

NMSSH:s bibliotek saknar arm64-varianten för simulatorn, som är den en Mac med Apple Silicon använder. Lösningen sitter i post_install i Podfile: med WENDO_SSH_SIM_LIBS=1 byter den ut NMSSH:s .a-filer mot libssh2 1.11.1 och OpenSSL 3.4.0 kompilerade för arm64-ios-simulator, som ligger i packages/wendo_ssh/ios/libs, och utesluter x86_64 från alla targets.

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

De biblioteken har agent-callbacken, så i simulatorn fungerar vidarebefordran. På en enhet länkas de inte in: där kompileras appen utan variabeln.

Mosh för Flutter: skrivet i Kotlin, bara på Android

Mosh använder SSH bara för att starta. Wendo öppnar en anslutning med JSch och startar servern så här (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 --'

Allt skickas inom enkla citattecken till sh eftersom användarens skal kan vara fish. Raden med $SSH_CONNECTION behövs för alternativet ”Använd serverns IP för UDP”. Om servern saknar locale en_US.UTF-8 görs ett nytt försök med C.UTF-8. Ur utdata läses MOSH CONNECT <port> <nyckel>, och därefter går allt över UDP.

Klienten implementerar protokollet i Mosh 1.4 i Kotlin och följer network.cc och transportfilerna: AES-128-OCB-paket med BouncyCastle (MoshCrypto.kt), de fyra protobuf-meddelanden som behövs, handskrivna (MoshProto.kt), fragmentering efter MTU, kvittenser, omsändningar och portbyte. När det inte har gått någon tur och retur på en stund öppnar klienten en ny socket med en annan källport, och servern följer efter så fort den ser paket från den nya adressen. Så överlever sessionen bytet från wifi till mobildata.

Skärmen är den del som har flest fällor. Mosh skickar inte appens byteflöde, utan skillnaden mellan två skärmar, och den skillnaden gäller bara mot det tillstånd den utgår från. MoshScreen.kt håller en skuggskärm som bara tolkar den dialekt som Display::new_frame skickar ut, utan att vara en fullständig emulator. Ovanpå ligger MoshPrediction.kt, vår implementation av det prediktiva ekot (PredictionEngine) i lägena adaptive, always och never: förutsägelserna måste tas bort innan varje diff från servern tillämpas och ritas upp igen efteråt, i samma block, så att xterm inte visar något halvfärdigt.

Två detaljer till. mosh-server förutsätter att terminalen skickar piltangenterna i applikationsläge, så cursorKeysToSs3 byter ESC [ A mot ESC O A. Annars känner vim och tmux inte igen dem. Och om det första UDP-paketet inte kommer fram (6 s med Auto, 12 s när Mosh valts manuellt) dödas den nystartade mosh-server, och felet säger åt dig att kontrollera brandväggen.

JVM-testerna (MoshServerTest.kt) pratar med en riktig mosh-server 1.4 lokalt, även över en simulerad länk med 120 ms fördröjning i varje riktning, för att kontrollera att xterm visar exakt serverns skärm när du har skrivit klart.

Eternal Terminal och en app som systemet dödar

En Mosh-session dör med appens process, och ingen design kan undvika det. Servern sparar inget att komma ikapp med, och nonce i OCB är paketets sekvensnummer, som börjar på 0 i varje process. Att återuppta skulle upprepa nonce med samma nyckel, och i OCB läcker det klartext och bryter autentiseringen. Därför sparar Wendo aldrig Mosh-tillståndet.

Eternal Terminal 7.0 går att återuppta, och även den klienten är Kotlin: EtClient.kt, med krypteringen crypto_secretbox (XSalsa20-Poly1305) byggd ovanpå BouncyCastle. Om TCP faller ansluter den igen varje sekund, och varje sida talar om hur många paket den har läst så att den andra skickar om de som saknas. För att klara att Android dödar appen sparar EtStateStore.kt sessionens id och nyckel i appens privata filer, förseglade med en AES-256-GCM-nyckel från Android Keystore, plus en logg över varje krypterat paket, skriven innan paketet går ut genom socketen. ET:s nonce är en löpande räknare som inte tillåter hopp, så den förhandsloggen är det som garanterar att nästa nonce blir rätt. När appen öppnas igen matchar restoreRecoverableSessions() det native-sidan har sparat mot korten som sparats i Dart, och sessionerna dyker upp igen under Start. I appen är ET fortfarande märkt som experimentell.

Mosh och ET behöver dessutom en SSH-kanal för exec och portvidarebefordringar. SideConnection.kt behåller anslutningen från uppstarten, men kontrollerar den före varje användning, och om den föll vid nätbytet öppnar den en ny med samma fingeravtryck och återställer vidarebefordringarna på samma lokala portar.

Vad Dart-delen gör

lib/core/sessions.dart sparar utdata från varje session (upp till 512 KB) även när ingen terminal är öppen, så att den kan ritas upp igen när du kommer tillbaka. Med Mosh kastas det tidigare innehållet när en hel skärm (reset) kommer. Händelsen onTransportStatus innehåller silentMs och rebindInMs, och med dem varnar gränssnittet för att servern inte svarar i stället för att stänga sessionen. Stänger du av ”Håll aktiv i bakgrunden” stängs SSH-sessionerna när appen går i bakgrunden. Mosh- och ET-sessionerna lever kvar, eftersom en frånkoppling skulle döda fjärrsessionen.

Auto-läget sitter i openSession(): det provar Mosh, sedan ET bara om en port är konfigurerad eller något svarar på 2022 (för att inte lägga ihop långa väntetider), och sist SSH. Med Jump Host eller agentvidarebefordran går det direkt till SSH, och ett autentiserings- eller fingeravtrycksfel går inte vidare till nästa, eftersom SSH skulle misslyckas på samma sätt.

Kompromisserna som återstår

  • På iOS finns bara SSH. Att be om Mosh eller ET ger ERR_MOSH_UNSUPPORTED eller ERR_ET_UNSUPPORTED, och Auto går direkt till SSH.
  • Varken Mosh eller ET stöder Jump Host.
  • Mosh synkar bara det som syns på skärmen. Vill du ha historik bakåt, jobba inuti tmux, som dokumentationen förklarar.
  • Mosh och ET ingår i Wendo Pro. Utan Pro håller limitToPlan kvar anslutningen på SSH.

Vill du hellre välja transport för att följa Claude Code från mobilen än veta hur den är byggd finns den praktiska jämförelsen i Mosh eller SSH på mobilen, och fälten för varje anslutning i dokumentationen om sessioner som klarar avbrott. Hur appen använder samma SSH för aviseringarna, utan egen server, berättar vi i en mobilapp utan backend.