LirafyBlogWendo ↗
Español

Blog

Cómo metimos SSH, Mosh y Eternal Terminal en una app Flutter

En Wendo, el cliente SSH en Flutter no está escrito en Dart. La app habla con un plugin propio, packages/wendo_ssh, por un canal de plataforma, y cada sistema pone su librería: JSch en Android y NMSSH (libssh2) en iOS. Mosh y Eternal Terminal no salen de ninguna librería: los dos protocolos los implementamos nosotros en Kotlin, y por ahora solo existen en Android. Aquí va cómo está montado, qué problemas hubo que resolver por el camino y qué compromisos quedan.

Un cliente SSH en Flutter: plugin nativo con un contrato pequeño

El plugin vive en packages/wendo_ssh y el lado de Dart es delgado: un MethodChannel para las órdenes y un EventChannel para todo lo que llega del servidor.

El SSH de Wendo por dentro: la app en Dart habla por un canal de plataforma con el plugin wendo_ssh, que usa JSch, Mosh y ET en Android y NMSSH en 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 y disconnect son los mismos para los tres transportes. Los eventos (onData, onClose, onTransportStatus) salen de un único flujo nativo que WendoSsh reparte por nombre. Dos detalles que se agradecen: los bytes del servidor llegan a Dart como Uint8List, sin base64 por el medio, y los errores llegan como SshException con un code (ERR_AUTH, ERR_MOSH_UDP, ERR_ET_GONE…) que la interfaz traduce a un mensaje útil.

Del lado nativo, todo lo que bloquea sale del hilo principal: en Android con corrutinas en Dispatchers.IO y en iOS con una DispatchQueue propia (wendo.ssh.io). Los eventos vuelven siempre por el hilo principal, que es lo que exige el EventChannel. En iOS hubo que añadir una función que desenvuelve los Optional metidos dentro de un Any, porque el códec del canal no los admite.

SSH en Android con JSch

En Android usamos el fork mantenido de JSch (com.github.mwiede:jsch) con BouncyCastle. Android ignora META-INF/versions, así que JSch acaba usando sus clases de Java 8, y esas delegan ed25519, curve25519 y ML-KEM en BouncyCastle. Sin esa dependencia, las claves ed25519 que genera la app no funcionarían.

Hay un fallo que solo aparece en la build de release. JSch y BouncyCastle cargan clases por nombre, y R8 las elimina porque no ve a nadie usarlas. Si buscas «cliente SSH Flutter» acabarás leyendo el mismo problema en la página del paquete ssh2 (un ClassNotFoundException de com.jcraft.jsch.jce.Random). En wendo_ssh las reglas van en consumer-rules.pro, de modo que la app que use el plugin las hereda sin tocar nada.

Otros tres detalles que aparecen en SshSession.kt y que no se ven en ningún ejemplo:

  • Cada getInputStream() de JSch crea una tubería nueva. Hay que pedirlo antes de channel.connect() o se pierde lo primero que manda el servidor (el prompt, el MOTD).
  • sshd admite 10 canales por conexión (MaxSessions), y la shell, los reenvíos y el agente ya ocupan algunos. Con muchos exec a la vez salía «channel is not opened», así que se limitan.
  • Leer sondeando available() cada 20 ms no daba ni 100 KB/s, y la vista de chat lee cientos de KB de transcripción. Se pasó a lecturas bloqueantes.

SSH en iOS con NMSSH

En iOS el SSH va con NMSSH 2.3, que lleva dentro libssh2 1.8.0. La app lo declara en su Podfile con :modular_headers => true para poder importarlo desde Swift. La versión antigua de libssh2 tiene consecuencias. El reenvío de agente necesita el callback LIBSSH2_CALLBACK_AUTHAGENT, que llegó en libssh2 1.9.0; NMSSHAccessors.m lo registra solo si la cabecera lo define y, si no, compila a una función vacía. Para llegar al canal crudo de libssh2 que NMSSH guarda en una propiedad privada, ese mismo fichero lee la variable de instancia con ivar_getOffset.

También hubo que escribir en Swift bcrypt_pbkdf (BcryptPbkdf.swift, siguiendo el de OpenSSH) para descifrar claves privadas OpenSSH protegidas con frase. Y NMSSH cierra el canal antes de devolver el resultado de un comando, así que libssh2_channel_get_exit_status casi siempre da 0: el código mira el error de NMSSH para que un comando fallido no parezca un éxito.

El simulador arm64

Las librerías de NMSSH no traen la variante arm64 del simulador, que es la que usa un Mac con Apple Silicon. La solución está en el post_install del Podfile: con WENDO_SSH_SIM_LIBS=1, cambia los .a de NMSSH por libssh2 1.11.1 y OpenSSL 3.4.0 compilados para arm64-ios-simulator, que están en packages/wendo_ssh/ios/libs, y excluye x86_64 de todos los targets.

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

Esas librerías sí traen el callback del agente, así que en el simulador el reenvío funciona. En un dispositivo no se enlazan: allí se compila sin la variable.

Mosh en Flutter: escrito en Kotlin, solo en Android

