LirafyブログWendo ↗
日本語

ブログ

Flutter アプリに SSH・Mosh・Eternal Terminal を組み込んだ方法

Wendo の Flutter 製 SSH クライアントは、Dart では書かれていません。アプリはプラットフォームチャネル経由で自前のプラグイン packages/wendo_ssh と通信し、ライブラリは OS ごとに別のものを使います。Android は JSch、iOS は NMSSH(libssh2)です。Mosh と Eternal Terminal はどのライブラリにも頼らず、プロトコルを Kotlin で実装したもので、今のところ Android にしかありません。ここでは、その作りと、途中でぶつかった問題、そして残っている妥協点を書いていきます。

Flutter で SSH クライアントを作る: 小さな契約のネイティブプラグイン

プラグインは packages/wendo_ssh にあり、Dart 側は薄く、コマンド用の MethodChannel と、サーバーから届くものすべてを受ける EventChannel だけです。

Wendo の SSH の中身: Dart のアプリはプラットフォームチャネルで wendo_ssh プラグインと話し、プラグインは Android では JSch・Mosh・ET を、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、disconnect は 3 つのトランスポートで共通です。イベント(onData、onClose、onTransportStatus)はひとつのネイティブストリームから出てきて、WendoSsh が名前で振り分けます。地味に助かっている点が 2 つあります。サーバーからのバイト列は base64 を挟まずに Uint8List として Dart に届くこと、そしてエラーは code(ERR_AUTH、ERR_MOSH_UDP、ERR_ET_GONE…)付きの SshException として届き、UI がそれを分かりやすいメッセージに変えられることです。

ネイティブ側では、ブロックする処理はすべてメインスレッドの外に出しています。Android は Dispatchers.IO 上のコルーチン、iOS は専用の DispatchQueue(wendo.ssh.io)です。イベントは EventChannel の要求どおり、必ずメインスレッドで返します。iOS では、チャネルのコーデックが受け付けないため、Any の中に入った Optional を剥がす関数を追加する必要がありました。

Android の SSH ライブラリは JSch

Android では、メンテナンスされている JSch のフォーク(com.github.mwiede:jsch)を BouncyCastle と組み合わせて使っています。Android は META-INF/versions を無視するので、JSch は Java 8 用のクラスで動き、それらは ed25519、curve25519、ML-KEM を BouncyCastle に任せます。この依存がなければ、アプリが生成する ed25519 の鍵は動きません。

リリースビルドでしか出ない不具合もあります。JSch と BouncyCastle はクラスを名前で読み込むのですが、R8 は誰も使っていないと判断して削除してしまいます。「Flutter SSH クライアント」で検索すると、ssh2 パッケージのページで同じ問題(com.jcraft.jsch.jce.Random の ClassNotFoundException)に行き着くはずです。wendo_ssh ではルールを consumer-rules.pro に置いているので、プラグインを使うアプリは何もしなくてもそれを引き継ぎます。

SshSession.kt には、どのサンプルにも載っていない細かい点が 3 つあります。

  • JSch の getInputStream() は呼ぶたびに新しいパイプを作ります。channel.connect() より前に呼ばないと、サーバーが最初に送ってくるもの(プロンプトや MOTD)を取りこぼします。
  • sshd が許すのは接続あたり 10 チャネル(MaxSessions)で、シェル、ポート転送、エージェントがすでにいくつか使っています。exec を一度にたくさん走らせると「channel is not opened」が出たので、同時実行数を制限しています。
  • available() を 20 ms ごとにポーリングして読む方式では 100 KB/s にも届かず、チャットビューは数百 KB のトランスクリプトを読みます。そこでブロッキング読み込みに切り替えました。

iOS の SSH ライブラリは NMSSH

iOS の SSH は NMSSH 2.3 で、中身は libssh2 1.8.0 です。Swift から import できるよう、アプリの Podfile で :modular_headers => true を付けて宣言しています。libssh2 が古いことには代償があります。エージェント転送には LIBSSH2_CALLBACK_AUTHAGENT コールバックが必要ですが、これが入ったのは libssh2 1.9.0 です。NMSSHAccessors.m はヘッダーがそれを定義している場合にだけ登録し、定義がなければ空の関数としてコンパイルされます。NMSSH が private プロパティに持っている libssh2 の生のチャネルにたどり着くため、同じファイルで ivar_getOffset を使ってインスタンス変数を読んでいます。

パスフレーズで保護された OpenSSH の秘密鍵を復号するために、bcrypt_pbkdf を Swift で書く必要もありました(BcryptPbkdf.swift、OpenSSH の実装に沿ったもの)。NMSSH はコマンドの結果を返す前にチャネルを閉じてしまうため、libssh2_channel_get_exit_status はほぼ常に 0 を返します。失敗したコマンドが成功に見えないよう、コードは NMSSH の error を見ています。

arm64 シミュレーター

NMSSH のライブラリには、Apple Silicon の Mac が使う arm64 シミュレーター向けのバリアントが入っていません。解決策は Podfile の post_install にあります。WENDO_SSH_SIM_LIBS=1 を付けると、NMSSH の .a を packages/wendo_ssh/ios/libs にある arm64-ios-simulator 向けにビルドした libssh2 1.11.1 と OpenSSL 3.4.0 に差し替え、すべてのターゲットから x86_64 を外します。

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

