何を作っているか
Otter Beam という、スマホやタブレットから自分のマシン上のエージェントセッション(Claude Code、Codex など)を操作するツールを作っています。
発想は単純で、エージェントのセッションがウィンドウにぶら下がって生きているのが不便だ、というところから始まっています。チャットを閉じたら消える、別のデバイスからは繋がらない、何本か並行で回していると、どれが動いていてどれが自分の返事待ちなのか分からなくなる。結局、机の前で終わるのを待つことになります。
アプリを開いて最初に出てくるのは、ホスト名の一覧ではなく「今どのマシンで何が動いているか」です。タップすると、そのセッションの会話にも生のターミナルにも入れます。返事待ちのものだけは上に固定しています。
作業の実体は自分のマシンの tmux の中で走っています。中身が Claude Code だろうと Codex だろうと自作スクリプトだろうと、tmux に入っていれば等しく扱えます。処理は tmux の中で続くので、接続が切れても端末を替えても、同じセッションに戻れます。
Qiita のコミュニティガイドラインでは「自社や自作の技術的な解説等を主目的としている記事は、宣伝や販売には当たりません」とされているため、自作プロダクトの実装解説として書いています。宣伝目的の記事ではありません。
この記事で扱うのは、その中のネットワーク中継まわりです。Cloudflare Durable Objects(以下 DO)で SSH ストリームを中継する部分を実装したのですが、WebSocket Hibernation API まわりで罠を3つ踏みました。うち1つは自分たちの最初の診断が間違っていたので、その訂正も含めて書きます。
なぜ中継が必要だったか
やりたいことは「スマホ → 自宅の Mac の sshd」です。素直にやるなら選択肢は3つあります。
| 方法 | 問題 |
|---|---|
| ポート開放 + DDNS | ユーザーにルータ設定を要求する。そもそも sshd を公開したくない |
| Tailscale などの VPN | 実はこれがベスト。ただしユーザーが別アプリを入れる必要がある |
| cloudflared などのトンネル | ユーザーに CLI のセットアップを要求する |
Tailscale は本当に良い選択肢で、実際にサポートしています(アプリ側は何も実装していません。ユーザーが Tailscale を入れて tailnet IP を指定するだけです)。
ただ、何も設定せずに使い始められる経路も必要でした。そこで自前の relay を作りました。要件はこうです。
- ユーザー側のマシンでポートを開けない
- ユーザーに追加のセットアップをさせない
- relay の運営者(=私たち)が通信の中身を読めない
肝は3つ目です。中継するのは SSH の暗号化済みストリームそのものなので、relay はバイト列を右から左に流すだけで、復号鍵を持ちません。
アーキテクチャ
[スマホ] [Cloudflare Worker + DO] [ユーザーのマシン]
| | |
| | <--- ① 常時接続(アウトバウンド)---|
| | WS /agent/:machineId |
| | |
|--- ② WS /connect/:machineId ->| |
| |--- ③ {op:"dial", connId} --------->|
| | |
| | <--- ④ WS /agent-data/:machineId/:connId
| | ⑤ 127.0.0.1:22 へ接続
|<========== ⑥ 双方向ポンプ(SSH の暗号化ストリーム)==========>|
肝は①の常時接続です。ユーザーのマシンからはアウトバウンドの WebSocket を1本張るだけ。インバウンドのポートは一切開きません。スマホから繋ぎたくなったら、DO 経由でそのコントロール接続に「dial しろ」と指示を送り、マシン側が改めてデータ用の接続を張り直してローカルの sshd に繋ぎます。
machineId ごとに DO インスタンスが1つ対応し、その中の複数の WebSocket をタグで引きます。タグは役割ごとに1つ + アカウント単位で1つの計2つを付けています。
| 接続 | 役割タグ | アカウントタグ |
|---|---|---|
| マシンのコントロール接続 | 常設の1本 | あり |
| スマホからの接続要求 | 接続ごとに発行 | あり |
| マシンが張り直すデータ接続 | 上と対で発行 | あり |
アカウント単位のタグを全接続に付けているのは、アカウント削除やポリシー変更が起きたときに、そのユーザーに属する接続だけをまとめて引いて閉じるためです。役割タグだけだと「この machineId の全接続」しか引けず、マルチアカウントでは無関係な接続まで巻き添えになります。
タグの具体的な命名規則はここでは伏せます。必要なのは命名そのものではなく、役割とアカウントの2軸で引けることです。
両端とも、アクセストークンを Worker 側で検証してから DO にルーティングしています(検証を通らない接続は DO まで到達しません)。
罠1: server.accept() した WebSocket は約5秒で切れる
最初の実装は、素朴に addEventListener を使うものでした。
// これはダメ
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept();
server.addEventListener("message", (event) => { /* ... */ });
return new Response(null, { status: 101, webSocket: client });
ローカルの wrangler dev では動きます。ところが本番に出すと、接続が約5秒ごとに Network connection lost で落ちます。
原因
DO の WebSocket には2つの方式があります。
-
server.accept()+addEventListener(従来型) -
WebSocket Hibernation API:
ctx.acceptWebSocket(ws, tags)+webSocketMessage/webSocketClose/webSocketErrorハンドラー
従来型は、DO がメモリ上に生き続けていることを前提としています。DO はアイドル時に evict されるので、その際に接続ごと巻き添えになります。
対処
Hibernation API に移行しました。
export class MachineRelay {
constructor(private ctx: DurableObjectState, private env: Env) {}
async fetch(req: Request): Promise<Response> {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// accept() ではなく acceptWebSocket()。タグを付けて後から検索できる
this.ctx.acceptWebSocket(server, ["agent"]);
return new Response(null, { status: 101, webSocket: client });
}
// メソッドとして実装する(addEventListener ではない)
async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) {
// ...
}
async webSocketClose(ws: WebSocket, code: number, reason: string) {
// ...
}
}
これで DO が休眠しても接続は生き残ります。目的の相手は this.ctx.getWebSockets("agent") のようにタグで引きます。
罠2: 「バイナリフレームが Blob に化ける」は誤診だった
当初の観測
データ経路(SSH ストリーム)を DO 経由で流したところ、バイナリフレームが壊れました。そこで「DO はバイナリを転送すると Blob に化けてバイト列が壊れる」と結論づけ、データ経路を丸ごと base64 のテキストフレームに切り替えました。
当然、転送量は約 1.33 倍になります。ターミナルの応答性に直結するので、ずっと気持ち悪い状態でした。
実際は
後から計測し直したところ、バイナリが壊れるのは従来型(addEventListener)の経路だけでした。
Hibernation API の webSocketMessage(ws, msg: string | ArrayBuffer) は、バイナリをバイト単位で無損失に転送します。1 MiB のバイナリフレームを流して、ローカルと本番エッジの両方で完全一致を確認しました。
つまり罠1を直した時点で、罠2はすでに消えていたのに、base64 のまま1ヶ月ほど走っていました。
「A を直したついでに B も直っている」ことがあります。根本原因を直した時点で回避策も外して測り直すべきでした。
移行時の互換性
クライアントを先にバイナリ化しても、ユーザーのマシンで動いている古いエージェントは、まだテキストフレームを返してくる可能性があります。デスクトップアプリの更新は強制できないので、ここは非対称に倒しました。
送信は常にバイナリ・受信は両方受け付ける、という実装です。
// 送信は常にバイナリフレーム
// 受信: バイナリはそのまま、String は base64 とみなしてデコード
//(relay 経路のみ。古いエージェントとの互換)
if (data is List<int>) {
_incoming.add(data is Uint8List ? data : Uint8List.fromList(data));
} else if (_base64Compat && data is String && data.isNotEmpty) {
_incoming.add(base64.decode(data));
}
ただしクライアント側だけでは不十分です。常にバイナリで送るようになった新しいクライアントを、エージェント側が受け取れる必要があります。
エージェント側も、受信は両方・送信は対向に追従するようにしました。
// エージェント側: 受信は両方受ける
final toRelay = WebSocketFrameBatcher(ws.add); // 出口はどちらも同じ ws
var peerBinary = false;
ws.listen((data) {
if (data is List<int>) {
peerBinary = true; // ← 一度でもバイナリを見たら記憶する
sock.add(data);
} else if (data is String && data.isNotEmpty) {
sock.add(base64.decode(data));
}
});
// 送信は「相手が何で喋ってきたか」に合わせる
sock.listen((data) =>
peerBinary ? toRelay.add(data) : ws.add(base64.encode(data)));
新しいクライアントが最初に送る SSH のバナーがそもそもバイナリなので、それを受けた時点で peerBinary が立ちます。厳密には、その前にローカルの sshd が返す最初の出力だけはテキストで出ていきますが、1往復すればバイナリに落ち着きます。相手が古いクライアントなら peerBinary は false のままなので、従来どおりテキストが返ります。
別途バージョンネゴシエーションは設けず、最初のフレームの型をそのまま使っています。おかげで if (version >= N) の類を一行も書かずに済みました。
あわせてフラグ名も base64Text から base64Compat に変えました。前者は「base64 のテキストで喋る」と読めますが、実際の意味は「base64 のテキストも受け付ける」です。
これは地味に効きました。名前が base64Text のままだと、コードを読んだ人(数ヶ月後の自分を含む)が「まだ base64 で送信している」と誤解します。
罠3: プロトコルレベルの ping は Hibernation 下では当てにならない
これは正直かなりハマりました。
症状
- スマホ / Web から
/connect/:machineIdを叩くと、openした直後にclose(1013)で切られる - ところがマシン側のアプリは「relay に繋がっている」と思い込んでいる
- TCP は
ESTABLISHED、WebSocket の ping(pingInterval = 15s)も全て成功している - アプリのプロセスを再起動しないと直らない
1013 の発生源は Worker のこの1行だけでした。
ws.close(1013, "machine agent offline");
つまり DO 側で getWebSockets("agent") がそのマシンの接続を見つけられていません。一方、マシン側の監視はすべて緑。
原因
Hibernation 中、Cloudflare のランタイムがプロトコルレベルの ping に代理で応答し、DO を起こしません。
これは Hibernation の設計として正しい挙動です。課金される GB-秒を減らすために、生存確認程度で DO を起こしたくないからです。(だからこそ、死活監視の手段としてはタチが悪いのですが。)
しかし結果として、こういう状態が起こります。
| レイヤー | 状態 |
|---|---|
| TCP 接続 | 生きている |
| WebSocket プロトコルの ping/pong | エッジが代理応答するので成功する |
| DO インスタンスがこの socket を保持しているか | 失われている |
マシン側が「繋がっている」と判断する根拠は2番目でした。しかし dial が届く条件は3番目です。監視が測っている層と、実際に必要な層が違っていたわけです。
これは TCP がブラックホール化する現象とは別物です。あちらは TCP そのものが死んでいます。今回は TCP もプロトコル ping も完全に健全で、それでも用をなしません。
対処
アプリケーションレベルの往復ハートビートに変えました。
// DO 側: pong は必ず DO のコード自身が返す
async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) {
const role = this.roleOf(ws); // この接続の役割を判定する
if (role === "agent") {
// コントロール接続に流れてくるのはテキストの制御フレームだけ。
// ここで型を見ずに JSON.parse に渡すと事故ります。
// `JSON.parse(typeof msg === "string" ? msg : "")` のように
// 空文字を食わせるやつは SyntaxError で即死します(やりました)。
if (typeof msg !== "string") return;
try {
const control = JSON.parse(msg) as { op?: unknown };
if (control.op === "ping") {
// ← DO 自身が起きて返すことに意味がある
safeSend(ws, JSON.stringify({ op: "pong" }));
}
} catch {
// 壊れた制御フレームは黙って捨てる。
// ハートビートが「切断の新しい理由」になったら本末転倒なので。
}
return;
}
// データ経路はここから下。String / ArrayBuffer の両方をそのまま転送する。
this.forward(role, msg);
}
マシン側は 45 秒ごとに {op:"ping"} を送り、2周期(100 秒)分応答がなければ、自分から張り直します。
const kControlPingInterval = Duration(seconds: 45);
const kControlPingTimeout = Duration(seconds: 100);
bool controlHeartbeatExpired({
required bool pongSeen,
required Duration sinceLastInbound,
}) => pongSeen && sinceLastInbound > kControlPingTimeout;
ここで重要なのは、setWebSocketAutoResponse() を使わないことです。
// これをやると元の木阿弥
this.ctx.setWebSocketAutoResponse(
new WebSocketRequestResponsePair("ping", "pong")
);
これは「DO を起こさずにエッジが自動応答する」ための API です。まさに今回の盲点そのものを再現してしまいます。pong が DO のコードから返ってくることに意味があります。
デプロイ順序のガード
もう1つ、地味ですが重要な実装があります。
新しいハートビートを持つクライアントを、古い Worker が動いている状態でリリースすると何が起きるか。古い Worker は {op:"ping"} を受け取っても無視するので、pong は永遠に来ません。無条件に「死んだ」と判定すると、健全な接続を自分から切ってしまいます。
そこで「一度も pong を見たことがない接続は、死んだ判定をしない」という条件を入れています。上のコードの pongSeen && がそれです。
// pongSeen が false = 相手はそもそも pong を返さない世代
// → ハートビート導入前の挙動に退化させる
) => pongSeen && sinceLastInbound > kControlPingTimeout;
この pongSeen && は、パッと見ると冗長に見えます(「タイムアウトしたなら死んでいるだろう」)。しかし消すと、古い Worker が動いている間だけ、健全な接続を 100 秒ごとに自分で切断する再接続の嵐になります。デプロイの順序に依存するので、Worker とクライアントを揃えて試すステージングでは再現しないことがあります。
消されないようにテストで担保しています。
サーバー側とクライアント側を別々にデプロイできる構成では、「新しいプロトコルに相手が答えなかったとき、どう振る舞うか」を必ず決めておく必要があります。
補足: プロトコル ping が無意味なわけではない
**プロトコルレベルの ping は捨てていません。**データ用の接続では今も 15 秒間隔で使っています。
ws.pingInterval = const Duration(seconds: 15);
モバイル回線の NAT は、アイドルなマッピングを数十秒で破棄します。破棄された後は、こちらから見ると「何も起きない」だけなので、画面が最後のフレームで固まったまま、いつまでも戻ってきません。この検出には、エッジが代理応答してくれるだけで十分です。TCP が本当に死んでいれば、代理応答も届かないからです。
使い分けると、こうなります。
| 知りたいこと | 適した手段 |
|---|---|
| TCP / NAT の経路が生きているか | プロトコル ping で十分 |
| DO がこの socket をまだ保持しているか | アプリケーション層の往復が必須 |
問題は「ping が無意味」だったことではなく、ping が答えられる質問と、こちらがしたかった質問が食い違っていたことでした。
おまけ: 「Wi-Fi なのに relay を経由してしまう」の原因は探索順序ではなかった
これも中継まわりでよく効いた話です。
LAN 直結 → Tailscale → relay の順で試す実装になっていて、順序自体は正しく書けていました。それでも、同じ Wi-Fi にいるのに relay 経由になることが頻発しました。
原因は候補アドレスの供給元でした。LAN の IP 候補が、クラウド側の presence(在席情報)のメモリ上の名簿だけから来ていたのです。そして購読エラー時にその名簿を丸ごと clear() していました。
Wi-Fi が一瞬揺れる / DNS が一瞬失敗する
→ presence の購読がエラー
→ 名簿を clear()
→ LAN 候補がゼロ件
→ ループに一度も入らない
→ いきなり有料の relay へ
クラウドの名簿が切れたことと、LAN で相手に届くかどうかは、まったく無関係な2つの事実です。 前者に後者を否決させていたのが構造的な間違いでした。
直したのは次の2点です。
- 購読エラー時は
clear()せず「stale としてマークする」(アドレスは残し、live 判定だけ落とす)。clear()はログアウトやアカウント切り替えのときだけ - SSH の認証が成功したアドレスを端末ローカルに TTL 付きでキャッシュし、フォールバック候補にする(疎通しただけでは書かない)
無料の直結経路を、不要なクラウド依存で潰していないか。この手の実装では一度確認した方がいいです。
セキュリティ設計について
ここまでに出てきた話を、「relay から何が見えるか」の観点でまとめ直しておきます。
- relay が流すのは SSH の暗号化済みストリームそのものです。復号鍵は両端にしかなく、relay は復号できません
- 両端とも接続時にアクセストークンを検証し、machineId ごとに分離された DO インスタンスにのみ接続されます
- 同じ Wi-Fi にいるときは relay を経由しません(上記の直結経路が優先されます)
まとめ
Cloudflare Durable Objects で長時間の WebSocket を扱うなら、押さえるべきは以下です。
-
Hibernation API を使う。 従来型の
accept()は長時間の接続では evict されます - 回避策を入れたら、根本原因を直した後に外して測り直す。 私たちは不要な base64 を1ヶ月引きずりました
- プロトコルレベルの ping は「TCP の経路が生きているか」しか答えない。 エッジが代理応答するため、DO 側で接続が失われていても緑のままです。「DO がこの socket を保持しているか」を知りたいなら、アプリケーションレベルの往復で、しかも DO のコード自身に応答させる必要があります
setWebSocketAutoResponse()は 3 の盲点をそのまま再現するので、死活判定には使わない- 相手が新しいプロトコルに答えなかったときの振る舞いを決めておく。 無反応を「死亡」と解釈すると、デプロイの過渡期に再接続の嵐になります
3 が本質的な学びでした。監視や死活判定を入れるときは、その監視が測っている層と、実際に必要な層が一致しているかを確認する必要があります。今回は TCP もプロトコル ping も完全に健全なまま、機能だけが死んでいました。
「生きてますか?」という問いは、層を指定しないと意味が定まりません。