Mosh usa SSH solo para arrancar. Wendo abre una conexión con JSch y lanza el servidor así (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 --'

Todo va entre comillas simples a sh porque la shell del usuario puede ser fish. La línea de $SSH_CONNECTION sirve para la opción «Usar IP del servidor para UDP». Si el servidor no tiene la locale en_US.UTF-8, se reintenta con C.UTF-8. De la salida se lee MOSH CONNECT <puerto> <clave>, y a partir de ahí todo va por UDP.

El cliente implementa en Kotlin el protocolo de Mosh 1.4 siguiendo network.cc y los ficheros del transporte: paquetes AES-128-OCB con BouncyCastle (MoshCrypto.kt), los cuatro mensajes de protobuf que hacen falta escritos a mano (MoshProto.kt), fragmentación por MTU, acks, retransmisiones y salto de puerto. Cuando no hay ida y vuelta durante un rato, el cliente abre otro socket con otro puerto de origen, y el servidor lo sigue en cuanto ve llegar paquetes de la dirección nueva. Así sobrevive a pasar de wifi a datos.

La pantalla es la parte con más trampas. Mosh no envía el flujo de bytes de la aplicación, sino la diferencia entre dos pantallas, y esa diferencia solo vale sobre el estado del que parte. MoshScreen.kt mantiene una pantalla sombra que interpreta solo el dialecto que emite Display::new_frame, sin ser un emulador completo. Encima va MoshPrediction.kt, nuestra implementación del eco predictivo (PredictionEngine) en los modos adaptive, always y never: hay que quitar las predicciones antes de aplicar cada diff del servidor y volver a pintarlas después, en el mismo bloque, para que xterm no muestre nada a medias.

Dos detalles más. mosh-server da por hecho que el terminal envía las flechas en modo aplicación, así que cursorKeysToSs3 cambia ESC [ A por ESC O A; si no, vim o tmux no las reconocen. Y si el primer paquete UDP no llega (6 s con Auto, 12 s con Mosh elegido a mano), se mata el mosh-server recién lanzado y el error dice que revises el cortafuegos.

Los tests de JVM (MoshServerTest.kt) hablan con un mosh-server 1.4 real en local, incluido un enlace simulado con 120 ms de ida en cada sentido para comprobar que, al acabar de teclear, xterm muestra exactamente la pantalla del servidor.

Eternal Terminal y una app que el sistema mata

Una sesión Mosh muere con el proceso de la app, y no hay diseño que lo evite. El servidor no guarda nada para ponerse al día, y el nonce de OCB es el número de secuencia del paquete, que empieza en 0 en cada proceso. Reanudar repetiría nonces con la misma clave, y en OCB eso filtra el texto y rompe la autenticación. Por eso Wendo nunca guarda el estado de Mosh.

Eternal Terminal 7.0 sí se puede reanudar, y el cliente también es Kotlin: EtClient.kt, con el cifrado crypto_secretbox (XSalsa20-Poly1305) montado sobre BouncyCastle. Si el TCP cae, reconecta cada segundo y cada lado dice cuántos paquetes ha leído para que el otro reenvíe los que faltan. Para sobrevivir a que Android mate la app, EtStateStore.kt guarda en los ficheros privados de la app el id y la clave de la sesión, sellada con una clave AES-256-GCM del Keystore de Android, y un registro de cada paquete cifrado escrito antes de que salga por el socket. El nonce de ET es un contador consecutivo que no admite saltos, así que ese registro previo es lo que garantiza que el siguiente nonce es el correcto. Al volver a abrir la app, restoreRecoverableSessions() cruza lo que guarda el nativo con las tarjetas guardadas en Dart y las sesiones reaparecen en Inicio. En la app, ET sigue marcado como experimental.

Mosh y ET necesitan además un canal SSH para exec y los reenvíos de puertos. SideConnection.kt conserva la conexión del arranque, pero la comprueba antes de cada uso y, si cayó con el cambio de red, abre otra con las mismas huellas y repone los reenvíos en sus mismos puertos locales.

Lo que hace la parte de Dart

lib/core/sessions.dart guarda la salida de cada sesión (hasta 512 KB) aunque no haya terminal abierto, para repintarla al volver. Con Mosh, cuando llega una pantalla completa (reset), se descarta lo anterior. El evento onTransportStatus trae silentMs y rebindInMs, y con eso la interfaz avisa de que el servidor no responde en lugar de cerrar la sesión. Si desactivas «Mantener activo en segundo plano», al pasar a segundo plano se cierran las sesiones SSH; las de Mosh y ET se quedan, porque desconectarlas mataría la sesión remota.

El modo Auto vive en openSession(): prueba Mosh, luego ET solo si hay puerto configurado o algo contesta en el 2022 (para no sumar esperas largas), y por último SSH. Con Jump Host o reenvío de agente va directo a SSH, y un error de autenticación o de huella no hace caer al siguiente, porque SSH fallaría igual.

Los compromisos que quedan

  • En iOS solo hay SSH. Pedir Mosh o ET devuelve ERR_MOSH_UNSUPPORTED o ERR_ET_UNSUPPORTED, y Auto pasa directamente a SSH.
  • Ni Mosh ni ET admiten Jump Host.
  • Mosh solo sincroniza lo que se ve en pantalla: para tener historial hacia atrás, trabaja dentro de tmux, como explica la documentación.
  • Mosh y ET son de Wendo Pro; sin Pro, limitToPlan deja la conexión en SSH.

Si lo que quieres es elegir transporte para seguir a Claude Code desde el móvil, y no saber cómo está hecho, tienes la comparativa práctica en Mosh o SSH en el móvil y los campos de cada conexión en la documentación de sesiones que aguantan cortes. Cómo usa la app ese mismo SSH para los avisos, sin servidor propio, lo contamos en una app móvil sin backend.