remote.DocumentationHome

Wire Protocol

This document specifies the complete wire format between agent, exchange, and cli. Definitions live in crates/proto, and all three share the same code: fields are defined in one place, so inconsistencies surface at compile time rather than during a production session.

It serves operators inspecting network traffic to diagnose problems, and developers changing the protocol or writing third-party clients.


Contents


Overview

The protocol has two layers sharing one WebSocket connection:

Layer Carrier Encoding Contents
Control messages WebSocket text frames JSON Registration, heartbeats, opening/closing sessions, result notifications
Data frames WebSocket binary frames 9-byte fixed header + raw bytes PTY byte streams, command stdout/stderr

Embedding streaming data in JSON as base64 increases its size by approximately 33%. PTY traffic consists of frequent small packets echoing each keystroke, so this overhead directly affects interaction latency. Control messages are infrequent, making JSON readability more valuable: packet captures are immediately understandable.

By convention, each WebSocket binary frame carries exactly one data frame. Extra or missing bytes are errors.


Transport and Endpoints

Endpoint Client Authentication
GET /ws/device agent The first frame after connection establishment must be a register control message containing device_id and token
GET /ws/operator remote-cli HTTP request header Authorization: Bearer <运维令牌> in the WebSocket handshake

The two paths use separate credential types. Device tokens cannot access the operator endpoint, and vice versa.

Operator credentials use a request header rather than a URL query string because URLs appear in reverse proxy logs, browser history, and Referer headers. Device tokens use the first frame rather than a request header because some embedded clients cannot easily customize WebSocket handshake headers. The first-frame approach also allows protocol version mismatches and invalid tokens to use the same response message.

Production deployments must use wss://: both credential types appear in plaintext during the handshake.


Protocol Version

pub const PROTOCOL_VERSION: u16 = 1;

Versions are compared in both directions during the handshake. A mismatch causes immediate rejection with an explicit explanation. This prevents version mismatches from appearing as an inexplicable parsing failure in a later message, which would be difficult to diagnose.

  • Device side: agent includes its version in register. On a mismatch, the server returns register_ack with ok: false, describes both versions in error, and closes the connection.
  • Operator side: the server proactively sends welcome after connection establishment. If the CLI detects a mismatch, it reports an error and instructs the user to upgrade to a version matching server_version.

Unknown message type values are always rejected, never silently interpreted as a known type.


Control Messages

All control messages are JSON objects tagged by the type field (serde internally tagged enums). Tag values and field names use snake_case. session_id is transparently serialized as a plain number.

DeviceUp (agent → exchange)

type Fields Purpose
register protocol_version: u16
device_id: string
token: string
info: DeviceInfo
Must be the first message after connection. The server accepts no other messages before it, with a 15-second deadline
heartbeat metrics: Metrics Periodic operational metrics. Connection liveness is handled separately by native WebSocket ping/pong: a missing heartbeat does not imply a broken connection, or vice versa
exec_started session_id
job_id
hold
timeout_secs
Command started; job_id is used in the hold list and for reattachment after reconnecting
exec_exit session_id
code: i32 | null
timed_out: bool
Command finished. code is null when a signal terminates the process; timed_out indicates forced termination due to timeout
exec_list_resp session_id
jobs: ExecHoldInfo[]
List of hold jobs
ubus_resp session_id
code: i32
data: any
ubus call result. code is the ubus return code; 0 means success
android_resp session_id
result: AndroidResult
Android screen operation completed; this message ends the session
pty_closed session_id
code: i32 | null
PTY session ended; code is the shell exit code
session_error session_id
message: string
Session-level error. The device sends this when it cannot complete a request, rather than silently dropping it

A second register on an already registered connection is treated as protocol misuse and logged as a warning.

Example:

