LirafyBlogWendo ↗
Italiano

Blog

Come abbiamo messo SSH, Mosh ed Eternal Terminal in un’app Flutter

In Wendo il client SSH in Flutter non è scritto in Dart. L’app parla con un plugin nostro, packages/wendo_ssh, attraverso un platform channel, e ogni sistema ci mette la sua libreria: JSch su Android e NMSSH (libssh2) su iOS. Mosh ed Eternal Terminal non vengono da nessuna libreria: i due protocolli li abbiamo implementati noi in Kotlin e per ora esistono solo su Android. Qui raccontiamo com’è costruito, quali problemi abbiamo dovuto risolvere strada facendo e quali compromessi restano.

Un client SSH in Flutter: plugin nativo con un contratto piccolo

Il plugin sta in packages/wendo_ssh e il lato Dart è sottile: un MethodChannel per i comandi e un EventChannel per tutto ciò che arriva dal server.

L’SSH di Wendo visto da dentro: l’app in Dart parla tramite un platform channel con il plugin wendo_ssh, che usa JSch, Mosh ed ET su Android e NMSSH su 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 e disconnect sono gli stessi per i tre trasporti. Gli eventi (onData, onClose, onTransportStatus) escono da un unico flusso nativo che WendoSsh smista per nome. Due dettagli che fanno comodo: i byte del server arrivano a Dart come Uint8List, senza passare da base64, e gli errori arrivano come SshException con un code (ERR_AUTH, ERR_MOSH_UDP, ERR_ET_GONE…) che l’interfaccia traduce in un messaggio utile.

Sul lato nativo tutto ciò che blocca esce dal thread principale: su Android con le coroutine su Dispatchers.IO, su iOS con una DispatchQueue dedicata (wendo.ssh.io). Gli eventi tornano sempre sul thread principale, come richiede l’EventChannel. Su iOS abbiamo dovuto aggiungere una funzione che spacchetta gli Optional finiti dentro un Any, perché il codec del canale non li accetta.

SSH su Android: la libreria JSch

Su Android usiamo il fork mantenuto di JSch (com.github.mwiede:jsch) con BouncyCastle. Android ignora META-INF/versions, quindi JSch finisce per usare le sue classi Java 8, che delegano ed25519, curve25519 e ML-KEM a BouncyCastle. Senza quella dipendenza le chiavi ed25519 generate dall’app non funzionerebbero.

C’è un bug che compare solo nella build di release. JSch e BouncyCastle caricano classi per nome, e R8 le elimina perché non vede nessuno che le usi. Se cerchi «client SSH Flutter», prima o poi leggi lo stesso problema nella pagina del pacchetto ssh2 (un ClassNotFoundException di com.jcraft.jsch.jce.Random). In wendo_ssh le regole stanno in consumer-rules.pro, così l’app che usa il plugin le eredita senza toccare niente.

Altri tre dettagli che si trovano in SshSession.kt e che nessun esempio mostra:

  • Ogni getInputStream() di JSch crea una pipe nuova. Va chiesto prima di channel.connect(), altrimenti si perde la prima cosa che manda il server (il prompt, il MOTD).
  • sshd accetta 10 canali per connessione (MaxSessions), e la shell, gli inoltri e l’agente ne occupano già alcuni. Con molti exec in contemporanea usciva «channel is not opened», quindi li limitiamo.
  • Leggere interrogando available() ogni 20 ms non arrivava a 100 KB/s, e la vista chat legge centinaia di KB di trascrizione. Siamo passati a letture bloccanti.

SSH su iOS: la libreria NMSSH

Su iOS l’SSH passa per NMSSH 2.3, che si porta dietro libssh2 1.8.0. L’app lo dichiara nel Podfile con :modular_headers => true per poterlo importare da Swift. Una libssh2 così vecchia ha le sue conseguenze. L’inoltro dell’agente richiede la callback LIBSSH2_CALLBACK_AUTHAGENT, arrivata con libssh2 1.9.0: NMSSHAccessors.m la registra solo se l’header la definisce, e altrimenti compila una funzione vuota. Per arrivare al canale grezzo di libssh2, che NMSSH tiene in una proprietà privata, lo stesso file legge la variabile d’istanza con ivar_getOffset.

Abbiamo anche dovuto scrivere in Swift bcrypt_pbkdf (BcryptPbkdf.swift, seguendo quello di OpenSSH) per decifrare le chiavi private OpenSSH protette da passphrase. E NMSSH chiude il canale prima di restituire il risultato di un comando, quindi libssh2_channel_get_exit_status dà quasi sempre 0: il codice guarda l’error di NMSSH perché un comando fallito non sembri riuscito.

Il simulatore arm64

Le librerie di NMSSH non includono la variante arm64 per il simulatore, quella che usa un Mac con Apple Silicon. La soluzione sta nel post_install del Podfile: con WENDO_SSH_SIM_LIBS=1 sostituisce i .a di NMSSH con libssh2 1.11.1 e OpenSSL 3.4.0 compilati per arm64-ios-simulator, che stanno in packages/wendo_ssh/ios/libs, ed esclude x86_64 da tutti i target.

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

Quelle librerie la callback dell’agente ce l’hanno, quindi nel simulatore l’inoltro funziona. Su un dispositivo non vengono collegate: lì si compila senza la variabile.

Mosh in Flutter: scritto in Kotlin, solo su Android

