Como colocamos SSH, Mosh e Eternal Terminal num app Flutter

No Wendo, o cliente SSH no Flutter não é escrito em Dart. O app conversa com um plugin nosso, packages/wendo_ssh, por um platform channel, e cada sistema entra com a sua biblioteca: JSch no Android e NMSSH (libssh2) no iOS. Mosh e Eternal Terminal não vêm de biblioteca nenhuma: implementamos os dois protocolos nós mesmos em Kotlin e, por enquanto, eles só existem no Android. Aqui contamos como isso está montado, que problemas tivemos de resolver no caminho e que concessões ainda ficam.
Um cliente SSH no Flutter: plugin nativo com um contrato pequeno
O plugin fica em packages/wendo_ssh, e o lado Dart é fino: um MethodChannel para os comandos e um EventChannel para tudo o que chega do servidor.
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 são os mesmos para os três transportes. Os eventos (onData, onClose, onTransportStatus) saem de um único fluxo nativo que o WendoSsh distribui pelo nome. Dois detalhes que ajudam: os bytes do servidor chegam ao Dart como Uint8List, sem base64 no meio, e os erros chegam como SshException com um code (ERR_AUTH, ERR_MOSH_UDP, ERR_ET_GONE…) que a interface traduz numa mensagem útil.
No lado nativo, tudo o que bloqueia sai da thread principal: no Android com corrotinas em Dispatchers.IO e no iOS com uma DispatchQueue própria (wendo.ssh.io). Os eventos sempre voltam pela thread principal, que é o que o EventChannel exige. No iOS foi preciso adicionar uma função que desembrulha os Optional enfiados dentro de um Any, porque o codec do canal não aceita esse tipo.
SSH no Android com JSch
No Android usamos o fork mantido do JSch (com.github.mwiede:jsch) com BouncyCastle. O Android ignora META-INF/versions, então o JSch acaba usando suas classes de Java 8, e elas delegam ed25519, curve25519 e ML-KEM ao BouncyCastle. Sem essa dependência, as chaves ed25519 que o app gera não funcionariam.
Tem um bug que só aparece no build de release. JSch e BouncyCastle carregam classes pelo nome, e o R8 as remove porque não vê ninguém usando. Se você pesquisar “cliente SSH Flutter”, vai acabar lendo o mesmo problema na página do pacote ssh2 (um ClassNotFoundException de com.jcraft.jsch.jce.Random). No wendo_ssh as regras ficam em consumer-rules.pro, de modo que o app que usar o plugin as herda sem mexer em nada.
Outros três detalhes que aparecem em SshSession.kt e que nenhum exemplo mostra:
- Cada
getInputStream()do JSch cria um pipe novo. É preciso pedir antes dechannel.connect(), senão se perde a primeira coisa que o servidor manda (o prompt, o MOTD). - O
sshdaceita 10 canais por conexão (MaxSessions), e o shell, os encaminhamentos e o agente já ocupam alguns. Com muitosexecao mesmo tempo aparecia “channel is not opened”, então eles são limitados. - Ler consultando
available()a cada 20 ms não passava de 100 KB/s, e a visão de chat lê centenas de KB de transcrição. Passamos para leituras bloqueantes.
SSH no iOS com NMSSH
No iOS o SSH vai com o NMSSH 2.3, que traz dentro a libssh2 1.8.0. O app declara a dependência no Podfile com :modular_headers => true para poder importar do Swift. Uma libssh2 tão antiga tem consequências. O encaminhamento de agente precisa do callback LIBSSH2_CALLBACK_AUTHAGENT, que chegou na libssh2 1.9.0; o NMSSHAccessors.m só o registra se o header o define e, se não, compila uma função vazia. Para chegar ao canal cru da libssh2, que o NMSSH guarda numa propriedade privada, esse mesmo arquivo lê a variável de instância com ivar_getOffset.
Também tivemos de escrever em Swift o bcrypt_pbkdf (BcryptPbkdf.swift, seguindo o do OpenSSH) para decifrar chaves privadas OpenSSH protegidas por senha. E o NMSSH fecha o canal antes de devolver o resultado de um comando, então libssh2_channel_get_exit_status quase sempre dá 0: o código olha o error do NMSSH para que um comando que falhou não pareça um sucesso.
O simulador arm64
As bibliotecas do NMSSH não trazem a variante arm64 do simulador, que é a que um Mac com Apple Silicon usa. A solução está no post_install do Podfile: com WENDO_SSH_SIM_LIBS=1, ele troca os .a do NMSSH pela libssh2 1.11.1 e pelo OpenSSL 3.4.0 compilados para arm64-ios-simulator, que ficam em packages/wendo_ssh/ios/libs, e exclui x86_64 de todos os targets.
(cd ios && WENDO_SSH_SIM_LIBS=1 pod install) && WENDO_SSH_SIM_LIBS=1 fvm flutter build ios --simulator --debug
Essas bibliotecas trazem o callback do agente, então no simulador o encaminhamento funciona. Num aparelho elas não são linkadas: ali se compila sem a variável.
Mosh no Flutter: escrito em Kotlin, só no Android
O Mosh usa SSH só para começar. O Wendo abre uma conexão com JSch e inicia o servidor assim (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 --'
Tudo vai entre aspas simples para o sh porque o shell do usuário pode ser o fish. A linha do $SSH_CONNECTION serve para a opção “Usar IP do servidor para UDP”. Se o servidor não tem a locale en_US.UTF-8, tenta de novo com C.UTF-8. Da saída se lê MOSH CONNECT <porta> <chave>, e a partir daí tudo vai por UDP.
O cliente implementa em Kotlin o protocolo do Mosh 1.4 seguindo o network.cc e os arquivos do transporte: pacotes AES-128-OCB com BouncyCastle (MoshCrypto.kt), as quatro mensagens de protobuf necessárias escritas à mão (MoshProto.kt), fragmentação por MTU, acks, retransmissões e troca de porta. Quando passa um tempo sem ida e volta, o cliente abre outro socket com outra porta de origem, e o servidor o acompanha assim que vê chegarem pacotes do endereço novo. É assim que ele sobrevive à troca do Wi-Fi para os dados móveis.
A tela é a parte com mais armadilhas. O Mosh não envia o fluxo de bytes da aplicação, e sim a diferença entre duas telas, e essa diferença só vale sobre o estado de onde parte. O MoshScreen.kt mantém uma tela sombra que interpreta só o dialeto que o Display::new_frame emite, sem ser um emulador completo. Por cima vem o MoshPrediction.kt, a nossa implementação do eco preditivo (PredictionEngine) nos modos adaptive, always e never: é preciso tirar as previsões antes de aplicar cada diff do servidor e desenhá-las de novo depois, no mesmo bloco, para que o xterm não mostre nada pela metade.
Mais dois detalhes. O mosh-server assume que o terminal envia as setas em modo de aplicação, então cursorKeysToSs3 troca ESC [ A por ESC O A; sem isso, vim ou tmux não as reconhecem. E se o primeiro pacote UDP não chega (6 s com Auto, 12 s com Mosh escolhido à mão), o mosh-server recém-iniciado é morto e o erro pede que você confira o firewall.
Os testes de JVM (MoshServerTest.kt) conversam com um mosh-server 1.4 de verdade rodando local, incluindo um link simulado com 120 ms de ida em cada sentido, para conferir que, ao terminar de digitar, o xterm mostra exatamente a tela do servidor.
Eternal Terminal e um app que o sistema mata
Uma sessão Mosh morre junto com o processo do app, e não há design que evite isso. O servidor não guarda nada para se atualizar, e o nonce do OCB é o número de sequência do pacote, que começa em 0 em cada processo. Retomar repetiria nonces com a mesma chave, e no OCB isso vaza o texto e quebra a autenticação. Por isso o Wendo nunca salva o estado do Mosh.
O Eternal Terminal 7.0 pode ser retomado, e o cliente também é Kotlin: EtClient.kt, com a cifra crypto_secretbox (XSalsa20-Poly1305) montada sobre o BouncyCastle. Se o TCP cai, ele reconecta a cada segundo e cada lado diz quantos pacotes leu para que o outro reenvie os que faltam. Para sobreviver ao Android matando o app, o EtStateStore.kt guarda nos arquivos privados do app o id e a chave da sessão, selados com uma chave AES-256-GCM do Keystore do Android, e um registro de cada pacote cifrado, gravado antes de ele sair pelo socket. O nonce do ET é um contador consecutivo que não admite saltos, e é esse registro prévio que garante que o próximo nonce seja o correto. Quando você abre o app de novo, restoreRecoverableSessions() cruza o que o nativo guarda com os cards salvos no Dart, e as sessões reaparecem no Início. No app, o ET continua marcado como experimental.
Mosh e ET também precisam de um canal SSH para exec e para os encaminhamentos de porta. O SideConnection.kt mantém a conexão do início, mas a verifica antes de cada uso e, se ela caiu com a troca de rede, abre outra com as mesmas impressões digitais e refaz os encaminhamentos nas mesmas portas locais.
O que a parte em Dart faz
O lib/core/sessions.dart guarda a saída de cada sessão (até 512 KB) mesmo sem terminal aberto, para redesenhá-la quando você volta. Com Mosh, quando chega uma tela completa (reset), o que havia antes é descartado. O evento onTransportStatus traz silentMs e rebindInMs, e com isso a interface avisa que o servidor não responde em vez de fechar a sessão. Se você desativa “Manter ativo em segundo plano”, as sessões SSH fecham quando o app vai para segundo plano; as de Mosh e ET ficam, porque desconectá-las mataria a sessão remota.
O modo Auto mora em openSession(): tenta Mosh, depois ET só se houver porta configurada ou algo responder na 2022 (para não somar esperas longas), e por último SSH. Com Jump Host ou encaminhamento de agente vai direto para SSH, e um erro de autenticação ou de impressão digital não faz cair para o próximo, porque o SSH falharia do mesmo jeito.
As concessões que ficam
- No iOS só tem SSH. Pedir Mosh ou ET devolve
ERR_MOSH_UNSUPPORTEDouERR_ET_UNSUPPORTED, e o Auto passa direto para SSH. - Nem Mosh nem ET aceitam Jump Host.
- O Mosh só sincroniza o que está na tela: para ter histórico para trás, trabalhe dentro do tmux, como explica a documentação.
- Mosh e ET são do Wendo Pro; sem Pro,
limitToPlandeixa a conexão em SSH.
Se o que você quer é escolher o transporte para acompanhar o Claude Code pelo celular, e não saber como foi feito, a comparação prática está em Mosh ou SSH no celular e os campos de cada conexão estão na documentação de sessões que aguentam quedas. Como o app usa esse mesmo SSH para os avisos, sem servidor próprio, contamos em um app mobile sem backend.