{"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 Fields Purpose
register_ack ok: bool
error: string | null
heartbeat_interval_secs: u64
Registration result. With ok: false, error provides the reason, then the server closes the connection. The server specifies the heartbeat interval so it can reduce frequency centrally as the fleet grows without reconfiguring each device. agent clamps 0 to at least 1 second to prevent a busy timer from saturating the CPU
device_access managers: DeviceManager[]
updated_at: u64 (milliseconds)
Who has permission on this device. DeviceManager = operator_id, username, email?, role, level (manage/view), source (role_default/grant). Sent after registration and permission changes only to agents advertising the device_access capability; see repository docs/permissions.md
exec_req session_id
command: string
timeout_secs: u64
hold: bool (default true)
hold_timeout_secs?: u64
Execute a command. With hold=true (default), the process can continue after the operator disconnects. Timeout uses hold_timeout_secs (absent/0 → 3600, hard maximum 86400). hold=false cancels the command when the operator disconnects
exec_cancel session_id Terminate (kill) command execution early
exec_detach session_id Stop streaming output to the current operator while retaining the hold process
exec_list_req session_id Query current hold jobs
exec_attach_req session_id
job_id
Reattach a hold job to this session and replay buffered output
exec_hold_kill session_id
job_id
Terminate a hold job by job_id
exec_hold_tail session_id
job_id
Follow new output only, without replaying the buffer
exec_hold_cat session_id
job_id
Dump the current buffer and end with exec_hold_cat_done
ubus_req session_id
object: string
method: string
params: any
Make a ubus call. When params is null, no third positional argument is passed to ubus
pty_open session_id
rows: u16
cols: u16
Open a PTY session. Zero dimensions fall back to 24×80
pty_resize session_id
rows: u16
cols: u16
Terminal window dimensions changed
pty_close session_id Close a PTY session
android_req session_id
request: AndroidAction
One Android screen operation
android_cancel session_id Cancel an unfinished Android request

OperatorUp (cli → exchange)

Authentication happens during the WebSocket handshake, so there are no login messages.

type Fields Purpose
open_exec session_id
device_id
command
timeout_secs
hold (default true)
hold_timeout_secs?
Execute a command on the target device. hold is enabled by default; hold=true with timeout 0 → 3600s. hold=false with timeout 0 → Exchange default of 60s
list_exec_holds session_id
device_id
Query exec jobs and active downloads, uploads, and directory queries
list_device_sessions session_id
device_id
Query the Exchange routing table directly without consuming a device session slot
kill_device_session session_id
device_id
target_session_id
Cancel by device-side target_session_id and release the route locally; includes hold exec and consumes no extra slot
attach_exec session_id
device_id
job_id
Attach to a hold job and resume receiving output
exec_hold_kill / exec_hold_tail / exec_hold_cat session_id
device_id
job_id
kill / tail / cat for hold jobs
detach_session session_id Stop waiting in the foreground and detach without killing the hold process
open_ubus session_id
device_id: string
object: string
method: string
params: any
Make a ubus call
open_pty session_id
device_id: string
rows: u16
cols: u16
Open an interactive PTY session
open_android session_id
device_id: string
request: AndroidAction
Initiate an Android screen operation; read-only accounts are prohibited
pty_resize session_id
rows: u16
cols: u16
Terminal window dimensions changed. No device_id: the session already exists, so the server can look up the target device
close_session session_id End a session proactively: normal PTY exit or user interruption of command execution

The session_id in all four open_* messages is allocated by the CLI itself (see Session ID Translation Between Two Namespaces), allowing subsequent frames immediately after the request without waiting for an ID acknowledgment.

OperatorDown (exchange → cli)

type Fields Purpose
welcome protocol_version: u16
server_version: string
Sent proactively by the server after connection establishment to verify matching protocol versions
device_sessions session_id
sessions: DeviceSessionInfo[]
limit
count
Current device-side sessions in Exchange
device_session_killed session_id
target_session_id
released: bool
Released locally; false if it did not exist
exec_exit session_id
code: i32 | null
timed_out: bool
Command ended, translated from the device-side message of the same name
ubus_resp session_id
code: i32
data: any
ubus call result
android_resp session_id
result: AndroidResult
Android result with the session ID translated to the operator namespace
pty_closed session_id
code: i32 | null
PTY session ended
session_failed session_id
reason: SessionFailure
message: string
Session failure. reason distinguishes causes so the CLI can decide whether retrying is worthwhile

Shared Data Structures

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. For compatibility, missing kind defaults to exec, and missing age_secs defaults to 0. List responses calculate age from the current clock. Command/file path summaries are limited to approximately 120 characters. Non-exec jobs support kill only, not tail/cat/attach.

DeviceSessionInfo: session_id: u32 (device-side ID), operator_session_id: u32, kind: string, hold: bool, age_secs: u64, deadline_unix_ms: u64. Exchange answers session-list and forced-cancellation requests directly without opening another device session; read-only accounts receive forbidden.

DeviceInfo

Static device information, reported once in register and stored in devices.info. Everything except agent_version is optional: most sources are unavailable when agent runs outside OpenWrt, such as on a development machine or in a container. Registration should still succeed rather than startup failing. Missing fields are omitted during serialization.

Field Type Source
hostname string? /proc/sys/kernel/hostname
model string? DISTRIB_TARGET in /etc/openwrt_release, falling back to model from ubus call system board if unavailable
openwrt_version string? DISTRIB_DESCRIPTION, or ubus release.description
kernel string? /proc/sys/kernel/osrelease
arch string? Compile-time constant, more reliable than runtime detection
agent_version string The agent version, used to diagnose version mismatches

Metrics

Collected again for each heartbeat, using only values available directly from /proc without forking subprocesses. Failure to read one item does not fail the whole collection: losing one metric is preferable to failing the whole heartbeat and incorrectly reporting the device offline.

Field Type Description
uptime_secs u64 /proc/uptime
load [f32; 3] 1/5/15-minute load averages from /proc/loadavg
mem_total_kb u64 /proc/meminfo
mem_available_kb u64 Uses MemAvailable rather than MemFree: the latter excludes reclaimable caches and significantly underestimates available memory on routers, producing false alerts
cpu_usage_pct f32? Calculated from the difference between two /proc/stat samples; omitted on the first heartbeat because there is no reference sample
interfaces InterfaceStats[] /proc/net/dev. Reports cumulative receive/transmit bytes rather than rates, leaving the server free to calculate rates over any time window. Omitted when empty

InterfaceStats: { name: string, rx_bytes: u64, tx_bytes: u64 }


Binary Data Frames

 0        1                    5                    9
 +--------+--------------------+--------------------+
 |  kind  |     session_id     |    payload_len     |  payload (payload_len 字节)
 |   u8   |    u32 (大端)      |    u32 (大端)      |
 +--------+--------------------+--------------------+
 └──────────── 9 字节定长头 ────────────┘
Offset Length Field Description
0 1 kind Stream type; see below
1 4 session_id Big-endian u32. 0 is reserved as a sentinel meaning no session
5 4 payload_len Big-endian u32, payload byte count, maximum 1 MiB
9 payload_len payload Raw bytes without encoding or escaping

Stream Types

Value Name Direction Description
1 ExecStdout Device → operator Remote command standard output
2 ExecStderr Device → operator Standard error, transmitted separately from stdout so the CLI can render it differently, such as in red
3 PtyData Bidirectional PTY byte stream. Upstream carries keyboard input, downstream carries screen output; the same type serves both directions

If agent receives ExecStdout / ExecStderr downstream, it logs a warning and discards the frame: this indicates an implementation error at the peer.

Design Notes

payload_len is indeed redundant with the WebSocket length field. It remains for two reasons: decoding can detect truncation (a declaration of 11 bytes with only 5 supplied must be rejected rather than silently accepted as a shorter packet), and the frame format can remain unchanged if raw TCP is needed later.

The 1 MiB payload limit is not intended for normal traffic: PTY and command output are incremental streams far smaller than that. It prevents a peer from exhausting server memory with a header claiming a 2 GiB payload.

Empty payloads are valid: zero-byte reads occasionally occur in PTY sessions. Such a frame consists of the 9-byte header alone.

Payloads contain arbitrary bytes, including non-UTF-8 sequences, and must be preserved exactly.

Decoding Errors

Error Trigger
FrameTooShort { len } Total length below 9 bytes
UnknownStreamKind(v) First byte is not 1/2/3
PayloadTooLarge { len } Declared payload_len exceeds 1 MiB
LengthMismatch { declared, actual } Declared length differs from the actual remaining byte count

A data-frame decoding failure affects only that frame: exchange logs a warning and discards it without disconnecting.


Session ID Translation Between Two Namespaces

This is the part of the protocol most easily misunderstood.

In One Sentence

The initiator allocates session_id, which is unique only within its own connection. Operator and device connections each maintain independent ID namespaces, and exchange translates between them. One logical session has two different IDs.

  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…)

