LirafyBlogWendo ↗
Français

Blog

SSH en Flutter : comment nous avons intégré SSH, Mosh et Eternal Terminal à Wendo

Dans Wendo, le SSH en Flutter n’est pas écrit en Dart. L’app parle à notre propre plugin, packages/wendo_ssh, via un canal de plateforme, et chaque système apporte sa bibliothèque : JSch sur Android et NMSSH (libssh2) sur iOS. Mosh et Eternal Terminal ne viennent d’aucune bibliothèque. Nous avons implémenté nous-mêmes les deux protocoles en Kotlin, et pour l’instant ils n’existent que sur Android. Voici comment c’est monté, les problèmes que nous avons dû résoudre en route et les compromis qui restent.

Un client SSH en Flutter : un plugin natif au contrat réduit

Le plugin vit dans packages/wendo_ssh, et le côté Dart reste mince : un MethodChannel pour les commandes et un EventChannel pour tout ce qui arrive du serveur.

Le SSH de Wendo vu de l’intérieur : l’app en Dart parle par un canal de plateforme au plugin wendo_ssh, qui utilise JSch, Mosh et ET sur Android et NMSSH sur 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 et disconnect sont les mêmes pour les trois transports. Les événements (onData, onClose, onTransportStatus) sortent d’un seul flux natif que WendoSsh répartit par nom. Deux détails appréciables : les octets du serveur arrivent dans Dart sous forme de Uint8List, sans base64 au milieu, et les erreurs arrivent comme SshException avec un code (ERR_AUTH, ERR_MOSH_UDP, ERR_ET_GONE…) que l’interface traduit en message utile.

Côté natif, tout ce qui bloque sort du thread principal : avec des coroutines sur Dispatchers.IO sur Android, et avec une DispatchQueue dédiée (wendo.ssh.io) sur iOS. Les événements reviennent toujours par le thread principal, comme l’exige l’EventChannel. Sur iOS, il a fallu ajouter une fonction qui déballe les Optional imbriqués dans un Any, parce que le codec du canal ne les accepte pas.

SSH sur Android : la bibliothèque JSch

Sur Android, nous utilisons le fork maintenu de JSch (com.github.mwiede:jsch) avec BouncyCastle. Android ignore META-INF/versions, donc JSch finit par utiliser ses classes Java 8, qui délèguent ed25519, curve25519 et ML-KEM à BouncyCastle. Sans cette dépendance, les clés ed25519 générées par l’app ne fonctionneraient pas.

Il y a un bug qui n’apparaît que dans la build de release. JSch et BouncyCastle chargent des classes par leur nom, et R8 les supprime parce qu’il ne voit personne les utiliser. Si tu cherches « client SSH Flutter », tu finiras par lire le même problème sur la page du paquet ssh2 (une ClassNotFoundException sur com.jcraft.jsch.jce.Random). Dans wendo_ssh, les règles sont dans consumer-rules.pro, si bien que toute app qui utilise le plugin en hérite sans rien toucher.

Trois autres détails de SshSession.kt qu’on ne voit dans aucun exemple :

  • Chaque getInputStream() de JSch crée un nouveau tube. Il faut le demander avant channel.connect(), sinon on perd ce que le serveur envoie en premier (le prompt, le MOTD).
  • sshd accepte 10 canaux par connexion (MaxSessions), et le shell, les redirections et l’agent en occupent déjà quelques-uns. Avec beaucoup d’exec simultanés, on obtenait « channel is not opened », donc ils sont limités.
  • Lire en interrogeant available() toutes les 20 ms ne dépassait même pas 100 Ko/s, alors que la vue chat lit des centaines de Ko de transcription. Nous sommes passés à des lectures bloquantes.

SSH sur iOS : la bibliothèque NMSSH

