ワイヤープロトコル
本書は agent、exchange、cli 間のワイヤー形式の完全な仕様です。定義は crates/proto にあり、三者は同じコードを共有します。フィールドの追加や削除の定義箇所は一つだけで、不整合は本番のセッション中ではなくコンパイル時に検出されます。
パケットをキャプチャして問題を調査する運用担当者と、プロトコルの変更やサードパーティークライアントの開発を行う開発者を対象としています。
目次
- 概要
- トランスポートとエンドポイント
- プロトコルバージョン
- 制御メッセージ
- バイナリデータフレーム
- 二つの名前空間におけるセッション番号の変換
- ハンドシェイクのシーケンス
- エラー処理と失敗の分類
- キープアライブとタイムアウト
- バックプレッシャー
- Android 画面操作
- エンドツーエンドの密封セッション
概要
プロトコルは二層に分かれ、一つの WebSocket 接続を共有します。
| 層 | 搬送形式 | エンコーディング | 内容 |
|---|---|---|---|
| 制御メッセージ | WebSocket テキストフレーム | JSON | 登録、ハートビート、セッションの開始/終了、結果通知 |
| データフレーム | WebSocket バイナリフレーム | 9 バイトの固定長ヘッダー + 生バイト列 | PTY バイトストリーム、コマンドの stdout/stderr |
分離する理由は、ストリーミングデータを base64 で JSON に入れると約 33% 膨らむためです。PTY はキーごとにエコーする高頻度の小さなパケットで、このコストが対話の遅延に直結します。制御メッセージは低頻度なので、JSON の可読性(キャプチャを直接読めること)の利点が大きくなります。
一つの WebSocket バイナリフレームは規約上一つのデータフレームだけを格納します。バイトの過不足はエラーです。
トランスポートとエンドポイント
| エンドポイント | 接続元 | 認証方式 |
|---|---|---|
GET /ws/device |
agent | 接続確立後の最初のフレームは、register 制御メッセージで、device_id と token を含む形式である必要があります |
GET /ws/operator |
remote-cli | WebSocket ハンドシェイクの HTTP リクエストヘッダー Authorization: Bearer <运维令牌> |
二つのパスは相互利用できない二種類の認証情報に対応します。デバイストークンを運用エンドポイントに使うことも、その逆もできません。
運用の認証情報を URL クエリーではなくヘッダーで送るのは、URL がリバースプロキシのログ、ブラウザー履歴、Referer に残るためです。デバイストークンをヘッダーではなく最初のフレームで送るのは、一部の組み込みクライアントでは WebSocket ハンドシェイクヘッダーをカスタマイズしにくく、また「プロトコルバージョンの不一致」と「トークンの誤り」を同じ応答メッセージで表せるためです。
本番では必ず wss:// を使ってください。どちらの認証情報もハンドシェイク段階では平文で現れます。
プロトコルバージョン
pub const PROTOCOL_VERSION: u16 = 1;
ハンドシェイク時に双方向でバージョンを比較し、不一致なら明確な説明とともに直ちに拒否します。バージョンの食い違いが「後のメッセージが理由不明で解析できない」という調査困難な障害になることを防ぎます。
- デバイス側:agent は
registerに自身のバージョンを含めます。サーバーは不一致ならok: falseのregister_ackを返し、errorに双方のバージョンを示して接続を閉じます。 - 運用側:サーバーは接続確立後に
welcomeを自発的に送信します。CLI は不一致を検出するとエラーを出し、server_versionに合うバージョンへの更新を案内します。
未知のメッセージ type はすべて拒否され、既知の型として黙って処理されることはありません。
制御メッセージ
すべての制御メッセージは JSON オブジェクトで、type フィールドをタグに使います(serde の internally-tagged 列挙型)。タグ値とフィールド名はすべて snake_case です。session_id はラッパーなしの数値として透過的にシリアライズされます。
DeviceUp(agent → exchange)
type |
フィールド | 用途 |
|---|---|---|
register |
protocol_version: u16device_id: stringtoken: stringinfo: DeviceInfo |
接続後の最初のメッセージは必ずこれです。サーバーは受信前に他のメッセージを受け付けず、制限時間は 15 秒です |
heartbeat |
metrics: Metrics |
業務メトリクスの定期報告。リンクの生存確認は WebSocket 標準の ping/pong が担い、このメッセージとは役割が別です。ハートビートの欠落はリンク切断を意味せず、その逆も同様です |
exec_started |
session_idjob_idholdtimeout_secs |
コマンドが起動しました。job_id は hold 一覧と再接続時のアタッチに使います |
exec_exit |
session_idcode: i32 | nulltimed_out: bool |
コマンド実行終了。シグナルで終了した場合、code は null です。timed_out はタイムアウトによる強制終了を表します |
exec_list_resp |
session_idjobs: ExecHoldInfo[] |
hold ジョブ一覧 |
ubus_resp |
session_idcode: i32data: any |
ubus 呼び出しの結果。code は ubus 自体の戻りコードで、0 が成功です |
android_resp |
session_idresult: AndroidResult |
Android 画面操作完了。このメッセージでセッションが終了します |
pty_closed |
session_idcode: i32 | null |
PTY セッション終了。code は shell の終了コードです |
session_error |
session_idmessage: string |
セッション単位のエラー。デバイスが要求を完了できない場合、黙って破棄せずこれを返します |
登録済み接続で二つ目の register を受け取ると、サーバーはプロトコルの誤用として警告を記録します。
例:
{"type":"register","protocol_version":1,"device_id":"lab-router","token":"a1b2….c2Vj…","info":{"hostname":"OpenWrt","model":"ath79/generic","openwrt_version":"OpenWrt 23.05.5","kernel":"5.15.137","arch":"mips","agent_version":"0.1.0"}}
{"type":"exec_exit","session_id":31,"code":0,"timed_out":false}
DeviceDown(exchange → agent)
type |
フィールド | 用途 |
|---|---|---|
register_ack |
ok: boolerror: string | nullheartbeat_interval_secs: u64 |
登録結果。ok: false では error が理由を示し、その後サーバーが切断します。間隔はサーバー指定で、台数が増えても各端末の設定を変えず一括で頻度を下げられます。agent は 0 を最低 1 秒に補正し、タイマーの空回りによる CPU 飽和を防ぎます |
device_access |
managers: DeviceManager[]updated_at: u64(ミリ秒) |
本デバイスへのアクセス権者。DeviceManager = operator_id、username、email?、role、level(manage/view)、source(role_default/grant)。登録後と権限変更時に送信し、device_access 能力を宣言した agent のみが対象です。リポジトリの docs/permissions.md を参照してください |
exec_req |
session_idcommand: stringtimeout_secs: u64hold: bool(既定 true)hold_timeout_secs?: u64 |
コマンド実行。hold=true(既定)なら運用側の切断後も継続し、タイムアウトは hold_timeout_secs(未指定/0 → 3600、絶対上限 86400)です。hold=false はオペレーターの切断時にコマンドをキャンセル |
exec_cancel |
session_id |
コマンド実行を途中終了(kill)します |
exec_detach |
session_id |
現在の運用側への出力配信を止め、hold プロセスは残します |
exec_list_req |
session_id |
現在の hold ジョブ一覧を照会します |
exec_attach_req |
session_idjob_id |
hold ジョブをこのセッションに再アタッチし、バッファー出力を再生します |
exec_hold_kill |
session_idjob_id |
job_id で hold ジョブを終了します |
exec_hold_tail |
session_idjob_id |
新規出力だけを追跡します(バッファー再生なし) |
exec_hold_cat |
session_idjob_id |
現在のバッファーを出力し、exec_hold_cat_done で終了します |
ubus_req |
session_idobject: stringmethod: stringparams: any |
ubus 呼び出し。params が null なら ubus に第 3 位置引数を渡しません |
pty_open |
session_idrows: u16cols: u16 |
PTY セッションを開きます。行列が 0 なら 24×80 に補正します |
pty_resize |
session_idrows: u16cols: u16 |
端末ウィンドウのサイズ変更 |
pty_close |
session_id |
PTY セッションを閉じます |
android_req |
session_idrequest: AndroidAction |
単発の Android 画面操作 |
android_cancel |
session_id |
未完了の Android 要求を取り消します |
OperatorUp(cli → exchange)
認証は WebSocket ハンドシェイク中に完了するため、ログイン用メッセージはありません。
type |
フィールド | 用途 |
|---|---|---|
open_exec |
session_iddevice_idcommandtimeout_secshold(既定 true)hold_timeout_secs? |
対象デバイスでコマンドを実行します。hold は既定で有効です。hold=true かつタイムアウト 0 → 3600s。hold=false かつタイムアウト 0 → Exchange の既定 60s |
list_exec_holds |
session_iddevice_id |
exec ジョブと進行中のダウンロード、アップロード、ディレクトリー照会を取得します |
list_device_sessions |
session_iddevice_id |
Exchange のルーティング表を直接照会し、デバイスのセッション枠を使いません |
kill_device_session |
session_iddevice_idtarget_session_id |
デバイス側の target_session_id で取り消し、ローカルのルートを解放します。hold exec も対象で、追加枠は使いません |
attach_exec |
session_iddevice_idjob_id |
hold ジョブにアタッチし出力の受信を続けます |
exec_hold_kill / exec_hold_tail / exec_hold_cat |
session_iddevice_idjob_id |
hold ジョブの kill / tail / cat |
detach_session |
session_id |
フォアグラウンドの待機を終了して detach します(hold プロセスは終了しません) |
open_ubus |
session_iddevice_id: stringobject: stringmethod: stringparams: any |
ubus 呼び出しを開始します |
open_pty |
session_iddevice_id: stringrows: u16cols: u16 |
対話型 PTY セッションを開きます |
open_android |
session_iddevice_id: stringrequest: AndroidAction |
Android 画面操作を開始します。読み取り専用アカウントは使用できません |
pty_resize |
session_idrows: u16cols: u16 |
端末ウィンドウのサイズ変更。セッションは確立済みでサーバーが対象を引けるため device_id は含みません |
close_session |
session_id |
PTY の正常終了やユーザーによるコマンド中断で、明示的にセッションを終了します |
四つの open_* メッセージの session_id はCLI 自身が割り当てます(二つの名前空間におけるセッション番号の変換を参照)。そのため要求後は番号の応答を待たず、直ちに後続フレームを送れます。
OperatorDown(exchange → cli)
type |
フィールド | 用途 |
|---|---|---|
welcome |
protocol_version: u16server_version: string |
接続確立後にサーバーが自発的に送り、バージョンの一致を確認します |
device_sessions |
session_idsessions: DeviceSessionInfo[]limitcount |
Exchange の現在のデバイス側セッション一覧 |
device_session_killed |
session_idtarget_session_idreleased: bool |
ローカルで解放済み。存在しなければ false |
exec_exit |
session_idcode: i32 | nulltimed_out: bool |
コマンド終了。デバイス側の同名メッセージを変換したものです |
ubus_resp |
session_idcode: i32data: any |
ubus 呼び出し結果 |
android_resp |
session_idresult: AndroidResult |
Android の戻り値。運用側のセッション番号に変換済みです |
pty_closed |
session_idcode: i32 | null |
PTY セッション終了 |
session_failed |
session_idreason: SessionFailuremessage: string |
セッション失敗。reason で理由を区別し、CLI が再試行の妥当性を判断できます |
共通データ構造
ExecHoldInfo:job_id、kind(exec / download / upload / file)、age_secs、session_id?、command_preview、status、timeout_secs、started_at_unix_ms、exit_code?、timed_out。互換性のため kind の既定は exec、age_secs の既定は 0 です。一覧応答では現在時刻から経過時間を計算します。コマンド/ファイルパスの概要は最大約 120 文字です。exec 以外のジョブは kill だけが可能で、tail/cat/attach はできません。
DeviceSessionInfo:session_id: u32(デバイス側番号)、operator_session_id: u32、kind: string、hold: bool、age_secs: u64、deadline_unix_ms: u64。一覧と強制キャンセルには Exchange が直接応答し、デバイスに新規セッションを開きません。読み取り専用アカウントには forbidden を返します。
DeviceInfo
デバイスの静的情報で、register 時に一度報告し、devices.info に保存します。agent_version 以外はすべて任意です。OpenWrt 以外(開発機、コンテナー)では情報源の多くがないため、その場合も起動失敗せず通常どおり登録します。未指定のフィールドはシリアライズ時に省略します。
| フィールド | 型 | 情報源 |
|---|---|---|
hostname |
string? |
/proc/sys/kernel/hostname |
model |
string? |
/etc/openwrt_release の DISTRIB_TARGET。読めなければ ubus call system board の model |
openwrt_version |
string? |
DISTRIB_DESCRIPTION または ubus の release.description |
kernel |
string? |
/proc/sys/kernel/osrelease |
arch |
string? |
コンパイル時の定数(実行時の検出より確実) |
agent_version |
string |
バージョン不一致の調査に使う agent 自身のバージョン |
Metrics
ハートビートごとに再収集し、子プロセスの fork が不要で /proc から直接読める項目だけを取得します。一項目の読み取り失敗で全体を失敗させません。メトリクスを一つ欠く方が、ハートビート全体が失敗してデバイスがオフラインと判断されるより適切です。
| フィールド | 型 | 説明 |
|---|---|---|
uptime_secs |
u64 |
/proc/uptime |
load |
[f32; 3] |
/proc/loadavg の 1/5/15 分間平均負荷 |
mem_total_kb |
u64 |
/proc/meminfo |
mem_available_kb |
u64 |
MemAvailable を使用し、MemFree は使いません。後者は回収可能なキャッシュを含まず、ルーターで空き容量を大幅に過小評価して誤警告を起こします |
cpu_usage_pct |
f32? |
二回の /proc/stat の差分で計算します。初回には比較サンプルがないため省略します |
interfaces |
InterfaceStats[] |
/proc/net/dev。速度ではなく送受信の累積バイト数を報告し、サーバーが任意の時間幅で速度を計算できるようにします。空配列は省略します |
InterfaceStats:{ name: string, rx_bytes: u64, tx_bytes: u64 }
バイナリデータフレーム
0 1 5 9
+--------+--------------------+--------------------+
| kind | session_id | payload_len | payload (payload_len 字节)
| u8 | u32 (大端) | u32 (大端) |
+--------+--------------------+--------------------+
└──────────── 9 字节定长头 ────────────┘
| オフセット | 長さ | フィールド | 説明 |
|---|---|---|---|
| 0 | 1 | kind |
ストリーム種別。次表参照 |
| 1 | 4 | session_id |
ビッグエンディアン u32。0 は「どのセッションにも属さない」番兵値として予約されています |
| 5 | 4 | payload_len |
ビッグエンディアン u32。ペイロードのバイト数で上限は 1 MiB |
| 9 | payload_len |
payload |
エンコードもエスケープもしない生バイト列 |
ストリーム種別
| 値 | 名前 | 方向 | 説明 |
|---|---|---|---|
| 1 | ExecStdout |
デバイス → 運用 | リモートコマンドの標準出力 |
| 2 | ExecStderr |
デバイス → 運用 | 標準エラー。stdout と別送することで CLI が区別して表示できます(赤色など) |
| 3 | PtyData |
双方向 | PTY バイトストリーム。上りはキー入力、下りは画面出力で、同じ型を双方向に使います |
agent が下り方向で ExecStdout / ExecStderr を受け取ると警告を記録して破棄します。対向実装の誤りを意味するためです。
設計上の説明
payload_len は WebSocket 自身の長さフィールドと確かに重複しています。 残す理由は二つあります。切り詰めを検証できること(11 バイトと宣言しながら 5 バイトしかない場合、短いパケットとして黙認せず検出する必要があります)と、将来生 TCP に切り替えてもフレーム形式を変えずに済むことです。
1 MiB のペイロード上限は通常通信のためではありません。PTY とコマンド出力は差分を逐次配信し、この規模には到底達しません。2 GiB のペイロードを宣言するヘッダーで、対向側がサーバーメモリーを使い切ることを防ぐためです。
空のペイロードは有効です。PTY セッションでは時折 0 バイト読み取りが発生し、その場合フレーム全体は 9 バイトのヘッダーだけになります。
ペイロードは任意のバイト列で、非 UTF-8 シーケンスも含めてそのまま保持する必要があります。
デコードエラー
| エラー | 発生条件 |
|---|---|
FrameTooShort { len } |
全長が 9 バイト未満 |
UnknownStreamKind(v) |
先頭バイトが 1/2/3 以外 |
PayloadTooLarge { len } |
宣言された payload_len が 1 MiB を超える |
LengthMismatch { declared, actual } |
宣言長と実際の残りバイト数が一致しない |
データフレームのデコード失敗はそのフレームだけに影響します。exchange は警告を記録して破棄し、接続は切断しません。
二つの名前空間におけるセッション番号の変換
プロトコルで最も誤解しやすい部分です。
要点
session_id は開始側が割り当て、その接続内だけで一意です。運用側とデバイス側は独立した番号空間を持ち、exchange が間で変換します。同じ論理セッションが双方で異なる二つの番号を持ちます。
remote-cli exchange remote-agent
(设备 lab-router)
┌────────────┐ ┌────────────────────┐ ┌────────────┐
│ 运维编号 │ │ 路由表 │ │ 设备编号 │
│ 空间 │ │ │ │ 空间 │
│ │ │ lab-router: │ │ │
│ session 7 │───────▶│ 31 → (op-A, 7) │───────────▶│ session 31 │
│ │◀───────│ │◀───────────│ │
└────────────┘ │ op-A: │ └────────────┘
│ 7 → (lab-router,│
由 CLI 自己的 │ 31) │ 由 exchange
SessionIdGenerator └────────────────────┘ 的 DeviceHandle
分配(1,2,3…) 分配(1,2,3…)
この設計の理由
一往復を省きます。 サーバーが番号を一括割り当てすると、CLI は PTY を開いた後「セッション番号は 31」という応答を待つまでキー入力を送れません。CLI 自身が割り当てれば open_pty の直後にデータフレームを送れます。
運用クライアント間の番号衝突を防ぎます。 二人の運用担当者が同じデバイスを同時にローカル番号 1 で操作することはあり得ます。デバイスが区別できなければ、一方の出力が他方へ流れます。exchange はデバイス接続ごとに番号を割り当て、二つの session 1 をデバイス側の 31 と 32 に対応付けます。
運用クライアントの動作によるデバイス側番号への影響を防ぎます。 デバイス側番号は完全に exchange が管理し、どの運用クライアントも指定や推測はできません。
二つの表
exchange は接続ごとに表を持ち、二つの表を常に対で更新します。
| 場所 | 表 | キー → 値 |
|---|---|---|
DeviceHandle.sessions |
デバイス側の順方向表 | デバイス番号 → (運用側送信ハンドル, 運用番号, 運用アカウント ID, セッション種別, 監査行 ID) |
OperatorHandle.sessions |
運用側の逆引きインデックス | 運用番号 → (デバイス ID, デバイス番号) |
逆引きインデックスの目的はクリーンアップです。運用側の切断時に各デバイスに残したセッションを見つけて順に終了できなければ、出力の受信者がいないプロセスや PTY がデバイスに残ります。
変換を行う場所
| 方向 | メッセージ | 変換処理 |
|---|---|---|
運用 → デバイス(open_*) |
制御メッセージ | デバイスの番号空間に新しい番号を割り当て、両表に登録し、デバイスへのメッセージに新番号を使います |
運用 → デバイス(pty_resize / close_session) |
制御メッセージ | 逆引きでデバイス番号を取得し、置換して転送します |
| 運用 → デバイス | データフレーム | 逆引きでデバイス番号を取得し、ヘッダーの 4 バイトをその場で書き換え、ペイロードは保持します |
| デバイス → 運用 | 制御メッセージ | 順方向表で運用番号と対象の運用接続を取得し、置換して配信します |
| デバイス → 運用 | データフレーム | 順方向表で運用番号を取得し、ヘッダーだけをその場で書き換えます |
データフレームの番号変換が唯一の書き換えです。exchange はペイロードのどのバイトも解析、キャッシュ、変更しません。DataFrame::with_session_id はペイロードを再割り当てせず、その場で置換します。
割り当て規則
双方のアロケーターは 1 から増加し、0 を飛ばします(0 は接続単位のエラーメッセージなど、「どのセッションにも属さない」番兵です)。
- CLI 側(
SessionIdGenerator):オーバーフロー後は折り返して 0 を飛ばします。一接続で 40 億セッションを使い切るのは非現実的ですが、常駐プロセスには panic より折り返しが適しています。 - デバイス側(
DeviceHandle::open_session):折り返し後に有効な旧セッションと衝突したら、次の空き番号を探します。
exchange は運用側の 0 や、活動中の番号の再利用を拒否します。デバイス接続の有効性、同時実行上限、双方のインデックス登録を同じクリティカルセクションで扱い、複数の運用接続が同時に開いても上限を突破しないようにします。
ルートが見つからない場合
エラーを返さず黙って破棄します。 セッション終了直後に対向側が末尾データを送っているのは通常の競合です。この規則は両方向に適用します。
- 存在しないセッションへのデバイス応答 → debug ログを記録します。運用側が先に切断した可能性があります。
- 運用側から所有者のないセッションデータを受信 → trace ログを記録して破棄します。
セッションのクリーンアップ経路
| トリガー | 処理 |
|---|---|
デバイスが正常に終了(exec_exit / ubus_resp / android_resp / pty_closed / session_error) |
順方向ルートと所属接続の逆引きインデックスを同時に削除し、監査を更新し、変換済みメッセージを運用側へ送ります |
運用側の close_session |
双方のインデックスを削除し、元のデバイス接続に終了を通知し、運用側へ直ちに終了応答を返して取消監査を更新します。重複した close は何もしません |
| デバイス切断 | 接続の全セッションを取り出し、対応する各運用側へ session_failed / device_disconnected を送り、監査に {"outcome":"device_disconnected"} を記録します |
| 運用側切断 | 逆引きで各デバイス上の自身のセッションを探し、非 hold には終了命令を送り、hold exec には exec_detach だけを送りプロセスを残します。自身のセッションだけを削除し、同じデバイス上の他の運用接続には影響しません |
| デバイスの再接続が旧接続を置換 | 旧接続の全セッションを無効化し、各運用側へ device_disconnected を通知します |
逆引きインデックスは具体的なデバイス接続に結び付き、再接続後にデバイス ID を使って新接続へ切り替わりません。同一アカウントの複数運用接続もそれぞれのインデックスを持ち、一つの切断で別の接続のセッションは取り消されません。
明示的な取消の終了応答は、exec が exec_exit(code: null、timed_out: false)、PTY が pty_closed(code: null)、ubus が session_failed(reason: device_error)です。これは exchange によるセッション終了確認で、遠隔プロセスが終了した証明ではありません。その後のデバイスの末尾データや終了メッセージは破棄します。
同時実行上限に達すると、Exchange は同じセッションロック内で運用側を失ったルートや期限超過ルートを回収し、上限を再確認します。exec の期限は実際の送信時限 + 15 秒の猶予、ファイルは 3600 秒 + 15 秒、その他は 24 時間です。期限内の hold exec は運用側が離れても終了しません。回収や明示的な kill_device_session はデバイスに対応する取消を送り、応答を待たず双方のインデックスを削除します。有効な運用接続には exec の exec_exit(timed_out:true)、その他は session_failed(device_error、session reclaimed)を送り、監査は可能な限り非同期で更新します。
デバイスの再接続と epoch
各デバイス接続は登録時にランダムな epoch(UUID)を取得します。同じデバイスが再接続すると、新接続が旧接続を置き換えます。新接続を拒否しないのは、「重複接続」と「旧接続が死んでいるのにサーバーが未検知の半開接続」を確実に区別できず、拒否すると旧接続のタイムアウトまで再接続できなくなるためです。
旧接続のクリーンアップは、新接続の登録後に実行される場合があります。そのため登録解除時には epoch を検証し、接続表の現在の epoch が自身と一致する場合だけ削除します。この確認がないと、復帰直後のデバイスが旧接続の処理で即座にオフラインにされます。
ハンドシェイクのシーケンス
デバイス側
remote-agent exchange
│ │
│ ① WebSocket 握手(wss://host/ws/device) │
│────────────────────────────────────────────────▶│
│◀────────────────────────────────────────────────│
│ 101 Switching Protocols │
│ │
│ ┌──────┴──────┐
│ │ 启动 15 秒 │
│ │ 注册计时器 │
│ └──────┬──────┘
│ ② {"type":"register","protocol_version":1, │
│ "device_id":"…","token":"…","info":{…}} │
│────────────────────────────────────────────────▶│
│ │
│ ┌──────────────────┴──────────────────┐
│ │ a. 比对 protocol_version │
│ │ b. split_token → 前缀查 devices 表 │
│ │ c. 校验 enabled │
│ │ d. argon2 verify(前缀不符也照做, │
│ │ 避免用耗时区分前缀是否存在) │
│ └──────────────────┬──────────────────┘
│ │
│ ③ {"type":"register_ack","ok":true, │
│ "error":null,"heartbeat_interval_secs":30} │
│◀────────────────────────────────────────────────│
│ │
│ ┌──────┴──────┐
│ │ 登记连接表 │
│ │ 顶替旧连接 │
│ │ 回写 info 与│
│ │ last_seen_at│
│ └──────┬──────┘
│ │
│ ④ 退避曲线复位,写状态文件 state=connected │
│ │
│══════════════ 转发循环 ═══════════════════════════│
│ │
│ heartbeat(每 30 秒,携带 Metrics) │
│────────────────────────────────────────────────▶│
│ WebSocket Ping(随每次 heartbeat 发出) │
│────────────────────────────────────────────────▶│
│ WebSocket Pong
│◀────────────────────────────────────────────────│
│ WebSocket Ping(每 30 秒)
│◀────────────────────────────────────────────────│
│ Pong(tungstenite 排队,agent 显式 flush) │
│────────────────────────────────────────────────▶│
失敗経路:
- 最初のフレームがテキストでない、有効な JSON でない、または
registerでない → 警告を記録して直ちに切断します(ack なし)。 - 15 秒以内に
registerを送らない → 切断します。 - バージョン不一致 →
register_ack{ok:false, error:"协议版本不匹配:设备为 X,服务端为 Y"}を返して切断します。 - 無効なトークン / デバイス不存在 / デバイス無効化 → 常に同じ
"设备 ID 或令牌无效"を返して切断します。理由を区別しません。区別するとデバイス一覧を列挙できる情報になってしまうためです。
agent はこの二種類の失敗を区別して処理します。
| agent 側の分類 | トリガー | 再接続戦略 |
|---|---|---|
Rejected |
ok: false の ack を受信 |
固定で 300 秒待ちます。誤ったトークンは再試行しても正しくならず、頻繁な再試行は双方のリソースとサーバーログを浪費します。設定修正の時間を確保します |
Transport |
接続不能、ハンドシェイクのタイムアウト(45 秒)、応答形式の不正、接続中断 | 指数バックオフ。初期 1 秒、毎回倍増し最大 300 秒、[0.5, 1.5) のランダムジッターを掛けます |
バックオフは登録成功後にのみリセットします。TCP 接続時ではありません。そうしないと「接続直後に拒否」の場合にミリ秒単位の再試行へ退化します。
ジッターは必須です。 サーバー再起動時には数百、数千台が同時に切断を検知します。同じ決定的な曲線で再接続すると同期を保ったまま起動直後のサーバーに一斉に負荷をかけ、一度の再起動が持続的な連鎖障害になります。
運用側
remote-cli exchange
│ │
│ ① WebSocket 握手(wss://host/ws/operator) │
│ Authorization: Bearer <prefix>.<secret> │
│────────────────────────────────────────────────▶│
│ │
│ ┌──────────────────┴──────────────────┐
│ │ 在 upgrade 之前完成鉴权: │
│ │ split_token → 前缀查 operators 表 │
│ │ → argon2 verify → 校验 enabled │
│ └──────────────────┬──────────────────┘
│ │
│ 鉴权失败 → HTTP 401「凭证无效」(不升级) │
│◀────────────────────────────────────────────────│
│ │
│◀────────────────────────────────────────────────│
│ 101 Switching Protocols │
│ │
│ ② {"type":"welcome","protocol_version":1, │
│ "server_version":"0.1.0"} │
│◀────────────────────────────────────────────────│
│ │
│ ③ 比对 protocol_version(15 秒超时) │
│ 不一致 → 报错退出,提示升级 CLI │
│ │
│══════════════ 可以发起会话 ═══════════════════════│
│ │
│ {"type":"open_pty","session_id":1, │
│ "device_id":"lab-router","rows":40,"cols":120}│
│────────────────────────────────────────────────▶│
│ │──▶ 查设备在线
│ │──▶ 查并发上限
│ │──▶ 查 readonly 角色
│ │──▶ 写审计(先于下发)
│ │──▶ 分配设备侧编号
│ 二进制帧(PtyData, session 1, 键盘输入) │
│────────────────────────────────────────────────▶│──▶ 改写为设备侧编号
│ │
│ 二进制帧(PtyData, session 1, 屏幕输出) │
│◀────────────────────────────────────────────────│◀── 改写为运维侧编号
CLI は確認を一切待たず二段目のデータフレームを送れます。これが開始側に番号を割り当てさせる理由です。
エラー処理と失敗の分類
SessionFailure
session_failed メッセージの reason フィールドに現れ、JSON 値は snake_case です。
| 値 | 意味 | 生成元 | 再試行可能 |
|---|---|---|---|
device_offline |
デバイスが存在しない、または現在オフライン | exchange が open_* 時に接続表で対象を見つけられない |
✅ |
device_disconnected |
セッション途中でデバイスが切断 | デバイス切断 / 新接続による置換 / 要求送信時に送信キューが閉鎖済み | ✅ |
device_error |
デバイス側の拒否や実行失敗 | agent の session_error(PTY 作成失敗、コマンド起動失敗、ubus 失敗)、または転送先の消費が遅すぎる |
❌ |
rate_limited |
同時セッション数の上限超過 | exchange が open_* 時に活動数が --max-sessions-per-device(既定 32)に到達したと判断 |
✅ |
forbidden |
運用認証情報に対象デバイスへの権限がない | 読み取り専用(role = readonly)アカウントによるセッション開始 |
❌ |
is_retryable() は意図的に forbidden と device_error を除外します。権限問題やデバイス内部エラーの再試行は意味がなく、CLI を空回りさせるだけです。
CLI は元の message の後に実行可能な案内を追加します。例えば device_offline では remote-cli device list で状態を確認するよう案内します。device_error は例外で、デバイス側のエラー自体が最も正確な説明なので定型文で薄めません。
階層ごとの失敗の意味
| 層 | 表現 | 影響範囲 |
|---|---|---|
| フレームのデコード失敗 | ログを記録しフレームを破棄 | 単一フレーム |
| 制御メッセージの解析失敗 | 警告を記録しメッセージを無視 | 単一メッセージ、接続継続 |
| セッション失敗 | session_failed / session_error |
単一セッション、接続継続 |
| 登録失敗 | register_ack{ok:false} 後に切断 |
接続全体 |
| バージョン不一致 | 明確なエラー後に切断 | 接続全体 |
| キューの遅延 | 能動的に切断 | 接続全体 |
原則は、局所化できる失敗を接続全体の障害にしないことです。一つのデバイス接続では複数の運用担当者のセッションが同時に動く可能性があるためです。
キープアライブとタイムアウト
| 仕組み | 値 | 説明 |
|---|---|---|
| exchange WebSocket Ping | --heartbeat-secs ごと(既定 30 秒) |
exchange がデバイスと運用の両方へ送信します。クライアントライブラリーが Pong を自動でキューに積み、agent は Ping 受信後に sink を明示的に flush して速やかに返す必要があります |
| agent WebSocket Ping | heartbeat_interval_secs ごと(サーバーの ack で指定) |
JSON heartbeat に加えて標準 Ping も送信し、exchange の Ping 遅延時に Cloudflare の約 100 秒のアイドル切断境界に達することを防ぎます |
| 接続無通信タイムアウト | heartbeat × --missed-pings(既定 30 × 3 = 90 秒) |
この間に受信メッセージがなければデバイス/運用接続を切断と判断します。Pong も活動に含みます。--missed-pings が 0 なら最低一周期待つよう補正します |
| exchange 書き込みタイムアウト | 10 秒 | WebSocket データまたは Ping の一回の書き込みがタイムアウトしたら接続を終了し、遅い接続がクリーンアップを妨げるのを防ぎます |
| 業務ハートビート | heartbeat_interval_secs ごと(サーバーの ack で指定) |
agent は JSON heartbeat に Metrics を含め、別途 WebSocket Ping も送ります。メトリクス報告と標準 ping/pong は別の役割で、業務ハートビートだけでリンク状態を判断できません |
| オンライン/オフラインイベント | 通知トリガー online / offline の tick_count(既定 3、範囲 1–100)。ブロードキャストと監査は REMOTE_MISSED_THRESHOLD / REMOTE_ONLINE_THRESHOLD にフォールバック |
運用側は一つのトリガーの二つの数ではなく二種類のトリガーを設定します。offline は連続 tick_count 回の失敗でオフライン通知、online は連続 tick_count 回の成功でオンライン通知します。各トリガーの device_ids は単一 ID(文字列)、ID 群(配列)、または省略 / null でワークスペース全デバイスを指定できます。既に報告済みの状態なら繰り返し通知せず、再通知には逆の結果が連続 tick_count 回となり再度有効化される必要があります。プロセス内 presence ブロードキャストと監査はこれらのトリガーを読まず、REMOTE_MISSED_THRESHOLD / REMOTE_ONLINE_THRESHOLD(同じく既定 3)を使います。ソケットを接続表に残すかどうかは別の --missed-pings が決めます |
| デバイス登録タイムアウト | 15 秒 | 接続確立後、この時間内に register を送る必要があります |
| agent ハンドシェイクタイムアウト | 各段階 45 秒(HANDSHAKE_TIMEOUT) |
connect_device_ws(TCP、任意の HTTP CONNECT、TLS、WebSocket ハンドシェイク)と、register 送信後の register_ack 待機がそれぞれこの時限を使います。二段階合計 45 秒ではありません |
| agent 書き込み / Pong flush タイムアウト | 30 秒(WRITE_TIMEOUT) |
各 send(登録、JSON ハートビート、Ping、セッション出力)と登録成功後の Pong flush ごとに制限します。失敗や超過で終了して再接続します。登録待機中の Pong flush は前述の 45 秒の ack 待機時限に従います |
| agent TCP キープアライブ | アイドル 30 秒後にプローブ | 直接接続または HTTP CONNECT 後に TCP_NODELAY と TCP keepalive を有効にします。TCP 層の設定であり WebSocket Ping/Pong の代替ではありません |
| CLI welcome タイムアウト | 15 秒 | 接続後に CLI が welcome を待つ制限時間 |
| コマンド実行タイムアウト | open_exec の timeout_secs で指定 |
agent は最低 1 秒に補正します。超過時はプロセスグループ全体に SIGTERM、3 秒の猶予後に SIGKILL を送ります |
| ubus 呼び出しタイムアウト | 15 秒(agent 内固定) | ubus はローカル RPC なので、この時間を超えれば対象サービスが停止状態とほぼ判断できます |
アイドルな shell のクライアントも Pong を返します。agent はライブラリーが自動で積んだ応答を明示的な flush で送り、次の業務ハートビートを待たないため、キー入力がなくても接続断と判断されません(#42)。--heartbeat-secs は 0 より大きい必要があり、不正値は起動引数の解析時に拒否します。
バックプレッシャー
exchange は接続ごとに 512 メッセージ容量の有界送信キューを持ちます。
満杯は対向の消費が追い付かないことを示し、PTY では通常ネットワーク停止かクライアントの一時停止です。その場合はバッファリングを続けず能動的に接続を切断します。無制限の蓄積は一つの停止クライアントをサービス全体のメモリーリークに変えます。512 件は対話型 PTY に十分な余裕(通常は一桁)で、到達すれば対向が消費していないとほぼ判断できます。
配信には常に非ブロッキングの try_send を使います。ルーティング経路は接続表のロックを保持しており、await すると保持時間が対向の消費速度に依存し、一つの遅いクライアントが表全体を止めます。
配信失敗は二種類を区別します。
| 結果 | 意味 | 処理 |
|---|---|---|
Lagging |
キューが満杯 | 独立した切断シグナルで対象接続を中断し、そのセッションを削除して、可能な限り反対側へ失敗を通知します |
Closed |
受信側が解放済み | 通常の接続終了として debug ログだけを記録します |
切断シグナルは送信キューを通らず、満杯や書き込み停止中でもクリーンアップを開始できます。デバイスの再接続による置換やトークンローテーションも同じ仕組みを使います。
agent にも容量 256 の送信キューがあります。満杯はネットワークがデータを全く送れない状態で、蓄積を続けても意味がなく再接続すべきです。PTY 入力キュー(64)が満杯ならキーを直接破棄します。小さな対話データをためても意味がないためです。
Android 画面操作
定義は crates/proto/src/android.rs にあり、既存の二名前空間ルーティング、認証、同時実行制限、監査を使います。open_android / android_req の request は action で区別する JSON オブジェクトです。
action |
その他のフィールド | 動作 |
|---|---|---|
status |
なし | ローカルの認可と SDK バージョンを照会。画面操作スイッチは不要ですがリモートサービスは動作している必要があります |
tree |
なし | 現在アクティブなアプリのアクセシビリティノードツリー |
screenshot |
なし | 単一の JPEG スクリーンショット |
tap |
x, y: u32 |
元の画面ピクセル座標をタップ |
swipe |
x1, y1, x2, y2, duration_ms: u32 |
スワイプ。時間は 50–5000 ms |
swipe_path |
points: [{x, y}](2–64 点), duration_ms: u32 |
折れ線スワイプ。一度の押下で全点を連続通過し、長さに沿って均等補間します。時間は 50–5000 ms |
global |
key: string |
back / home / recents / notifications / quick_settings |
input_text |
text: string |
フォーカス中の編集可能フィールドを置換。最大 8192 UTF-8 バイトで NUL 不可 |
AndroidResult は kind で区別し、フィールドはすべて必須です。
kind |
フィールド |
|---|---|
status |
controls_enabled, accessibility_enabled, screen_capture_enabled: bool;sdk: u32 |
tree |
nodes: AndroidNode[];screen_width, screen_height: u32;truncated: bool |
screenshot |
mime_type: "image/jpeg";data_base64: string;width, height, screen_width, screen_height: u32 |
ok |
なし。入力、ジェスチャー、システム操作の完了を示します |
ノードには文字列 class_name, package, view_id, text, description、bounds: [left, top, right, bottom](符号付き画面ピクセル座標)、真偽値 clickable, editable, focused, password を含みます。最大 200 ノード、各文字列は最大 256 UTF-16 単位で、truncated はノード数上限だけを示します。パスワードの値と説明は空で、agent 自身の設定サブツリーは省略します。
画像の長辺は最大 1600 ピクセルです。width/height は画像サイズ、screen_width/screen_height は縮小前のサイズで、ジェスチャー座標は縮小前のサイズに従います。Java ブリッジの戻り JSON は最大 2 MiB、WebSocket のラップには追加で 1024 バイトを許可します。バイナリフレーム種別の追加はありません。
{"type":"open_android","session_id":1,"device_id":"phone","request":{"action":"input_text","text":"Hello\nWorld"}}
{"type":"android_req","session_id":31,"request":{"action":"input_text","text":"Hello\nWorld"}}
{"type":"android_resp","session_id":31,"result":{"kind":"ok"}}
{"type":"android_resp","session_id":1,"result":{"kind":"ok"}}
screen_capture_enabled は現在使える画面取得権限(MediaProjection または Android 11+ のアクセシビリティ)を示します。実際のすべての画面操作には引き続き controls_enabled が必要です。権限不足、アクセスできないウィンドウ、操作失敗は agent が session_error を返し、Exchange が session_failed に変換します。Android 以外の agent は明示的な非対応エラーを返します。
Agent は Android 要求を同時に最大 4 件扱い、画像エンコードは一度に一枚だけです。Java 操作待機は最大 10 秒(画面フレーム待機は最大 5 秒)、agent の要求タイムアウトは 15 秒、CLI は 20 秒です。運用側の取消や切断時、Exchange は android_cancel を送り、agent はローカル socket を閉じ、APK は未実行のキュー済み作業を取り消します。実行済みのジェスチャーやテキスト変更はロールバックしません。
監査 action は android で、detail に操作と座標を保存します。input_text は characters(Unicode 文字数)だけを保存し、成功結果は outcome: "completed" だけを記録します。本文、画面ツリー、画像は監査に書きません。readonly は status、tree、スクリーンショットも開始できません。
ファイルセッション拡張
ファイル操作対応 Agent は files 能力を宣言します。open_file {session_id, device_id, request} は Exchange で file_req {session_id, request} に変換されます。request.action は list、download、upload で、定義は crates/proto/src/files.rs を参照してください。
双方向制御は JSON です。操作側は file_input {session_id, input}、デバイスは file_event {session_id, event} を送ります。input.kind は chunk {offset, data}、ack {offset}、finish、event.kind は ready {size}、chunk {offset, data}、progress {received}、complete {size}、list {path, entries, truncated} です。チャンク内容は標準 Base64 で、各最大 64 KiB、送信側は確認を待って続行します。
ダウンロードの最初の ready には ack {offset: 0} が必要で、その後各チャンクで累積バイト数を確認します。アップロードは ready で始まり、各 progress が次のチャンクを駆動し、全送信後に finish で確定します。complete と list は終端メッセージで、エラーは既存の session_error / session_failed を使います。close_session は file_cancel を送り、取消と切断はともにルートを削除します。ファイルメッセージはファイルセッションだけで転送され、操作側 ID とデバイス側 ID の分離を維持します。
プロトコルバージョンは引き続き 1 です。新メッセージには Exchange と Agent の同時更新が必要です。操作制限と LAN インターフェースはファイル転送と LAN 直接接続を参照してください。
マルチプラットフォーム拡張
プロトコルバージョンは引き続き 1 です。登録時の info に任意の platform(openwrt / linux / freebsd / netbsd / windows / android / macos)、os_version、capabilities(exec / pty / ubus / android_control / files / sensors / net)を追加します。OpenWrt は openwrt_version を報告します。
ハートビートの metrics.unavailable は任意の文字列配列で、現在取得できない uptime、load、memory、cpu、interfaces を示します。配列がなければ旧形式で解析します。列挙された指標の数値は互換用のプレースホルダーなので、クライアントは欠損として表示すべきです。Windows は Unix 平均負荷を提供せず、Android の /proc の一部はシステム権限で制限される場合があります。
コマンドは対象デバイスの shell で解釈します。Linux/OpenWrt/macOS は /bin/sh、Windows は PowerShell、Android は /system/bin/sh です。PTY フレーム形式と制御メッセージは三システムで同じで、Windows は ConPTY を使います。OpenWrt 以外が ubus 要求を受けると session_error を返します。
エンドツーエンドの密封セッション
プロトコルバージョンは引き続き 1 です。新メッセージは OperatorUp の open_sealed {session_id, device_id, hello}、sealed_input {session_id, packet}、DeviceDown の sealed_open {session_id, hello}、sealed_input、sealed_close {session_id, detach}、DeviceUp / OperatorDown の sealed_accept {session_id, accept}、sealed_event {session_id, packet, last} です。バイナリフレーム種別 4 = Sealed(ペイロード counter ‖ AEAD(inner_kind ‖ bytes))、DeviceInfo.seal_key と能力 sealed を追加します。
Exchange は密封セッションの番号変換、同時実行制限、ルーティングだけを行います。ペイロードの解析と監査はせず(監査は公開鍵、認可レベルと期限、終了結果だけ)、権限判断もしません(アカウントがデバイスを参照できるかだけを確認)。last:true でルートを解放します。密封ルートは Sealed フレームだけを受け付け、通常ルートは Sealed を受け付けません。agent は密封セッション番号宛ての平文をすべて破棄します。認可、ハンドシェイク、鍵導出、脅威モデルはエンドツーエンドの密封セッションを参照してください。