Why This Design

Avoid a round trip. If the server allocated all IDs, the CLI would have to wait for an acknowledgment saying its session ID is 31 before sending keyboard input after opening a PTY. CLI allocation allows data frames immediately after open_pty.

Avoid ID collisions between operators. Two operators may simultaneously use local ID 1 on the same device. The device must distinguish them to avoid sending one person's output to another. exchange allocates device-side IDs separately for each device connection, mapping the two session 1 instances to device-side 31 and 32.

Keep operator behavior from influencing device-side IDs. exchange controls device-side IDs entirely; no operator can specify or guess them.

Two Tables

exchange maintains one table per connection, with both tables always updated together:

Location Table Key → value
DeviceHandle.sessions Device-side forward table Device session ID → (operator send handle, operator session ID, operator account ID, session type, audit row ID)
OperatorHandle.sessions Operator-side reverse index Operator session ID → (device ID, device session ID)

The reverse index enables cleanup: when an operator disconnects, the server must find and terminate its sessions on every device, otherwise processes and PTYs with no output recipient would remain.

Where Translation Happens

Direction Message Translation
Operator → device (open_*) Control message Allocate a new ID in the device namespace, register it in both tables, and use the new ID in the message sent to the device
Operator → device (pty_resize / close_session) Control message Look up the device session ID through the reverse index, replace it, and forward
Operator → device Data frame Look up the device session ID through the reverse index and overwrite the 4 header bytes in place, leaving the payload unchanged
Device → operator Control message Look up the operator session ID and target connection in the forward table, replace the ID, and deliver
Device → operator Data frame Look up the operator session ID in the forward table and overwrite the header in place, leaving the payload unchanged