Sur iOS, le SSH passe par NMSSH 2.3, qui embarque libssh2 1.8.0. L’app le déclare dans son Podfile avec :modular_headers => true pour pouvoir l’importer depuis Swift. L’ancienne version de libssh2 a des conséquences. Le transfert d’agent a besoin du callback LIBSSH2_CALLBACK_AUTHAGENT, arrivé avec libssh2 1.9.0 ; NMSSHAccessors.m ne l’enregistre que si l’en-tête le définit, et sinon compile une fonction vide. Pour atteindre le canal libssh2 brut que NMSSH garde dans une propriété privée, ce même fichier lit la variable d’instance avec ivar_getOffset.

Il a aussi fallu écrire bcrypt_pbkdf en Swift (BcryptPbkdf.swift, sur le modèle de celui d’OpenSSH) pour déchiffrer les clés privées OpenSSH protégées par une phrase de passe. Et NMSSH ferme le canal avant de renvoyer le résultat d’une commande, donc libssh2_channel_get_exit_status renvoie presque toujours 0. Le code regarde l’error de NMSSH pour qu’une commande en échec ne passe pas pour un succès.

Le simulateur arm64

Les bibliothèques de NMSSH ne contiennent pas la variante arm64 du simulateur, celle qu’utilise un Mac avec Apple Silicon. La solution se trouve dans le post_install du Podfile : avec WENDO_SSH_SIM_LIBS=1, il remplace les .a de NMSSH par libssh2 1.11.1 et OpenSSL 3.4.0 compilés pour arm64-ios-simulator, rangés dans packages/wendo_ssh/ios/libs, et exclut x86_64 de toutes les cibles.

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

Ces bibliothèques-là incluent le callback de l’agent, donc dans le simulateur le transfert fonctionne. Sur un appareil, elles ne sont pas liées : on compile sans la variable.

Mosh en Flutter : écrit en Kotlin, seulement sur Android