こちらのライブラリにはエージェントのコールバックがあるので、シミュレーターではエージェント転送が動きます。実機ではリンクしないので、実機向けは環境変数なしでビルドします。

Flutter で Mosh を使う: 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 --'

ユーザーのシェルが fish の場合もあるので、全体をシングルクォートで囲んで sh に渡しています。$SSH_CONNECTION の行は「UDP にサーバー IP を使用」オプションのためのものです。サーバーに en_US.UTF-8 ロケールがなければ C.UTF-8 で再試行します。出力から MOSH CONNECT <ポート> <鍵> を読み取り、そこから先はすべて UDP です。

クライアントは Mosh 1.4 のプロトコルを Kotlin で実装したもので、network.cc とトランスポート周りのファイルに沿っています。BouncyCastle による AES-128-OCB パケット(MoshCrypto.kt)、必要な 4 つの protobuf メッセージの手書き実装(MoshProto.kt)、MTU に合わせた分割、ACK、再送、ポートホッピングです。しばらく往復の通信がないと、クライアントは送信元ポートを変えた新しいソケットを開き、サーバーは新しいアドレスからパケットが届いた時点でそちらに追従します。こうして Wi-Fi からモバイル回線への切り替えを乗り切ります。

いちばん罠が多かったのは画面です。Mosh はアプリのバイトストリームではなく、2 つの画面の差分を送ります。その差分は、元になった状態に対してしか意味を持ちません。MoshScreen.kt は、Display::new_frame が出力する方言だけを解釈するシャドウ画面を持っていて、完全なエミュレーターではありません。その上に、予測エコー(PredictionEngine)を adaptive、always、never の各モードで独自に実装した MoshPrediction.kt が載っています。サーバーからの差分を適用する前に予測を取り除き、適用後に同じブロックの中で描き直さないと、xterm に中途半端な状態が表示されてしまいます。

細かい点をあと 2 つ。mosh-server は端末が矢印キーをアプリケーションモードで送るものとみなすので、cursorKeysToSs3 が ESC [ A を ESC O A に置き換えます。これがないと vim や tmux が矢印キーを認識しません。そして最初の UDP パケットが届かない場合(Auto なら 6 秒、手動で Mosh を選んだなら 12 秒)、起動したばかりの mosh-server を終了させ、ファイアウォールを確認するよう伝えるエラーを出します。

JVM テスト(MoshServerTest.kt)は、ローカルの本物の mosh-server 1.4 と通信します。片道 120 ms の遅延を入れた模擬回線も含め、入力し終えたときに xterm がサーバーの画面とまったく同じものを表示するかを確かめています。

Eternal Terminal と、OS に終了させられるアプリ

Mosh のセッションはアプリのプロセスと一緒に終わり、これはどう設計しても避けられません。サーバーは追いつくための状態を何も保存しませんし、OCB の nonce はパケットのシーケンス番号で、プロセスごとに 0 から始まります。再開すると同じ鍵で nonce を繰り返すことになり、OCB ではそれが平文の漏洩と認証の破綻につながります。だから Wendo は Mosh の状態を一切保存しません。

Eternal Terminal 7.0 は再開でき、クライアントはこちらも Kotlin です。EtClient.kt で、暗号は BouncyCastle の上に組んだ crypto_secretbox(XSalsa20-Poly1305)です。TCP が切れると 1 秒ごとに再接続し、双方が読んだパケット数を伝え合って、足りない分を相手が再送します。Android にアプリを終了させられても生き残るため、EtStateStore.kt はアプリのプライベートファイルにセッションの id と鍵を保存します。これは Android Keystore の AES-256-GCM 鍵で封印したもので、あわせて暗号化した各パケットを、ソケットに送り出す前に記録しておきます。ET の nonce は飛ばしを許さない連番カウンターなので、この事前の記録が次の nonce の正しさを保証します。アプリを開き直すと、restoreRecoverableSessions() がネイティブ側の保存内容と Dart 側に保存したカードを突き合わせ、セッションが「ホーム」に戻ってきます。アプリ内では、ET はまだ実験的機能の扱いです。

Mosh と ET には、exec とポート転送のための SSH チャネルも必要です。SideConnection.kt は起動時の接続を残しておきますが、使う前に毎回確認し、ネットワークの切り替えで切れていれば同じフィンガープリントで新しい接続を開き、ポート転送を同じローカルポートで張り直します。

Dart 側がやっていること

lib/core/sessions.dart は、ターミナルを開いていないときでも各セッションの出力(最大 512 KB)を保持し、戻ってきたときに描き直せるようにしています。Mosh では、画面全体(reset)が届いた時点でそれまでの分を捨てます。onTransportStatus イベントは silentMs と rebindInMs を運んでくるので、UI はセッションを閉じる代わりに、サーバーが応答していないことを知らせられます。「バックグラウンドで接続を維持」をオフにすると、バックグラウンドに回ったときに 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 を使い、自前のサーバーなしで通知を届けている仕組みは、バックエンドなしのモバイルアプリに書きました。