Data-frame translation is the only rewrite: exchange does not parse, buffer, or change any payload byte. DataFrame::with_session_id replaces the ID in place without reallocating the payload.

Allocation Rules

Both allocators increment from 1 and skip 0, the no-session sentinel used, for example, in connection-level error messages.

  • CLI (SessionIdGenerator): wraps on overflow and skips 0. Using four billion sessions in one connection is unrealistic, but wrapping is better suited to a long-running process than a panic.
  • Device (DeviceHandle::open_session): after wrapping, if an ID belongs to a live session, continues to the next free slot.

exchange rejects operator ID 0 and reuse of an active session ID. Device connection validity, concurrency limits, and registration in both indexes are checked within the same critical section, preventing simultaneous session opens from multiple operators from exceeding the limit.

When No Route Exists

Silently discard without an error. A peer still sending trailing data just after session closure is a normal race. This applies in both directions:

  • Device replies for a session that no longer exists → debug log; the operator may already have disconnected.
  • Operator sends data for an unowned session → trace log and discard.

Session Cleanup Paths

Trigger Cleanup
Device ends a session normally (exec_exit / ubus_resp / android_resp / pty_closed / session_error) Remove the forward route and owning connection's reverse index together, update the audit record, and deliver the translated message to the operator
Operator close_session Remove both indexes, tell the original device connection to terminate, immediately acknowledge closure to the operator, and update the cancellation audit record. Repeated closes do nothing
Device disconnects Take all sessions for that connection, send session_failed / device_disconnected to each operator, and update the audit record with {"outcome":"device_disconnected"}
Operator disconnects Traverse its reverse index to find its sessions on each device. Send termination instructions for non-hold sessions; for hold exec send only exec_detach, leaving the process running. Clean up only its own sessions; other operators on the same device are unaffected
Device reconnects and replaces the old connection Invalidate all sessions on the old connection and notify each operator with device_disconnected