Mosh n’utilise SSH que pour démarrer. Wendo ouvre une connexion avec JSch et lance le serveur ainsi (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 --'

Tout passe entre apostrophes simples à sh parce que le shell de l’utilisateur peut être fish. La ligne $SSH_CONNECTION sert à l’option « Utiliser l’adresse IP du serveur pour UDP ». Si le serveur n’a pas la locale en_US.UTF-8, on réessaie avec C.UTF-8. Dans la sortie, on lit MOSH CONNECT <port> <clé>, et à partir de là tout passe par UDP.

Le client implémente en Kotlin le protocole de Mosh 1.4 en suivant network.cc et les fichiers du transport : paquets AES-128-OCB avec BouncyCastle (MoshCrypto.kt), les quatre messages protobuf nécessaires écrits à la main (MoshProto.kt), fragmentation selon la MTU, acks, retransmissions et changement de port. Quand il n’y a pas eu d’aller-retour depuis un moment, le client ouvre un autre socket avec un autre port source, et le serveur le suit dès qu’il voit arriver des paquets de la nouvelle adresse. C’est comme ça qu’il survit au passage du Wi-Fi au réseau mobile.

L’écran est la partie la plus piégeuse. Mosh n’envoie pas le flux d’octets de l’application, mais la différence entre deux écrans, et cette différence ne vaut que sur l’état dont elle part. MoshScreen.kt tient un écran fantôme qui n’interprète que le dialecte émis par Display::new_frame, sans être un émulateur complet. Par-dessus vient MoshPrediction.kt, notre implémentation de l’écho prédictif (PredictionEngine) dans les modes adaptive, always et never. Il faut retirer les prédictions avant d’appliquer chaque diff du serveur et les repeindre ensuite, dans le même bloc, pour que xterm n’affiche rien à moitié.

Deux détails encore. mosh-server part du principe que le terminal envoie les flèches en mode application, donc cursorKeysToSs3 remplace ESC [ A par ESC O A ; sinon vim ou tmux ne les reconnaissent pas. Et si le premier paquet UDP n’arrive pas (6 s en Auto, 12 s avec Mosh choisi à la main), le mosh-server tout juste lancé est tué et l’erreur te dit de vérifier le pare-feu.

Les tests JVM (MoshServerTest.kt) parlent à un vrai mosh-server 1.4 en local, y compris sur un lien simulé avec 120 ms dans chaque sens, pour vérifier qu’une fois la frappe terminée, xterm affiche exactement l’écran du serveur.

Eternal Terminal et une app que le système tue

Une session Mosh meurt avec le processus de l’app, et aucune conception ne l’évite. Le serveur ne garde rien pour remettre le client à jour, et le nonce d’OCB est le numéro de séquence du paquet, qui repart de 0 dans chaque processus. Reprendre la session réutiliserait des nonces avec la même clé, et en OCB cela fait fuiter le texte clair et casse l’authentification. C’est pourquoi Wendo ne sauvegarde jamais l’état de Mosh.

Eternal Terminal 7.0, lui, peut reprendre, et son client est aussi en Kotlin : EtClient.kt, avec le chiffrement crypto_secretbox (XSalsa20-Poly1305) monté sur BouncyCastle. Si le TCP tombe, il se reconnecte chaque seconde, et chaque côté indique combien de paquets il a lus pour que l’autre renvoie ceux qui manquent. Pour survivre au moment où Android tue l’app, EtStateStore.kt enregistre dans les fichiers privés de l’app l’identifiant et la clé de la session, scellés avec une clé AES-256-GCM du Keystore Android, ainsi qu’un journal de chaque paquet chiffré, écrit avant qu’il parte sur le socket. Le nonce d’ET est un compteur consécutif qui n’admet pas de trou, et c’est ce journal écrit à l’avance qui garantit que le nonce suivant est le bon. À la réouverture de l’app, restoreRecoverableSessions() croise ce que garde le natif avec les cartes enregistrées côté Dart, et les sessions réapparaissent dans Accueil. Dans l’app, ET reste marqué comme expérimental.

Mosh et ET ont aussi besoin d’un canal SSH pour exec et les redirections de ports. SideConnection.kt conserve la connexion du démarrage, mais la vérifie avant chaque usage et, si elle est tombée avec le changement de réseau, en ouvre une autre avec les mêmes empreintes et rétablit les redirections sur les mêmes ports locaux.

Ce que fait la partie Dart

lib/core/sessions.dart garde la sortie de chaque session (jusqu’à 512 Ko) même sans terminal ouvert, pour la réafficher quand tu reviens. Avec Mosh, quand un écran complet (reset) arrive, ce qui précède est jeté. L’événement onTransportStatus apporte silentMs et rebindInMs, et avec ça l’interface prévient que le serveur ne répond pas au lieu de fermer la session. Si tu désactives « Garder actif en arrière-plan », les sessions SSH se ferment quand l’app passe en arrière-plan ; celles de Mosh et d’ET restent, parce que les déconnecter tuerait la session distante.

Le mode Auto vit dans openSession() : il essaie Mosh, puis ET seulement si un port est configuré ou si quelque chose répond sur le 2022 (pour ne pas cumuler de longues attentes), et enfin SSH. Avec un Jump Host ou le transfert d’agent, il va directement à SSH, et une erreur d’authentification ou d’empreinte ne fait pas passer au suivant, parce que SSH échouerait de la même façon.

Les compromis qui restent

  • Sur iOS, il n’y a que SSH. Demander Mosh ou ET renvoie ERR_MOSH_UNSUPPORTED ou ERR_ET_UNSUPPORTED, et Auto passe directement à SSH.
  • Ni Mosh ni ET ne prennent en charge le Jump Host.
  • Mosh ne synchronise que ce qui est visible à l’écran : pour avoir l’historique, travaille dans tmux, comme l’explique la documentation.
  • Mosh et ET font partie de Wendo Pro ; sans Pro, limitToPlan laisse la connexion en SSH.

Si ce que tu veux, c’est choisir un transport pour suivre Claude Code depuis ton téléphone plutôt que savoir comment c’est fait, la comparaison pratique est dans Mosh ou SSH sur ton téléphone, et les champs de chaque connexion dans la documentation des sessions qui résistent aux coupures. Comment l’app se sert de ce même SSH pour les alertes, sans serveur à nous, on le raconte dans une application mobile sans backend.