Як ми вбудували SSH, Mosh і Eternal Terminal у застосунок на Flutter

У Wendo SSH-клієнт на Flutter написаний не на 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. Передбачення треба прибрати перед застосуванням кожного diff від сервера і намалювати знову після нього, в тому самому блоці, щоб 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 для сповіщень без власного сервера, ми розповідаємо в статті мобільний застосунок без бекенду.