The reverse index binds to a specific device connection; it does not redirect by device ID to a new connection after reconnection. Multiple operator connections for one account each own an index, so disconnecting one does not cancel another connection's sessions.

Acknowledgments for explicit cancellation: exec returns exec_exit (code: null, timed_out: false), PTY returns pty_closed (code: null), and ubus returns session_failed (reason: device_error). This confirms exchange has closed the session, not that the remote process has exited; subsequent trailing data and exit messages from the device are discarded.

At the concurrency limit, Exchange first reclaims routes whose operators are gone or whose deadlines have passed under the same session lock, then checks the limit again. The exec deadline is the actual dispatched time limit plus a 15-second grace period; files use 3600 seconds plus 15 seconds; other types use 24 hours. Hold exec within its deadline is not killed when the operator leaves. Reclamation/explicit kill_device_session sends the corresponding cancellation to the device and immediately removes both indexes without waiting for a device response. Available operators receive exec_exit (timed_out:true) for exec or session_failed (device_error, session reclaimed) for other types. Audit updates are asynchronous and best effort.

Device Reconnection and epoch

Each device connection receives a random epoch (UUID) during registration. When the same device reconnects, the new connection replaces the old one, rather than being rejected. It is impossible to reliably distinguish a duplicate connection from a dead connection the server has not detected yet (a half-open connection); rejecting the new one would prevent reconnection until the old one timed out.

Cleanup for the old connection may execute after registration of the new one. Deregistration must therefore check epoch: remove the connection table entry only if its current epoch matches the connection being cleaned up. Without this check, old-connection cleanup would immediately mark the newly connected device offline.


Handshake Sequence

Device Side

  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)       │
      │────────────────────────────────────────────────▶│

Failure paths:

  • First frame is not text, is invalid JSON, or is not register → log a warning and close immediately without an ack.
  • No register within 15 seconds → close.
  • Version mismatch → return register_ack{ok:false, error:"协议版本不匹配:设备为 X,服务端为 Y"} and close.
  • Invalid token / nonexistent device / disabled device → always return the same "设备 ID 或令牌无效" and close. Do not distinguish causes: doing so would make the device inventory enumerable.

agent handles these two failure categories separately:

agent category Trigger Reconnection strategy
Rejected Receives an ack with ok: false Fixed 300-second wait. Retrying cannot fix an invalid token; frequent retries waste resources on both sides and flood server logs. A longer wait gives someone time to update configuration
Transport Connection failure, handshake timeout (45 seconds), malformed response, interrupted connection Exponential backoff: starts at 1 second, doubles each time up to 300 seconds, multiplied by random jitter in [0.5, 1.5)

Backoff resets only after successful registration, not merely TCP establishment. Otherwise, immediate rejection after connection would degrade into dense retries at millisecond intervals.

Jitter is essential. When a server restarts, hundreds or thousands of devices detect the disconnect at once. With an identical deterministic retry curve, they remain synchronized and hit the newly started server in waves, turning a restart into a sustained cascading failure.

Operator Side

  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, 屏幕输出)        │
      │◀────────────────────────────────────────────────│◀── 改写为运维侧编号

