LirafyBlogWendo ↗
Polski

Blog

Jak wbudowaliśmy SSH, Mosh i Eternal Terminal w aplikację we Flutterze

W Wendo klient SSH we Flutterze nie jest napisany w Darcie. Aplikacja rozmawia z naszym własnym pluginem, packages/wendo_ssh, przez kanał platformy, a każdy system dokłada swoją bibliotekę: JSch na Androidzie i NMSSH (libssh2) na iOS. Mosh i Eternal Terminal nie pochodzą z żadnej biblioteki. Oba protokoły zaimplementowaliśmy sami w Kotlinie i na razie istnieją tylko na Androidzie. Opisujemy tu, jak to jest zbudowane, jakie problemy trzeba było rozwiązać po drodze i jakie kompromisy zostały.

Klient SSH we Flutterze: natywny plugin z małym kontraktem

Plugin mieszka w packages/wendo_ssh, a strona Darta jest cienka: MethodChannel na polecenia i EventChannel na wszystko, co przychodzi z serwera.

SSH w Wendo od środka: aplikacja w Darcie rozmawia przez kanał platformy z pluginem wendo_ssh, który na Androidzie używa JSch, Mosh i ET, a na iOS NMSSH
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 i disconnect są takie same dla wszystkich trzech transportów. Zdarzenia (onData, onClose, onTransportStatus) płyną jednym natywnym strumieniem, który WendoSsh rozdziela po nazwie. Dwa szczegóły, które się przydają: bajty z serwera trafiają do Darta jako Uint8List, bez base64 po drodze, a błędy przychodzą jako SshException z polem code (ERR_AUTH, ERR_MOSH_UDP, ERR_ET_GONE…), które interfejs zamienia na sensowny komunikat.

Po stronie natywnej wszystko, co blokuje, schodzi z głównego wątku: na Androidzie przez korutyny na Dispatchers.IO, na iOS przez własną DispatchQueue (wendo.ssh.io). Zdarzenia zawsze wracają głównym wątkiem, bo tego wymaga EventChannel. Na iOS trzeba było dopisać funkcję, która rozpakowuje Optional schowane w Any, bo kodek kanału ich nie przyjmuje.

SSH na Androidzie z JSch

Na Androidzie używamy utrzymywanego forka JSch (com.github.mwiede:jsch) z BouncyCastle. Android ignoruje META-INF/versions, więc JSch kończy na swoich klasach z Javy 8, a te przekazują ed25519, curve25519 i ML-KEM do BouncyCastle. Bez tej zależności klucze ed25519 generowane przez aplikację by nie działały.

Jest błąd, który wychodzi tylko w buildzie release. JSch i BouncyCastle ładują klasy po nazwie, a R8 je usuwa, bo nie widzi, żeby ktoś ich używał. Jeśli szukasz „klient SSH Flutter”, prędzej czy później przeczytasz o tym samym problemie na stronie pakietu ssh2 (ClassNotFoundException dla com.jcraft.jsch.jce.Random). W wendo_ssh reguły siedzą w consumer-rules.pro, więc aplikacja korzystająca z pluginu dziedziczy je bez żadnych zmian.

Trzy inne szczegóły z SshSession.kt, których nie ma w żadnym przykładzie:

  • Każde getInputStream() w JSch tworzy nowy potok. Trzeba je wywołać przed channel.connect(), inaczej ginie to, co serwer wysyła na samym początku (prompt, MOTD).
  • sshd pozwala na 10 kanałów na połączenie (MaxSessions), a powłoka, przekierowania i agent zajmują już kilka. Przy wielu równoległych exec pojawiało się „channel is not opened”, więc je ograniczamy.
  • Odpytywanie available() co 20 ms nie dawało nawet 100 KB/s, a widok czatu czyta setki KB transkrypcji. Przeszliśmy na odczyty blokujące.

SSH na iOS z NMSSH

Na iOS SSH działa na NMSSH 2.3, które ma w środku libssh2 1.8.0. Aplikacja deklaruje go w Podfile z :modular_headers => true, żeby dało się go importować ze Swifta. Stara wersja libssh2 ma swoje skutki. Przekierowanie agenta potrzebuje callbacku LIBSSH2_CALLBACK_AUTHAGENT, który pojawił się w libssh2 1.9.0. NMSSHAccessors.m rejestruje go tylko wtedy, gdy nagłówek go definiuje, a w przeciwnym razie kompiluje się do pustej funkcji. Żeby dostać się do surowego kanału libssh2, który NMSSH trzyma w prywatnej właściwości, ten sam plik czyta zmienną instancji przez ivar_getOffset.