Mosh usa SSH solo per partire. Wendo apre una connessione con JSch e avvia il server così (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 --'

Tutto passa tra apici singoli a sh perché la shell dell’utente potrebbe essere fish. La riga con $SSH_CONNECTION serve all’opzione «Usa IP del server per UDP». Se il server non ha la locale en_US.UTF-8, si riprova con C.UTF-8. Dall’output si legge MOSH CONNECT <porta> <chiave>, e da lì in poi tutto viaggia su UDP.

Il client implementa in Kotlin il protocollo di Mosh 1.4 seguendo network.cc e i file del trasporto: pacchetti AES-128-OCB con BouncyCastle (MoshCrypto.kt), i quattro messaggi protobuf necessari scritti a mano (MoshProto.kt), frammentazione per MTU, ack, ritrasmissioni e cambio di porta. Quando per un po’ non c’è andata e ritorno, il client apre un altro socket con un’altra porta di origine, e il server lo segue appena vede arrivare pacchetti dal nuovo indirizzo. È così che sopravvive al passaggio dal Wi-Fi ai dati mobili.

Lo schermo è la parte con più trabocchetti. Mosh non manda il flusso di byte dell’applicazione ma la differenza tra due schermi, e quella differenza vale solo rispetto allo stato da cui parte. MoshScreen.kt tiene uno schermo ombra che interpreta solo il dialetto emesso da Display::new_frame, senza essere un emulatore completo. Sopra c’è MoshPrediction.kt, la nostra implementazione dell’eco predittivo (PredictionEngine) nelle modalità adaptive, always e never: bisogna togliere le predizioni prima di applicare ogni diff del server e ridisegnarle subito dopo, nello stesso blocco, perché xterm non mostri niente a metà.

Altri due dettagli. mosh-server dà per scontato che il terminale mandi le frecce in modalità applicazione, quindi cursorKeysToSs3 trasforma ESC [ A in ESC O A; altrimenti vim o tmux non le riconoscono. E se il primo pacchetto UDP non arriva (6 s con Auto, 12 s con Mosh scelto a mano), il mosh-server appena avviato viene ucciso e l’errore ti dice di controllare il firewall.

I test JVM (MoshServerTest.kt) parlano con un vero mosh-server 1.4 in locale, compreso un collegamento simulato con 120 ms di andata in ogni direzione, per verificare che a fine digitazione xterm mostri esattamente lo schermo del server.

Eternal Terminal e un’app che il sistema uccide

Una sessione Mosh muore con il processo dell’app, e nessun design lo evita. Il server non conserva niente per rimettersi in pari, e il nonce di OCB è il numero di sequenza del pacchetto, che riparte da 0 in ogni processo. Riprendere la sessione ripeterebbe nonce con la stessa chiave, e in OCB questo fa trapelare il testo e rompe l’autenticazione. Per questo Wendo non salva mai lo stato di Mosh.

Eternal Terminal 7.0 invece si può riprendere, e anche il suo client è in Kotlin: EtClient.kt, con la cifratura crypto_secretbox (XSalsa20-Poly1305) costruita su BouncyCastle. Se il TCP cade, si riconnette ogni secondo e ciascun lato dice quanti pacchetti ha letto, così l’altro rimanda quelli mancanti. Per sopravvivere ad Android che uccide l’app, EtStateStore.kt salva nei file privati dell’app l’id e la chiave della sessione, sigillati con una chiave AES-256-GCM del Keystore di Android, più un registro di ogni pacchetto cifrato scritto prima che esca dal socket. Il nonce di ET è un contatore consecutivo che non ammette salti, ed è quel registro scritto in anticipo a garantire che il nonce successivo sia quello giusto. Quando riapri l’app, restoreRecoverableSessions() incrocia quello che conserva il nativo con le schede salvate in Dart e le sessioni ricompaiono in Home. Nell’app ET resta segnato come sperimentale.

Mosh ed ET hanno poi bisogno di un canale SSH per exec e per gli inoltri di porta. SideConnection.kt tiene la connessione dell’avvio, ma la controlla prima di ogni uso e, se è caduta con il cambio di rete, ne apre un’altra con le stesse impronte e ripristina gli inoltri sulle stesse porte locali.

Cosa fa la parte Dart

lib/core/sessions.dart conserva l’output di ogni sessione (fino a 512 KB) anche senza un terminale aperto, per ridisegnarlo quando torni. Con Mosh, quando arriva uno schermo completo (reset), si scarta quello che c’era prima. L’evento onTransportStatus porta silentMs e rebindInMs, e con questi l’interfaccia avvisa che il server non risponde invece di chiudere la sessione. Se disattivi «Mantieni attivo in background», quando l’app va in background le sessioni SSH si chiudono; quelle Mosh ed ET restano, perché disconnetterle ucciderebbe la sessione remota.

La modalità Auto sta in openSession(): prova Mosh, poi ET solo se c’è una porta configurata o se qualcosa risponde sulla 2022 (per non sommare attese lunghe), e infine SSH. Con un Jump Host o l’inoltro dell’agente va dritta a SSH, e un errore di autenticazione o di impronta non fa passare al trasporto successivo, perché SSH fallirebbe allo stesso modo.

I compromessi che restano

  • Su iOS c’è solo SSH. Chiedere Mosh o ET restituisce ERR_MOSH_UNSUPPORTED o ERR_ET_UNSUPPORTED, e Auto passa direttamente a SSH.
  • Né Mosh né ET supportano il Jump Host.
  • Mosh sincronizza solo ciò che si vede sullo schermo: per avere lo storico all’indietro, lavora dentro tmux, come spiega la documentazione.
  • Mosh ed ET fanno parte di Wendo Pro; senza Pro, limitToPlan lascia la connessione su SSH.

Se ti serve scegliere il trasporto per seguire Claude Code dal telefono, più che sapere com’è fatto, trovi il confronto pratico in Mosh o SSH sul telefono e i campi di ogni connessione nella documentazione sulle sessioni che reggono le interruzioni. Come l’app usa questo stesso SSH per le notifiche, senza un server nostro, lo raccontiamo in un’app mobile senza backend.