The CLI can send the next data frame without waiting for any acknowledgment. This is precisely why the initiator allocates session IDs.


Error Handling and Failure Categories

SessionFailure

Used in the reason field of session_failed; JSON values use snake_case.

Value Meaning Producer Retryable
device_offline Device does not exist or is currently offline exchange finds no device in the connection table during open_* ✅
device_disconnected Device disconnected during the session Device disconnected / replaced by a new connection / send queue already closed during dispatch ✅
device_error Device rejected the request or execution failed agent returns session_error (PTY creation, command startup, or ubus call failure), or the peer consumes too slowly during forwarding ❌
rate_limited Concurrent session limit exceeded exchange finds the device's active sessions have reached --max-sessions-per-device (default 32) during open_* ✅
forbidden Operator credentials cannot access the device A read-only account (role = readonly) attempts to initiate any session ❌

is_retryable() deliberately excludes forbidden and device_error: retrying a permission issue or internal device error is pointless and only causes unnecessary CLI activity.

The CLI appends an actionable hint after the original message. For example, device_offline suggests checking device status with remote-cli device list. device_error is the exception: the device's own error is the most accurate explanation and should not be diluted by boilerplate.

Failure Semantics by Layer

Layer Behavior Scope
Frame decoding failure Log and discard the frame One frame
Control message parsing failure Warn and ignore the message One message; connection continues
Session-level failure session_failed / session_error One session; connection continues
Registration failure Close after register_ack{ok:false} Entire connection
Version mismatch Close after an explicit error message Entire connection
Queue lag Proactively close connection Entire connection

The principle: keep failures local whenever possible, since a device connection may carry simultaneous sessions for multiple operators.


Keepalive and Timeouts

Mechanism Value Description
exchange WebSocket Ping Every --heartbeat-secs (default 30 seconds) exchange sends to both devices and operators. Client libraries automatically queue Pong; agent must explicitly flush the sink after receiving Ping to send Pong promptly
agent WebSocket Ping Every heartbeat_interval_secs (provided in the server ack) agent sends native Ping in addition to each JSON heartbeat, avoiding Cloudflare's approximately 100-second idle-disconnect boundary if exchange Ping is delayed
Connection silence timeout heartbeat × --missed-pings (default 30 × 3 = 90 seconds) A device or operator connection with no inbound messages during this interval is considered lost and disconnected. Pong counts as activity; --missed-pings set to 0 falls back to at least one heartbeat interval
exchange write timeout 10 seconds Any single WebSocket data or Ping write timeout ends the connection, preventing slow connections from blocking cleanup
Business heartbeat Every heartbeat_interval_secs (provided in the server ack) agent sends JSON heartbeat with Metrics and a separate WebSocket Ping. Metrics reporting and native ping/pong serve separate purposes; business heartbeats alone cannot determine connection status
Online/offline events tick_count for notification triggers online / offline (default 3, range 1–100); broadcast/audit fallback REMOTE_MISSED_THRESHOLD / REMOTE_ONLINE_THRESHOLD Operators configure two triggers, rather than two counts on one trigger. offline notifies after tick_count consecutive misses; online notifies after tick_count consecutive successes. Each trigger's device_ids can be one device ID (string), a set of IDs (array), or omitted / null for all workspace devices. A trigger does not repeat notifications while already in its reported state; it must first rearm through tick_count consecutive opposite results. In-process presence broadcasts and audits do not read these triggers and fall back to REMOTE_MISSED_THRESHOLD / REMOTE_ONLINE_THRESHOLD (also defaulting to 3). Whether the socket remains in the connection table is determined separately by --missed-pings
Device registration timeout 15 seconds register must be sent within this interval after connection establishment
agent handshake timeout 45 seconds per phase (HANDSHAKE_TIMEOUT) connect_device_ws (TCP, optional HTTP CONNECT, TLS, WebSocket handshake) and waiting for register_ack after sending register each get this limit; 45 seconds is not their combined limit
agent write / Pong flush timeout 30 seconds (WRITE_TIMEOUT) Each send (registration, JSON heartbeat, Ping, session output) and each post-registration Pong flush has its own deadline. Write failure or timeout ends the connection and triggers reconnection. Pong flush while waiting for registration is governed by the 45-second ack wait above
agent TCP keepalive Probe after 30 seconds idle Enables TCP_NODELAY and TCP keepalive after direct connection or HTTP CONNECT; TCP settings do not replace WebSocket Ping/Pong
CLI welcome timeout 15 seconds Time the CLI waits for welcome after connection
Command execution timeout Specified by timeout_secs in open_exec agent clamps to at least 1 second. On timeout, SIGTERM the entire process group, then SIGKILL after a 3-second grace period
ubus call timeout 15 seconds (fixed within agent) ubus is local RPC; exceeding this interval generally indicates the corresponding service is stuck

