SSH-клиент на Flutter: как мы встроили SSH, Mosh и Eternal Terminal

SSH-клиент на Flutter в Wendo написан не на Dart. Приложение общается с собственным плагином packages/wendo_ssh через платформенный канал, а каждая система подставляет свою библиотеку: JSch на Android и NMSSH (libssh2) на iOS. Mosh и Eternal Terminal ни из какой библиотеки не берутся: оба протокола мы сами реализовали на Kotlin, и пока они есть только на Android. Здесь рассказываем, как всё устроено, какие проблемы пришлось решить по дороге и какие компромиссы остались.
SSH-клиент на Flutter: нативный плагин с маленьким контрактом
Плагин лежит в packages/wendo_ssh, а сторона Dart тонкая: MethodChannel для команд и EventChannel для всего, что приходит с сервера.
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 и disconnect одинаковы для всех трёх транспортов. События (onData, onClose, onTransportStatus) идут одним нативным потоком, который WendoSsh раздаёт по имени. Две полезные мелочи: байты с сервера попадают в Dart как Uint8List, без base64 посередине, а ошибки приходят как SshException с полем code (ERR_AUTH, ERR_MOSH_UDP, ERR_ET_GONE…), которое интерфейс превращает в понятное сообщение.
На нативной стороне всё блокирующее уходит с главного потока: на Android через корутины на Dispatchers.IO, на iOS через собственную DispatchQueue (wendo.ssh.io). События всегда возвращаются через главный поток, этого требует EventChannel. На iOS пришлось добавить функцию, которая разворачивает Optional, вложенные в Any: кодек канала их не принимает.
SSH на Android с JSch
На Android мы используем поддерживаемый форк JSch (com.github.mwiede:jsch) с BouncyCastle. Android игнорирует META-INF/versions, поэтому JSch в итоге работает на своих классах для Java 8, а они передают ed25519, curve25519 и ML-KEM в BouncyCastle. Без этой зависимости ключи ed25519, которые генерирует приложение, не заработали бы.
Есть баг, который проявляется только в release-сборке. JSch и BouncyCastle загружают классы по имени, а R8 их вырезает, потому что не видит, кто их использует. Если ищешь «SSH-клиент Flutter», рано или поздно прочитаешь про ту же проблему на странице пакета ssh2 (ClassNotFoundException для com.jcraft.jsch.jce.Random). В wendo_ssh правила лежат в consumer-rules.pro, так что приложение, подключившее плагин, наследует их без всяких правок.
Ещё три детали из SshSession.kt, которых нет ни в одном примере:
- Каждый
getInputStream()в JSch создаёт новую трубу. Его надо запросить доchannel.connect(), иначе пропадёт то, что сервер отправляет первым (промпт, MOTD). sshdразрешает 10 каналов на соединение (MaxSessions), а оболочка, пробросы и агент уже занимают часть. При множестве одновременныхexecвылезало «channel is not opened», поэтому их число ограничено.- Чтение с опросом
available()каждые 20 мс не давало и 100 КБ/с, а режим чата читает сотни КБ транскрипта. Перешли на блокирующее чтение.
SSH на iOS с NMSSH
На iOS SSH работает через NMSSH 2.3, внутри которого libssh2 1.8.0. Приложение объявляет его в Podfile с :modular_headers => true, чтобы импортировать из Swift. У старой libssh2 есть последствия. Пробросу агента нужен колбэк LIBSSH2_CALLBACK_AUTHAGENT, появившийся в libssh2 1.9.0. NMSSHAccessors.m регистрирует его, только если заголовок его определяет, а иначе компилируется в пустую функцию. Чтобы добраться до сырого канала libssh2, который NMSSH держит в приватном свойстве, тот же файл читает переменную экземпляра через ivar_getOffset.
Ещё пришлось написать на Swift bcrypt_pbkdf (BcryptPbkdf.swift, по образцу из OpenSSH), чтобы расшифровывать приватные ключи OpenSSH, защищённые парольной фразой. А NMSSH закрывает канал до того, как вернуть результат команды, поэтому libssh2_channel_get_exit_status почти всегда даёт 0. Код смотрит на error от NMSSH, чтобы упавшая команда не выглядела успешной.
Симулятор arm64
В библиотеках NMSSH нет варианта arm64 для симулятора, а именно он нужен Mac на Apple Silicon. Решение в post_install в Podfile: при WENDO_SSH_SIM_LIBS=1 он подменяет .a от NMSSH на libssh2 1.11.1 и OpenSSL 3.4.0, собранные под arm64-ios-simulator и лежащие в packages/wendo_ssh/ios/libs, и исключает x86_64 из всех таргетов.
(cd ios && WENDO_SSH_SIM_LIBS=1 pod install) && WENDO_SSH_SIM_LIBS=1 fvm flutter build ios --simulator --debug
В этих библиотеках колбэк агента есть, так что в симуляторе проброс работает. На устройстве они не линкуются: там сборка идёт без этой переменной.
Mosh во Flutter: написан на Kotlin, только для Android
Mosh использует SSH только для запуска. Wendo открывает соединение через JSch и запускает сервер так (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 --'
Всё передаётся в sh в одинарных кавычках, потому что оболочкой пользователя может оказаться fish. Строка с $SSH_CONNECTION нужна для опции «Использовать IP сервера для UDP». Если на сервере нет локали en_US.UTF-8, попытка повторяется с C.UTF-8. Из вывода читается MOSH CONNECT <порт> <ключ>, и дальше всё идёт по UDP.
Клиент реализует протокол Mosh 1.4 на Kotlin, следуя network.cc и файлам транспорта: пакеты AES-128-OCB на BouncyCastle (MoshCrypto.kt), четыре нужных сообщения protobuf, написанные вручную (MoshProto.kt), фрагментация по MTU, подтверждения, повторные отправки и смена порта. Когда какое-то время нет обмена в обе стороны, клиент открывает новый сокет с другим исходным портом, и сервер переключается на него, как только видит пакеты с нового адреса. Так сессия переживает переход с Wi-Fi на мобильные данные.
Экран — часть с самым большим числом подвохов. Mosh отправляет не поток байтов приложения, а разницу между двумя экранами, и эта разница имеет смысл только относительно исходного состояния. MoshScreen.kt держит теневой экран, который понимает только диалект, выдаваемый Display::new_frame, и полноценным эмулятором не является. Поверх работает MoshPrediction.kt, наша реализация предиктивного эха (PredictionEngine) в режимах adaptive, always и never: перед применением каждого диффа с сервера предсказания нужно снять, а после перерисовать, в том же блоке, чтобы xterm не показал ничего наполовину.
И ещё две вещи. mosh-server считает, что терминал шлёт стрелки в режиме приложения, поэтому cursorKeysToSs3 меняет ESC [ A на ESC O A, иначе vim и tmux их не распознают. А если первый UDP-пакет не доходит (6 с в режиме Auto, 12 с при вручную выбранном Mosh), только что запущенный mosh-server убивается, а ошибка советует проверить файрвол.
JVM-тесты (MoshServerTest.kt) общаются с настоящим локальным mosh-server 1.4, в том числе через симулированный канал со 120 мс задержки в каждую сторону, и проверяют, что после окончания набора xterm показывает ровно экран сервера.
Eternal Terminal и приложение, которое убивает система
Сессия Mosh умирает вместе с процессом приложения, и никакая архитектура этого не изменит. Сервер не хранит ничего, по чему можно было бы догнать состояние, а nonce в OCB — это порядковый номер пакета, который в каждом процессе начинается с 0. Возобновление повторило бы nonce с тем же ключом, а в OCB это раскрывает открытый текст и ломает аутентификацию. Поэтому Wendo никогда не сохраняет состояние Mosh.
Eternal Terminal 7.0 возобновлять можно, и его клиент тоже на Kotlin: EtClient.kt, с шифрованием crypto_secretbox (XSalsa20-Poly1305) на BouncyCastle. Если TCP падает, клиент переподключается каждую секунду, а каждая сторона сообщает, сколько пакетов прочитала, чтобы другая дослала недостающие. Чтобы пережить убийство приложения системой Android, EtStateStore.kt сохраняет в приватных файлах приложения id и ключ сессии, запечатанные ключом AES-256-GCM из Android Keystore, и журнал каждого зашифрованного пакета, записанный до того, как пакет уйдёт в сокет. Nonce в ET — последовательный счётчик без пропусков, поэтому именно эта предварительная запись гарантирует правильный следующий nonce. При повторном открытии приложения restoreRecoverableSessions() сверяет то, что хранит нативная часть, с карточками, сохранёнными в Dart, и сессии снова появляются на экране «Главная». В приложении ET по-прежнему помечен как экспериментальный.
Mosh и ET нужен ещё SSH-канал для exec и проброса портов. SideConnection.kt сохраняет соединение, через которое шёл запуск, но проверяет его перед каждым использованием, а если оно упало при смене сети, открывает новое с теми же отпечатками и восстанавливает пробросы на тех же локальных портах.
Что делает часть на Dart
lib/core/sessions.dart хранит вывод каждой сессии (до 512 КБ), даже если терминал не открыт, чтобы перерисовать его при возвращении. С Mosh, когда приходит полный экран (reset), прежнее содержимое отбрасывается. Событие onTransportStatus несёт silentMs и rebindInMs, и по ним интерфейс предупреждает, что сервер не отвечает, вместо того чтобы закрыть сессию. Если выключить «Держать активным в фоне», SSH-сессии закрываются, когда приложение уходит в фон. Сессии Mosh и ET остаются: отключение убило бы удалённую сессию.
Режим Auto живёт в openSession(): он пробует Mosh, потом ET, но только если настроен порт или что-то отвечает на 2022 (чтобы не складывать долгие ожидания), и в конце SSH. С Jump Host или пробросом агента он сразу идёт в SSH, а ошибка аутентификации или отпечатка не передаёт управление следующему транспорту, потому что SSH упал бы так же.
Оставшиеся компромиссы
- На iOS есть только SSH. Запрос Mosh или ET возвращает
ERR_MOSH_UNSUPPORTEDилиERR_ET_UNSUPPORTED, и Auto сразу переходит к SSH. - Ни Mosh, ни ET не поддерживают Jump Host.
- Mosh синхронизирует только то, что видно на экране. Если нужна история назад, работай внутри tmux, как описано в документации.
- Mosh и ET входят в Wendo Pro. Без Pro
limitToPlanоставляет соединение на SSH.
Если тебе нужно просто выбрать транспорт, чтобы следить за Claude Code с телефона, а не разбираться, как он сделан, практическое сравнение есть в статье Mosh или SSH на телефоне, а поля каждого подключения описаны в документации о сессиях, которые переживают обрывы. Как приложение использует тот же SSH для уведомлений без собственного сервера, мы рассказали в статье мобильное приложение без бэкенда.