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 하나뿐입니다.
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가 이름으로 나눠 줍니다. 써 보면 고마운 점이 두 가지 있습니다. 서버의 바이트가 중간에 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 키가 동작하지 않습니다.
release 빌드에서만 나타나는 버그도 있습니다. JSch와 BouncyCastle은 클래스를 이름으로 불러오는데, R8은 아무도 쓰지 않는다고 보고 그 클래스들을 지워 버립니다. “Flutter SSH 클라이언트”로 검색하면 ssh2 패키지 페이지에서 같은 문제(com.jcraft.jsch.jce.Random의 ClassNotFoundException)를 만나게 됩니다. wendo_ssh는 규칙을 consumer-rules.pro에 두었기 때문에, 플러그인을 쓰는 앱은 아무것도 건드리지 않아도 그 규칙을 물려받습니다.
SshSession.kt에는 어떤 예제에도 나오지 않는 세부 사항이 세 가지 더 있습니다.
- JSch의
getInputStream()은 호출할 때마다 새 파이프를 만듭니다.channel.connect()보다 먼저 호출하지 않으면 서버가 처음 보내는 것(프롬프트, MOTD)을 놓칩니다. sshd는 연결당 채널을 10개(MaxSessions)까지 허용하고, 셸과 포트 포워딩과 에이전트가 이미 몇 개를 차지합니다.exec를 한꺼번에 많이 돌리면 “channel is not opened”가 나서 동시 실행 수를 제한했습니다.- 20 ms마다
available()를 폴링하며 읽으면 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), 필요한 protobuf 메시지 네 개를 손으로 작성한 코드(MoshProto.kt), MTU에 맞춘 분할, ACK, 재전송, 포트 호핑이 들어 있습니다. 한동안 왕복 통신이 없으면 클라이언트는 출발 포트가 다른 새 소켓을 열고, 서버는 새 주소에서 패킷이 오는 것을 보자마자 그쪽을 따라갑니다. 이렇게 Wi-Fi에서 셀룰러로 넘어가도 살아남습니다.
함정이 가장 많은 곳은 화면입니다. Mosh는 앱의 바이트 스트림이 아니라 두 화면 사이의 차이를 보내고, 그 차이는 출발점이 된 상태 위에서만 의미가 있습니다. MoshScreen.kt는 Display::new_frame이 내보내는 방언만 해석하는 섀도 화면을 유지하며, 완전한 에뮬레이터는 아닙니다. 그 위에 예측 에코(PredictionEngine)를 adaptive, always, never 모드로 직접 구현한 MoshPrediction.kt가 올라갑니다. 서버의 diff를 적용하기 전에 예측을 걷어 내고 적용한 뒤 같은 블록 안에서 다시 그려야, xterm에 반쯤 그려진 화면이 보이지 않습니다.
세부 사항이 두 가지 더 있습니다. 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, 그리고 시스템이 종료시키는 앱
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를 그대로 써서 자체 서버 없이 알림을 전달하는 방식은 백엔드 없는 모바일 앱에 적었습니다.