An idle shell client still replies with Pong. agent explicitly flushes the response automatically queued by the library without waiting for the next business heartbeat, so lack of keyboard input does not cause a false disconnect (#42). --heartbeat-secs must be greater than 0; invalid values are rejected during startup argument parsing.


Backpressure

exchange maintains a bounded send queue of 512 messages for each connection.

A full queue means the peer cannot keep up, usually because a PTY connection has stalled or the client is suspended. The response is to disconnect that connection proactively rather than keep buffering: unbounded buffering would turn one stalled client into a memory leak affecting the whole service. 512 messages provide ample headroom for interactive PTY traffic (normally queue length is in single digits); hitting the limit generally means the peer has stopped consuming.

Delivery always uses nonblocking try_send. The routing path holds a connection-table lock; any await would tie lock duration to peer consumption speed, allowing a slow client to stall the entire table.

The two delivery failures are handled differently:

Result Meaning Handling
Lagging Queue full Interrupt the connection using a separate close signal, clean up its sessions, and make a best effort to notify the other endpoint of failure
Closed Receiver released Normal connection termination; debug log only

Connection close signals bypass the send queue, allowing cleanup even when the queue is full or writes are blocked. Device replacement after reconnect and token rotation use the same mechanism.

agent also has an outbound queue of capacity 256. A full queue means the network cannot send data at all, so further accumulation is pointless; disconnect and reconnect. When the PTY input queue (64) is full, keystrokes are simply discarded: accumulating small interactive inputs serves no purpose.

Android Screen Control

Defined in crates/proto/src/android.rs, using the existing two-namespace session routing, authentication, concurrency limits, and auditing. The request in open_android / android_req is a JSON object discriminated by action:

action Other fields Behavior
status None Query local authorization and SDK version; the screen control switch need not be enabled, but the remote service must be running
tree None Accessibility node tree of the currently active app
screenshot None One JPEG screenshot
tap x, y: u32 Tap original screen pixel coordinates
swipe x1, y1, x2, y2, duration_ms: u32 Swipe, duration 50–5000 ms
swipe_path points: [{x, y}] (2–64 points), duration_ms: u32 Multi-segment swipe: one press continuously traverses all points, interpolated uniformly by length, duration 50–5000 ms
global key: string back / home / recents / notifications / quick_settings
input_text text: string Replace the focused editable field, maximum 8192 UTF-8 bytes, no NUL

AndroidResult is discriminated by kind; all fields are required:

kind Fields
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 None; indicates completion of input, gesture, or system action

Nodes contain strings class_name, package, view_id, text, description, bounds: [left, top, right, bottom] (signed screen pixel coordinates), and booleans clickable, editable, focused, password. At most 200 nodes, with each string limited to 256 UTF-16 units. truncated indicates only the node limit. Password values and descriptions are empty, and the agent's own configuration subtree is omitted.

Screenshot longest edge is limited to 1600 pixels. width/height are image dimensions; screen_width/screen_height are dimensions before scaling. Gesture coordinates use the original dimensions. JSON returned by the Java bridge is limited to 2 MiB, with an additional 1024 bytes allowed for the WebSocket envelope. No new binary frame type is added.

{"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 indicates currently available screenshot authorization (MediaProjection or Android 11+ accessibility); all actual screen operations still require controls_enabled. Insufficient permission, inaccessible windows, and failed operations return agent session_error, translated by Exchange to session_failed. Non-Android agents return an explicit unsupported error.

Agent handles at most 4 Android requests concurrently and encodes only one screenshot at a time. Java operations wait at most 10 seconds (up to 5 seconds for a screen frame); agent request timeout is 15 seconds, CLI timeout 20 seconds. On operator cancellation or disconnection, Exchange sends android_cancel; agent closes the local socket and the APK cancels queued work that has not run yet. Completed gestures or text changes are not rolled back.

The audit action is android; detail stores the action and coordinates. input_text stores only characters (Unicode character count), and successful results record only outcome: "completed". Text content, UI trees, and images are not written to audit records. readonly accounts cannot initiate even status, tree, or screenshots.

File Session Extension

Agents supporting file operations advertise the files capability. Exchange translates open_file {session_id, device_id, request} to file_req {session_id, request}. request.action is list, download, or upload; field definitions are in crates/proto/src/files.rs.

Bidirectional control messages use JSON: the controller sends file_input {session_id, input}, and the device returns file_event {session_id, event}. input.kind is chunk {offset, data}, ack {offset}, or finish; event.kind is ready {size}, chunk {offset, data}, progress {received}, complete {size}, or list {path, entries, truncated}. Chunk contents use standard Base64, each chunk is at most 64 KiB, and the sender waits for acknowledgment before continuing.

A download's initial ready requires ack {offset: 0}; subsequent chunks acknowledge the cumulative byte count. Upload starts with ready, and each progress drives the next chunk; after sending everything, finish commits it. complete and list are terminal messages; errors use existing session_error / session_failed. close_session sends file_cancel; cancellation and disconnection both remove session routes. File messages are forwarded only on file sessions, preserving isolation between operator and device session IDs.

Protocol version remains 1; the new messages require matching Exchange and Agent upgrades. For operation limits and LAN interfaces, see File Transfer and Direct LAN Connections.

Multi-Platform Extensions

Protocol version remains 1. Registration info adds optional platform (openwrt / linux / freebsd / netbsd / windows / android / macos), os_version, and capabilities (exec / pty / ubus / android_control / files / sensors / net). OpenWrt reports openwrt_version.

Heartbeat metrics.unavailable is an optional string array identifying currently unavailable uptime, load, memory, cpu, and interfaces metrics. If absent, parse using the old format. Values corresponding to listed metrics are only compatibility placeholders; clients should show them as unavailable. Windows does not provide Unix load averages, and Android may restrict some /proc sources through system permissions.

Commands are interpreted by the target device's shell: /bin/sh on Linux/OpenWrt/macOS, PowerShell on Windows, and /system/bin/sh on Android. PTY frame format and control messages are identical across the three systems; Windows uses ConPTY. Non-OpenWrt devices return session_error for ubus requests.

End-to-End Sealed Sessions

Protocol version remains 1. New messages: 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}. New binary frame type 4 = Sealed (payload counter ‖ AEAD(inner_kind ‖ bytes)), DeviceInfo.seal_key, and capability sealed.

For sealed sessions, Exchange performs only ID translation, concurrency limiting, and routing. It neither parses nor audits payloads (audit records contain only public keys, authorization level and expiry, and final outcome), and does not decide permissions beyond checking whether the account can see the device. Routes are released on last:true. Sealed routes accept only Sealed frames; ordinary routes reject Sealed frames. agent discards any plaintext sent to sealed session IDs. For authorization, handshake, key derivation, threat model, see End-to-End Sealed Sessions.