Musieliśmy też napisać w Swifcie bcrypt_pbkdf (BcryptPbkdf.swift, na wzór tego z OpenSSH), żeby odszyfrowywać klucze prywatne OpenSSH chronione hasłem. NMSSH zamyka kanał, zanim zwróci wynik polecenia, więc libssh2_channel_get_exit_status prawie zawsze daje 0. Kod patrzy na error z NMSSH, żeby nieudane polecenie nie wyglądało na sukces.

Symulator arm64

Biblioteki NMSSH nie mają wariantu arm64 dla symulatora, a z niego korzysta Mac z Apple Silicon. Rozwiązanie jest w post_install w Podfile: z WENDO_SSH_SIM_LIBS=1 podmienia pliki .a z NMSSH na libssh2 1.11.1 i OpenSSL 3.4.0 skompilowane dla arm64-ios-simulator, które leżą w packages/wendo_ssh/ios/libs, i wyklucza x86_64 ze wszystkich targetów.

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

Te biblioteki mają callback agenta, więc w symulatorze przekierowanie działa. Na urządzeniu nie są linkowane: tam kompiluje się bez tej zmiennej.

Mosh we Flutterze: napisany w Kotlinie, tylko na Androidzie

Mosh używa SSH tylko do startu. Wendo otwiera połączenie przez JSch i uruchamia serwer tak (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 --'

Całość idzie w pojedynczych cudzysłowach do sh, bo powłoką użytkownika może być fish. Linia z $SSH_CONNECTION jest potrzebna dla opcji „Użyj adresu IP serwera dla UDP”. Jeśli serwer nie ma locale en_US.UTF-8, ponawiamy z C.UTF-8. Z wyjścia czytamy MOSH CONNECT <port> <klucz> i od tej chwili wszystko idzie po UDP.

Klient implementuje w Kotlinie protokół Mosh 1.4, trzymając się network.cc i plików transportu: pakiety AES-128-OCB z BouncyCastle (MoshCrypto.kt), cztery potrzebne wiadomości protobuf napisane ręcznie (MoshProto.kt), fragmentacja według MTU, potwierdzenia, retransmisje i przeskok portu. Gdy przez jakiś czas nie ma wymiany w obie strony, klient otwiera nowe gniazdo z innym portem źródłowym, a serwer przechodzi na nie, gdy tylko zobaczy pakiety z nowego adresu. Dzięki temu sesja przeżywa przejście z Wi-Fi na dane komórkowe.

Ekran to część z największą liczbą pułapek. Mosh nie wysyła strumienia bajtów aplikacji, tylko różnicę między dwoma ekranami, a ta różnica ma sens wyłącznie względem stanu, od którego wychodzi. MoshScreen.kt utrzymuje ekran cień, który interpretuje tylko dialekt emitowany przez Display::new_frame, i nie jest pełnym emulatorem. Nad nim działa MoshPrediction.kt, nasza implementacja echa predykcyjnego (PredictionEngine) w trybach adaptive, always i never. Przed nałożeniem każdego diffu z serwera trzeba zdjąć predykcje i narysować je ponownie potem, w tym samym bloku, żeby xterm nie pokazał niczego w połowie.

Jeszcze dwie rzeczy. mosh-server zakłada, że terminal wysyła strzałki w trybie aplikacji, więc cursorKeysToSs3 zamienia ESC [ A na ESC O A. Bez tego vim i tmux ich nie rozpoznają. A jeśli pierwszy pakiet UDP nie dotrze (6 s w trybie Auto, 12 s przy ręcznie wybranym Mosh), świeżo uruchomiony mosh-server zostaje zabity, a błąd każe sprawdzić zaporę.

Testy JVM (MoshServerTest.kt) rozmawiają z prawdziwym lokalnym mosh-server 1.4, także przez symulowane łącze ze 120 ms opóźnienia w każdą stronę, żeby sprawdzić, że po skończeniu pisania xterm pokazuje dokładnie ekran serwera.

Eternal Terminal i aplikacja, którą system zabija

Sesja Mosh umiera razem z procesem aplikacji i żaden projekt tego nie zmieni. Serwer nie przechowuje niczego, z czego dałoby się nadrobić zaległości, a nonce w OCB to numer sekwencyjny pakietu, który w każdym procesie zaczyna się od 0. Wznowienie powtórzyłoby nonce przy tym samym kluczu, a w OCB to ujawnia tekst jawny i psuje uwierzytelnianie. Dlatego Wendo nigdy nie zapisuje stanu Mosh.

Eternal Terminal 7.0 da się wznowić, a klient też jest w Kotlinie: EtClient.kt, z szyfrowaniem crypto_secretbox (XSalsa20-Poly1305) zbudowanym na BouncyCastle. Gdy TCP padnie, klient łączy się ponownie co sekundę, a każda strona podaje, ile pakietów przeczytała, żeby druga dosłała brakujące. Żeby przetrwać zabicie aplikacji przez Androida, EtStateStore.kt zapisuje w prywatnych plikach aplikacji id i klucz sesji, zapieczętowane kluczem AES-256-GCM z Android Keystore, oraz dziennik każdego zaszyfrowanego pakietu zapisany, zanim pakiet wyjdzie przez gniazdo. Nonce w ET to licznik bez przeskoków, więc ten wcześniejszy zapis gwarantuje, że następny nonce będzie właściwy. Po ponownym otwarciu aplikacji restoreRecoverableSessions() zestawia to, co trzyma strona natywna, z kartami zapisanymi w Darcie, i sesje wracają na ekran Start. W aplikacji ET nadal jest oznaczony jako eksperymentalny.

Mosh i ET potrzebują jeszcze kanału SSH do exec i przekierowań portów. SideConnection.kt zachowuje połączenie ze startu, ale sprawdza je przed każdym użyciem, a jeśli padło przy zmianie sieci, otwiera nowe z tymi samymi odciskami i odtwarza przekierowania na tych samych lokalnych portach.

Co robi część w Darcie

lib/core/sessions.dart przechowuje wyjście każdej sesji (do 512 KB), nawet gdy żaden terminal nie jest otwarty, żeby odmalować je po powrocie. Przy Mosh, gdy przychodzi pełny ekran (reset), poprzednia zawartość jest odrzucana. Zdarzenie onTransportStatus niesie silentMs i rebindInMs, a interfejs na tej podstawie ostrzega, że serwer nie odpowiada, zamiast zamykać sesję. Jeśli wyłączysz „Podtrzymuj w tle”, sesje SSH zamykają się po przejściu aplikacji w tło. Sesje Mosh i ET zostają, bo rozłączenie zabiłoby sesję zdalną.

Tryb Auto mieszka w openSession(): próbuje Mosh, potem ET, ale tylko gdy port jest skonfigurowany albo coś odpowiada na 2022 (żeby nie sumować długich oczekiwań), a na końcu SSH. Z Jump Hostem albo przekierowaniem agenta idzie od razu do SSH, a błąd uwierzytelniania lub odcisku nie przechodzi do następnego transportu, bo SSH i tak by zawiódł.

Kompromisy, które zostały

  • Na iOS jest tylko SSH. Żądanie Mosh lub ET zwraca ERR_MOSH_UNSUPPORTED albo ERR_ET_UNSUPPORTED, a Auto od razu przechodzi do SSH.
  • Ani Mosh, ani ET nie obsługują Jump Hosta.
  • Mosh synchronizuje tylko to, co widać na ekranie. Jeśli chcesz mieć historię wstecz, pracuj w tmux, jak opisuje dokumentacja.
  • Mosh i ET są w Wendo Pro. Bez Pro limitToPlan zostawia połączenie na SSH.

Jeśli chcesz po prostu wybrać transport do śledzenia Claude Code z telefonu, a nie wiedzieć, jak to zrobiono, praktyczne porównanie jest w artykule Mosh czy SSH na telefonie, a pola każdego połączenia opisuje dokumentacja sesji odpornych na zerwania. Jak aplikacja używa tego samego SSH do powiadomień, bez własnego serwera, opisujemy w tekście aplikacja mobilna bez backendu.