Flutter SSH 用戶端:我們如何把 SSH、Mosh 和 Eternal Terminal 放進 App

Wendo 的 Flutter SSH 用戶端不是用 Dart 寫的。App 透過平台通道和我們自己的外掛 packages/wendo_ssh 溝通,每個系統各自使用自己的函式庫:Android 是 JSch,iOS 是 NMSSH(libssh2)。Mosh 和 Eternal Terminal 沒有現成的函式庫可用,兩個協定都是我們自己用 Kotlin 實作的,目前只有 Android 版。以下說明整體怎麼組起來、過程中解決了哪些問題,以及還留著哪些妥協。
用 Flutter 實作 SSH 用戶端:一個契約很小的原生外掛
外掛放在 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 依名稱分派。有兩個細節很貼心:伺服器傳來的位元組以 Uint8List 送到 Dart,中間不經過 base64;錯誤則以帶有 code 的 SshException 送達(ERR_AUTH、ERR_MOSH_UDP、ERR_ET_GONE……),介面再把它轉成有用的訊息。
在原生這一側,所有會阻塞的工作都移出主執行緒: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 處理。少了這個相依套件,App 產生的 ed25519 金鑰就不能用。
有個錯誤只在 release 建置中出現。JSch 和 BouncyCastle 依名稱載入類別,R8 看不到有誰在用,就把它們刪掉了。如果你搜尋過「Flutter SSH 用戶端」,大概在 ssh2 套件的頁面上看過同樣的問題(com.jcraft.jsch.jce.Random 的 ClassNotFoundException)。在 wendo_ssh 裡,這些規則放在 consumer-rules.pro,所以使用這個外掛的 App 不必動任何設定就會繼承。
SshSession.kt 裡還有三個在任何範例中都看不到的細節:
- JSch 每次呼叫
getInputStream()都會建立一條新的管線。必須在channel.connect()之前取得,否則伺服器最先送來的內容(提示字元、MOTD)就會遺失。 sshd每條連線最多允許 10 個通道(MaxSessions),shell、連接埠轉發和 agent 已經佔掉一些。同時跑很多個exec時會出現「channel is not opened」,所以我們限制了數量。- 每 20 ms 輪詢一次
available()來讀取,連 100 KB/s 都不到,而聊天檢視要讀好幾百 KB 的對話紀錄。後來改成阻塞式讀取。
iOS 上用什麼實作 SSH:NMSSH
iOS 上的 SSH 用的是 NMSSH 2.3,內含 libssh2 1.8.0。App 在 Podfile 中以 :modular_headers => true 宣告它,才能從 Swift 匯入。libssh2 的版本舊是有後果的。agent 轉發需要 LIBSSH2_CALLBACK_AUTHAGENT 回呼,這是 libssh2 1.9.0 才加入的;NMSSHAccessors.m 只在標頭檔有定義它時才註冊,否則編譯成一個空函式。NMSSH 把 libssh2 的原始通道存在私有屬性裡,為了取得它,同一個檔案用 ivar_getOffset 讀取這個實例變數。
我們還得用 Swift 寫一個 bcrypt_pbkdf(BcryptPbkdf.swift,參照 OpenSSH 的實作),用來解密有密語保護的 OpenSSH 私鑰。另外,NMSSH 會在回傳指令結果之前就關閉通道,所以 libssh2_channel_get_exit_status 幾乎總是回傳 0:程式碼改看 NMSSH 的 error,免得失敗的指令看起來像是成功了。
arm64 模擬器
NMSSH 的函式庫沒有 arm64 模擬器版本,而 Apple Silicon 的 Mac 用的正是這個。解法在 Podfile 的 post_install 裡:設定 WENDO_SSH_SIM_LIBS=1 時,它會把 NMSSH 的 .a 換成為 arm64-ios-simulator 編譯的 libssh2 1.11.1 和 OpenSSL 3.4.0(放在 packages/wendo_ssh/ios/libs),並在所有 target 中排除 x86_64。
(cd ios && WENDO_SSH_SIM_LIBS=1 pod install) && WENDO_SSH_SIM_LIBS=1 fvm flutter build ios --simulator --debug
這些函式庫有 agent 回呼,所以在模擬器上轉發可以運作。實機上不會連結它們:編譯時不設這個變數。
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 --'
整段用單引號交給 sh,因為使用者的 shell 可能是 fish。$SSH_CONNECTION 那一行是給「UDP 使用伺服器 IP」選項用的。如果伺服器沒有 en_US.UTF-8 這個 locale,就改用 C.UTF-8 重試。從輸出讀出 MOSH CONNECT <連接埠> <金鑰>,之後全部走 UDP。
用戶端以 Kotlin 實作 Mosh 1.4 的協定,照著 network.cc 和傳輸層的檔案來寫:用 BouncyCastle 實作的 AES-128-OCB 封包(MoshCrypto.kt)、手寫的四則必要 protobuf 訊息(MoshProto.kt)、依 MTU 分段、確認、重送,以及連接埠跳換。一段時間沒有來回時,用戶端會用另一個來源連接埠開新的 socket,伺服器一看到新位址送來的封包就跟過去。靠這個機制,從 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 秒,手動選 Mosh 時等 12 秒),就砍掉剛啟動的 mosh-server,錯誤訊息會請你檢查防火牆。
JVM 測試(MoshServerTest.kt)會和本機上真正的 mosh-server 1.4 溝通,包括一條每個方向延遲 120 ms 的模擬鏈路,用來確認打完字之後 xterm 顯示的正好是伺服器的畫面。
Eternal Terminal,以及會被系統砍掉的 App
Mosh 工作階段會隨著 App 的處理程序一起結束,這是任何設計都避免不了的。伺服器沒有保存任何能讓它追上進度的東西,而 OCB 的 nonce 就是封包的序號,每個處理程序都從 0 開始。恢復工作階段等於用同一把金鑰重複使用 nonce,在 OCB 中這會洩漏明文並破壞驗證。所以 Wendo 從不保存 Mosh 的狀態。
Eternal Terminal 7.0 則可以恢復,它的用戶端也是 Kotlin 寫的:EtClient.kt,加密用的是建立在 BouncyCastle 上的 crypto_secretbox(XSalsa20-Poly1305)。TCP 斷線時,它每秒重連一次,雙方各自告知讀了多少個封包,讓對方補送缺少的部分。為了在 Android 砍掉 App 之後仍能存活,EtStateStore.kt 會在 App 的私有檔案中保存工作階段的 id 和金鑰(以 Android Keystore 裡的一把 AES-256-GCM 金鑰封存),以及每個加密封包的紀錄,這筆紀錄在封包從 socket 送出之前就寫好。ET 的 nonce 是不允許跳號的連續計數器,所以正是這筆事先寫下的紀錄,保證了下一個 nonce 是對的。重新打開 App 時,restoreRecoverableSessions() 會把原生層保存的資料和 Dart 中儲存的卡片對起來,工作階段就重新出現在「首頁」。在 App 裡,ET 仍標示為實驗性功能。
Mosh 和 ET 另外還需要一條 SSH 通道來跑 exec 和連接埠轉發。SideConnection.kt 保留啟動時的那條連線,但每次使用前都會先檢查;如果它在網路切換時斷了,就用相同的主機指紋開一條新的,並在原本的本機連接埠上重建轉發。
Dart 這一側負責什麼
lib/core/sessions.dart 會保存每個工作階段的輸出(最多 512 KB),即使沒有開著終端機也一樣,回來時才能重畫。用 Mosh 時,收到完整的一個畫面(reset)就丟掉先前的內容。onTransportStatus 事件帶有 silentMs 和 rebindInMs,介面靠它們提示伺服器沒有回應,而不是直接關閉工作階段。如果你關掉「背景保持連線」,App 進入背景時會關閉 SSH 工作階段;Mosh 和 ET 的會留著,因為中斷它們會殺掉遠端的工作階段。
「自動」模式寫在 openSession() 裡:先試 Mosh,接著只有在設定了連接埠、或 2022 埠有回應時才試 ET(免得累積長時間的等待),最後是 SSH。使用跳板主機(Jump Host)或 agent 轉發時直接走 SSH;驗證錯誤或指紋錯誤不會讓它退到下一個,因為換成 SSH 一樣會失敗。
還留著的妥協
- iOS 上只有 SSH。要求 Mosh 或 ET 會回傳
ERR_MOSH_UNSUPPORTED或ERR_ET_UNSUPPORTED,「自動」會直接改走 SSH。 - Mosh 和 ET 都不支援跳板主機。
- Mosh 只同步畫面上看得到的內容:想要往回捲的歷史,就在 tmux 裡工作,文件裡有說明。
- Mosh 和 ET 屬於 Wendo Pro;沒有 Pro 時,
limitToPlan會讓連線維持 SSH。
如果你想要的是挑一種傳輸方式,用手機追蹤 Claude Code,而不是了解它怎麼做出來,實用的比較在手機上的 Mosh 與 SSH,每個連線欄位的說明則在撐得住斷線的工作階段文件。App 怎麼用同一套 SSH 傳遞通知、不靠自己的伺服器,我們寫在無後端的手機 App。