概要
iPhoneからMacへ、SSHではなくEternal Terminal(以下ET)というプロトコルで接続してみました。
iOSのET対応クライアントアプリは既存のものもありますが、今回はDartで自作してみました。
作ったライブラリはこちらで公開しています。
https://github.com/kodakamasaru/dart_et
動作環境: iPhone SE(第3世代)iOS 26.6 / macOS 26.7(Intel) / etserver 7.0.0 / Flutter 3.47.5 / Dart 3.13
経緯
AI利用に伴い、スマホからMacのターミナルを操作できると便利な場面がしばしばあります。
そして、そうしたリモート操作アプリがいくつかあることを知り、仕組みを調べるとmoshやETといった自分にとってあまり聞き馴染みのないプロトコルが使われていることが分かりました。
moshについては、Flutter(Dart)向けのライブラリdart_moshも存在したのですが、
ETのDartのライブラリは見当たらなかったので、学習も兼ねて自作してみました。
ETとは
ETは「自動で再接続してくれるリモートシェル」です。
SSH、moshとの違いをざっくり表にするとこのようになります。
| SSH | mosh | ET | |
|---|---|---|---|
| 通信 | TCP(22番) | UDP(60000〜61000番) | TCP(2022番) |
| サーバ側に必要なもの | sshd |
mosh-server(接続ごとにSSH経由で起動) |
etserver(常駐)+etterminal(接続ごとにSSH経由で起動) |
| 回線断からのセッション復帰 | × | 〇 | 〇 |
| 切れていた間の出力 | × | △ 最新の画面だけ届く | 〇 保持されている範囲を再送 |
| スクロールバック | 〇 | × | 〇 |
| 打った文字の即時表示(ローカルエコー予測) | × | 〇 | × |
| ポートフォワード | 〇 | × | 〇 |
SSHは、回線が切れたらそこでセッションが終わりです。
moshとETは、どちらも最初の起動と認証にSSHを使い、そのあとは独自の通信に切り替えることで、回線が切れても繋がり続けるようにしています。
違いは復帰のしかたで、moshは「画面の状態」を同期するので、切れていた間の途中の出力は飛ばされ、スクロールして遡ることもできません。
ETは一定量の送信済みパケットを保持しておき、再接続したときに相手が受け取れていない分を再送するので、途中の出力も届きます。
逆に、回線遅延が大きい環境での打鍵は、ローカルエコー予測を行うmoshの方が快適です。
完成品紹介
接続画面です。
ホストとユーザー名、SSHのパスワードを入れて接続します。
接続すると、このようにターミナルが開きます。
使い方
Mac側の準備
- Homebrewでetをインストールし、etserverを起動します
brew install et
brew services start et
- 「システム設定→一般→共有→リモートログイン」をオンにします(SSH用)
etserverはTCPの2022番ポートで待ち受けます。
同じWi-Fi内であれば、これだけでiPhoneからMacのIPアドレス(192.168.x.xなど)宛てに繋がります。
- 外出先からも繋ぐ場合は、MacとiPhoneにTailscaleを入れます
Tailscaleは、自分の端末同士をVPNで繋いでくれるサービスです。
MacとiPhoneの両方にアプリを入れて同じアカウントでログインするだけで、各端末に100.x.y.z のようなTailscaleのアドレスが割り当てられ、Tailscaleが正常に接続されていればモバイル通信からでもそのアドレス宛てでMacへ届くようになります。
ルーターのポート開放などが不要なので、個人で使うには一番手軽だと思います(執筆時点で、個人利用は無料で使えます)。
- 必要に応じて、etserverのtelemetryをオフにします
telemetryは、etserverでエラーやクラッシュが起きたときに、その内容を匿名で開発元(DatadogとSentry)に送る機能です。
Homebrewで入れた場合、設定ファイルでデフォルトでオンになっているので、気になる方はオフにしておくとよいと思います。
設定ファイルは$(brew --prefix)/etc/et.cfgにあります。
[Debug]
telemetry = false
※変更後はbrew services restart etで反映されます。
クライアント側
まずは、ライブラリに付属しているCLIのサンプルで、ETで繋がることを確認できます。
Dart SDKが入っている環境で、以下を実行します。
git clone https://github.com/kodakamasaru/dart_et.git
cd dart_et
dart pub get
dart run example/et_cli.dart ユーザー名@MacのIPアドレス
※CLIのサンプルは、etterminalの起動にPCのsshコマンドを使います。事前にssh ユーザー名@MacのIPアドレスで普通にログインできることを確認しておいてください。
自分のアプリに組み込む場合は、pubspec.yamlにGitのURLで追加します(pub.devには公開していません)。
dependencies:
dart_et:
git:
url: https://github.com/kodakamasaru/dart_et.git
今回のiPhoneアプリも、このライブラリを使って作っています。
ETの仕組み
公式サイトには大まかな説明しかないため、基本的にはGitHubのソースを読んで調べました。
接続の流れ
- クライアントがSSHでサーバに入り、
etterminalを起動する -
etterminalがセッションIDと鍵(passkey)を発行して標準出力に表示するので、クライアントはそれを受け取ってSSHを切断する - クライアントはTCP 2022番で
etserverに接続し、以降はETだけで通信する
ET接続時の認証にはSSHの認証を利用するため、ETプロトコル自体ではパスワードや公開鍵によるユーザー認証を行いません。
SSHで認証したユーザーだけがetterminalを起動でき、その際に発行されたpasskeyを使ってET接続を確立します。
1.で実行するコマンドは↓これだけです。
echo 'XXXabcdefghijklm/0123456789abcdefghijklmnopqrstuv_xterm-256color' | etterminal --verbose=0
ID/passkey_TERMの形式でランダムな値を渡すと、以下のような行が返ってきます。
サーバ側で採番し直した値が返ってくるので、こちらを使います。
IDPASSKEY:aB3dE5fG7hJ9kL1m/(32文字の英数字)
通信の暗号化
etserverに繋いだあとの通信は、すべて暗号化されています。
暗号化には、libsodiumという定番の暗号ライブラリの機能(crypto_secretbox)が使われていて、passkeyから得られる32バイトの値を鍵として使います。
この方式では、鍵のほかに「nonce」という、暗号化のたびに毎回違う値にしなければいけない番号が必要です。
ETでは、nonceは24バイトの番号で、行き(クライアント→サーバ)と帰り(サーバ→クライアント)で別々に1から数え上げています。
このままだと行きと帰りで同じ番号が使われてしまうので、最上位の1バイトだけは数え上げに使わず、行きは0、帰りは1に固定しています。
こうすることで、同じ鍵で暗号化していても、行きと帰りのnonceが同じ値になることはありません。
切れても復帰できる仕組み
ここがETの肝です。
- 送信側は、送った暗号化済みのパケットを一定量(64MB)手元に残しておく
- 再接続したら、お互いに「今までに何個受け取ったか」を送り合う
- 相手が受け取れていない分を、手元に残しておいたパケットから再送する
再送するのは「一度暗号化したパケットそのもの」なので、nonceのカウンタがずれることもありません。
実装1: ライブラリの構成
コードの全体はkodakamasaru/dart_etにあります。ここでは要点だけ抜粋して紹介します。
ライブラリの切り分けはdart_moshに合わせました。
- SSHと画面描画はアプリ側の責務とし、ライブラリは「起動コマンドの生成」「出力のパース」「通信」だけを持つ
-
MoshSshBootstrap→EtSshBootstrap、MoshServerConfig.parse→EtCredentials.parseのように、APIの形を揃える - protobufを
protocなしで手書きする(dart_moshのProtoWriter/ProtoReaderを流用し、bool・文字列・負の数を追加)
ただ、moshはUDPで状態を同期、ETはTCPで再送なので、中心の処理は別物になりました。
使う側からはこのようなイメージです。
// 1. SSHで etterminal を起動して鍵を受け取る(SSH部分はアプリ側)
final command = const EtSshBootstrap().command(EtCredentials.random());
final output = await sshClient.run(command);
// 2. ETで接続
final session = await EtSession.connect(
host: '100.x.y.z',
credentials: EtCredentials.parse(utf8.decode(output)),
);
session.stdout.listen(...); // 出力
session.send(utf8.encode('ls\r')); // 入力
session.resize(80, 24); // 画面サイズ
session.reconnect(); // アプリ復帰時などに即再接続
実装2: 暗号化
やりたいことは、「etserverと同じ方式で暗号化・復号できること」です。
etserver側の暗号化ライブラリ(libsodium)と同じ方式のDartライブラリpinenaclを使いました。
↓nonceを1ずつ数え上げる部分だけは、ETに合わせて自分で書いています。
Uint8List encrypt(List<int> plaintext) {
_incrementNonce(); // 暗号化する前に、番号を1つ進める
// pinenaclの関数は、先頭に32バイトの0を付けたデータを受け取る決まりなので、付けてから渡す
final padded = Uint8List(TweetNaCl.zerobytesLength + plaintext.length);
padded.setRange(TweetNaCl.zerobytesLength, padded.length, plaintext);
return TweetNaCl.crypto_secretbox(
Uint8List(padded.length), padded, padded.length, _nonce, _key);
}
void _incrementNonce() {
// 24バイトの番号を、先頭のバイトから繰り上がりつきで +1 する
for (var i = 0; i < etNonceLength; i++) {
_nonce[i] = (_nonce[i] + 1) & 0xff;
if (_nonce[i] != 0) break; // 繰り上がりがなければ終了
}
}
実装3: 再接続
ETの「受け取った数を送り合って、足りない分を再送する」部分です。
例えば、クライアントがサーバから100個、サーバがクライアントから80個のパケットを受け取っていた場合、以下のように再送します。
Client etserver
| |
|---- Sequence(100) ---------------->|
| |
|<----------------- Sequence(80) ----|
| |
|<---------- server packets 101~ ----|
| |
|---- client packets 81~ ----------->|
| |
- クライアントが「サーバから100個受け取った」と伝える
- サーバが「クライアントから80個受け取った」と伝える
- サーバは、自分が送ったパケットの101個目以降を再送する
- クライアントは、自分が送ったパケットの81個目以降を再送する
出てくる名前は、それぞれ以下のものです。
-
_reader/_writer: 受信側・送信側の管理。受け取った数と、送ったパケットの控えを持っている -
EtSequenceHeader: 「今までに何個受け取ったか」を入れて送るメッセージ -
EtCatchupBuffer: 再送するパケットをまとめて送るメッセージ -
frameProto/readProto: メッセージに長さを付けて送る・受け取る処理
↓は、エラー処理などを省いた抜粋です(全体はlib/src/transport/connection.dart)。
// 自分が受け取った数を送る
link.add(frameProto(
EtSequenceHeader(sequenceNumber: _reader.sequenceNumber).encode(),
));
// 相手が受け取った数を受け取る
final remote = EtSequenceHeader.decode(await readProto(queue));
// 相手が持っていない分を手元のバックアップから取り出す
final local = _writer.recover(remote.sequenceNumber);
// 相手の再送分を受け取ってから、自分の再送分を送る
final catchup = EtCatchupBuffer.decode(
await readProto(queue, maxLength: etMaxMessageLength),
);
link.add(frameProto(EtCatchupBuffer(local).encode()));
実装4: iPhoneアプリ
アプリはFlutterで作り、SSHにdartssh2、画面描画にxtermを使っています。
自作する場合にハマりやすいところが2つあったので紹介します。
LAN内のMacに繋ぐと「No route to host」になる
シミュレータでは繋がるのに、実機だとLAN内のMac(192.168.x.x)に繋ぐとNo route to hostになりました。
原因はiOSの「ローカルネットワーク」の許可で、Info.plistに利用目的の文言を書いておかないと、許可ダイアログすら出ずに拒否されるようです。
<key>NSLocalNetworkUsageDescription</key>
<string>同じネットワーク上の Mac に SSH / Eternal Terminal で接続するために使います。</string>
バックスペースが効かない
xtermのTerminalViewにあるdeleteDetectionというオプションをtrueにすることで解決しました(デフォルトはfalse)。
TerminalView(
terminal,
deleteDetection: true,
)
実機で試してみた
1秒ごとに番号を出力するループを流しながら、いろいろな方法で回線を切ってみました。
for i in $(seq 1 300); do echo $i; sleep 1; done
- 画面ロック→解除
- 機内モード→解除
- Wi-Fiオフ(モバイル通信に切り替え)
- モバイル通信で画面ロック→解除
どれも番号が抜けることなく続きました。
Wi-Fi⇔モバイル通信の切り替えは、Tailscale経由(100.x.y.z宛て)で試しています。
モバイル通信からは家のWi-Fi内のアドレス(192.168.x.x)には届かないので、Tailscaleなしだと切り替えた瞬間に繋ぎ先がなくなってしまうためです。
なお、Tailscaleを使うと、回線が変わってもトンネルの中のIPアドレスは変わらないので、短い切り替えであれば普通のSSHでも耐えることがあります。
ETが本領を発揮するのは、画面ロックやアプリのバックグラウンド化のように、TCPの接続自体が死んでしまう場面の方だと思います。
まとめ
今回はmoshやETといったプロトコルについて調べ、実際にETクライアントを実装して手持ちのiPhoneからMacのターミナルを操作してみました。
ETやDartに興味がある方、既存クライアントでは実現しづらいカスタマイズをしたい方は、本記事やライブラリを参考に自作してみてはいかがでしょうか。
最後まで読んでいただきありがとうございます。