Wire protocol
This document is the contract between the components. Source of truth for message layouts:
crates/rd-proto/src/bin.rs (binary) and crates/rd-proto/src/control.rs (JSON).
Golden vectors shared by Rust and TypeScript: crates/rd-proto/testvectors/binary.json
(u64 fields are JSON strings; payload_hex is the bytes after the header).
Topology
Section titled “Topology”browser (viewer) --WSS--> Traefik --> rd-gateway :8080 (Authentik forwardAuth + email allowlist + Origin check)rd-agent (Mac/Win) --WSS--> Traefik --> rd-gateway :8081 (per-agent bearer token; no Authentik)- Agents dial OUT. One long-lived control WS per agent:
GET {gateway_url}/ws/agent. - Per viewer session the agent dials a NEW data WS:
GET {gateway_url}/ws/agent/session/{session}. - File channels (browse and transfer files, File transfer) are joined the same way:
viewer
GET /ws/files/{machine}, agentGET /ws/agent/files/{channel}. So are terminal channels (a shell, Terminal): viewerGET /ws/terminal/{machine}, agentGET /ws/agent/terminal/{channel}. - Viewer connects
GET /ws/view/{machine}on the viewer origin. The gateway joins viewer WS ⇄ agent data WS and copies messages unchanged in both directions (it only parses JSON it owns and peeks the first byte of binary frames for input-idle tracking / byte counters). - Hosts (examples): viewer
https://rd.example.com, agentswss://rd-agent.example.com. - Dev:
rd-gateway --devserves both on localhost: viewerhttp://127.0.0.1:8080, agentsws://127.0.0.1:8081.
Agents — header Authorization: Bearer rd1.<machine>.<secret>.
- Gateway hashes the whole token with SHA-256 and compares (constant time) to the device’s digest in
{RD_STATE_DIR}/devices.json(devices added in the portal), else to the hex digest in{RD_AGENT_TOKENS_DIR}/<machine>(default/etc/rd/agents).<machine>from the token must match theregister.machine. - Rate limit: 5 failed auths / minute / client IP (client IP = rightmost
X-Forwarded-Forentry, else peer addr). --dev: any token accepted; machine taken from the token if it has therd1.<machine>.form, else fromregister.
Viewers — the request must carry X-authentik-email ∈ RD_ALLOWED_EMAILS (comma separated,
case-insensitive) and, for WebSocket upgrades, Origin == RD_PUBLIC_ORIGIN. Otherwise 403.
--dev: no header checks; user =dev@localhost.
Agent ⇄ gateway control socket (/ws/agent)
Section titled “Agent ⇄ gateway control socket (/ws/agent)”- Agent →
{"t":"register","machine":"mbp","agent_version":"1.5.0","os":"macOS 26.3 (25D125)","gw_proto":1,"state":{...},"caps":["files"]}caps(optional, added after gw_proto 1 shipped; older gateways ignore it): what the agent supports beyond screen sessions.files: file channels.terminal: terminal channels (Terminal; only while the terminal is enabled in the agent’s config).text: Unicode text input (TEXT, see Keys); without it the viewer does not offer its soft-keyboard button.headless: the agent has no screen and serves only its side channels (a Linux agent, or[terminal] only = true, Terminal); the gateway refuses viewer sessions to it with 4011, and older agents never list it. - Gateway →
{"t":"registered","gw_proto":1}. A newer registration for the same machine replaces the old socket (old one closed with code 4009). - Agent →
{"t":"state", ...AgentState}whenever lock/perms/in_session/secure/disconnected change (checked every 2 s).AgentStateis{"locked":bool,"perms":{"screen":bool,"input":bool},"in_session":bool,"secure":"unavailable"|"starting"|"ready"|"failed","disconnected":bool};secureis the Windows secure-desktop helper (docs/setup-windows.md), absent on older agents and on macOS.disconnected(Windows) means the agent’s Remote Desktop session is disconnected: Windows draws nothing for it, so there is nothing to show until someone reconnects; absent on older agents. - Gateway →
{"t":"session_request","session":"<32 hex>","user":"<email>","client_ip":"<ip>"}. Agent must dial/ws/agent/session/<session>(same bearer token) within 10 s. One session per machine: a new request implicitly ends the previous one. - Either side →
{"t":"session_end","session":"…","reason":"…"}. Gateway →{"t":"files_request","channel":"<32 hex>","user":"<email>","client_ip":"<ip>"}(only to agents with thefilescap): dial/ws/agent/files/<channel>within 10 s. A machine has up to 4 file channels next to its session; they are independent of it (nosession_end, the channel ends when its sockets close). See File transfer. Gateway →{"t":"terminal_request","channel":"<32 hex>","user":"<email>","client_ip":"<ip>"}(only to agents with theterminalcap): dial/ws/agent/terminal/<channel>within 10 s; up to 8 per machine, likewise independent. See Terminal. - Keepalive: the gateway sends WebSocket Ping frames every 15 s on every leg and closes a leg after
45 s without Pong. The agent reconnects its control socket if it receives nothing for 30 s.
Agent reconnect backoff: 1 s → 30 s exponential with ±20 % jitter, reset after a registration.
How the control upgrade’s answer changes that:
- 401/403, and closes 4002 (wrong machine, token replaced or device removed) / 4009 (replaced): a refusal, wait 30 s.
- 429 (failed-login rate limit): wait
Retry-After(≤ 10 min), else 30 s. - Any other HTTP answer (the proxy’s 502/503/504, or 404 when the gateway’s route is gone, while the gateway pod restarts): retry every 1 s (±20 %) for up to 2 min, then exponential.
- No answer (TCP/TLS failure, timeout): exponential.
Updates (rd-agent 1.5.0 and later)
Section titled “Updates (rd-agent 1.5.0 and later)”How agents update themselves is in Updates. Two control messages carry it:
- Agent →
{"t":"update_status","available":{"version":"1.6.0","notes":"## 1.6.0\n\n- …","url":"https://github.com/Thijmen/remoted/releases/tag/agent-v1.6.0"},"can_install":true,"phase":"idle","checked_ms":1729160000000,"last":{"from":"1.5.0","to":"1.5.1","result":"ok","at_ms":1729100000000}}, after everyregisteredand whenever it changes. Empty fields are left out.available: a newer version from the update manifest; absent when up to date, not checked yet, or checks are off.notes: the release notes of every version newer than the running one, newest first, as Markdown (at most 16 KiB).url: the release page.can_install: the agent can install updates itself. When false,cannot_installsays why (for the portal).phase:idle,downloadingorinstalling(the update helper took over; the agent restarts shortly).checkingis defined but not sent: the periodic checks would otherwise make two reports every 6 hours.error: why the last check or update request failed.checked_ms: the last successful check, Unix ms.last: the last update attempt, kept until the next one:from,to,result(ok,rolled_back: the new version did not come back and the old one was put back,failed: nothing was installed),error,at_ms.
- Gateway →
{"t":"update","version":"1.6.0"}: the portal’s Update now button. The gateway sends it only when the agent’s lastupdate_statusoffers exactly that version, withcan_installand no update in progress (POST /api/machines/{machine}/update). The agent reads the manifest again: the version must still be offered, and its file signed for that version; otherwise it reports anerror. Anupdatethat arrives while one is in progress is ignored. The agent reportsdownloadingfirst, downloads and checks the file, and only then ends its session: it closes the data socket with 4010 and sendssession_endwith reasonagent_update(viewers wait until the agent is back and no longer reports an update in progress). Sessions requested while it updates are ended the same way. Asession_endfrom the gateway for that already-ended session is ignored.
The gateway keeps the last update_status while the agent is offline (so the portal can show an
install in progress) and drops it when the agent registers again; the new connection reports
its own.
Compatibility: both messages are additive and gw_proto stays 1. Older gateways ignore
update_status (an unknown t). Older agents never send it, so the gateway never sends them
update. Receivers ignore unknown fields; unknown phase and result values are read as
unknown.
Viewer session (/ws/view/{machine})
Section titled “Viewer session (/ws/view/{machine})”- Viewer connects. Gateway checks auth. If the machine is unknown/offline → close 4003. If another viewer holds the machine, that viewer is closed with 4001 (take-over) and its agent session ended.
- Gateway sends
session_requestto the agent; waits ≤ 10 s for the data WS (else close viewer 4004). - Gateway joins the sockets and sends the viewer
{"t":"session","machine":"mbp","session":"…","agent":{AgentState}}. - Viewer → agent
{"t":"hello","proto":1,"min_proto":1,"viewer":{"ua":"…","dpr":2,"viewport":[1536,750],"decoders":{"avc":true,"hevc":false}}} - Agent → viewer
{"t":"hello","proto":1,"agent":{"os":"macos","version":"1.5.0","displays":[{"id":0,"w_pt":1728,"h_pt":1117,"scale":2.0}]},"caps":["text"]}caps: what this agent supports in the session;textmeans the viewer may sendTEXT(Unicode text from an IME). Older agents send an empty list, and the viewer then hides its soft-keyboard button. (proto = min(maxes); if incompatible agent sends{"t":"error","code":"proto_mismatch"}and closes 4005.) - Viewer →
{"t":"start","width":1728,"height":1118,"fps":60,"codec":"avc","start_kbps":8000,"min_kbps":1000,"max_kbps":20000,"adaptive":true,"audio":false,"display":0}width×heightis a bounding box: the agent always streams the display’s own aspect ratio, scaled to the largest even size that fits in the box (never up; at least 320×200), e.g. 1728×1118 on a 16:9 display gives 1728×972.video_configcarries the size actually sent. Astart/setthat does not parse (e.g. an unknown codec) or that the agent cannot serve is answered with{"t":"error","code":"bad_request","msg":…}; messages with an unknowntare ignored. - Agent →
{"t":"video_config","epoch":1,"codec":"avc1.640033","width":1728,"height":1118,"description":"<base64 avcC>"}then VIDEO frames (first hasFIRST_OF_EPOCH|KEY),{"t":"stats",…}every second,{"t":"state",…}on change,{"t":"error","code":…}on problems. - Viewer sends VIDEO_ACK after each decoder output (or discard), input events,
keyframe_req(see Keyframes below),pause/resume(tab hidden/visible; the agent sends a keyframe on resume by itself, so the viewer need not ask),set(partial params; size/display/codec change ⇒ new epoch ⇒ newvideo_config+ keyframe), andsas(Ctrl+Alt+Del on the Windows secure desktop; the agent answers{"t":"error","code":"sas"}when policy blocks it). - PING/PONG both ways: the agent pings every 500 ms (RTT → flow window); the viewer pings every 1 s
(clock offset). Every PING must be answered immediately with a PONG
(
t_origin_usechoed,t_reply_us= responder clock). - Close codes: 4001 kicked (don’t auto-reconnect), 4003 offline, 4004 agent timeout, 4005 protocol,
4006 session ended by agent, 4008 idle (no input for
RD_SESSION_IDLE_TIMEOUT, default 3600 s), 4009 replaced, 4010 agent updating (it comes back by itself, usually within a minute: keep reconnecting, and treat the 4003 and 4004 that follow while it restarts as part of the update), 4011 not supported by the agent (a file channel to an agent without thefilescap, a terminal to one withoutterminal, a viewer session to one withheadless), 1013 try again later (a fifth file channel, a ninth terminal).
Agent side: the session socket is the only one carrying media. When it closes the agent stops the pipeline, releases all keys/buttons and drops the keep-awake guard.
- Locked host: without the secure-desktop helper the agent never injects input into the
lock screen. While the host is locked (checked at most every 200 ms) every input event except
RELEASE_ALL is dropped; keys/buttons still held when the lock is noticed are released. The
first dropped event of a locked period makes the agent send the viewer
{"t":"state",…,"locked":true}(once per period). Withsecure:"ready"the agent swaps to the helper’s pipeline, shows the real lock screen, and forwards input to the helper instead of dropping it (docs/setup-windows.md). - Capture stopped: if capture stops on its own (permission revoked, display gone, capture
service error) the agent sends
{"t":"error","code":"capture_stopped","msg":…}once (checked every second) and keeps the session open; it is reported again only after capture ran again.
Clocks
Section titled “Clocks”- Agent timestamps:
rd_agent_core::clock::now_us()(monotonic µs since agent start). - Viewer timestamps:
Math.round(performance.now() * 1000). - Viewer clock offset (agent − viewer) from the min-RTT PONG of the last 10 s:
offset = t_reply − (t_origin + t_recv) / 2. capture→paint =(paint_us + offset) − capture_us.
Flow control (agent) — see crates/rd-flow
Section titled “Flow control (agent) — see crates/rd-flow”- Window:
horizon = rtt_min + enc + 40 ms,W_frames = clamp(ceil(fps·horizon) + 1, 2, 16),W_bytes = target_bps/8 · horizon.rtt_min: lowest agent PING RTT (every 500 ms) over the last 10 s; 50 ms until measured.enc: median encoder time (encode_us, VideoToolbox submit → output) of the last 64 non-key frames, clamped tomin(25 ms, 1/fps); 10 ms until measured (0 in the gateway’s synthetic probe). Frames count from admission, so their time in the encoder must fit in the budget too. Time in the agent’s own send queues is deliberately not part of it (it would widen the window exactly when the socket is congested). Instatsaswindow_enc_ms.- Frames still in the encoder count against
W_bytesat the average frame size (target_bps/8/fps).
- Capture callback:
gate.admit()→None= drop before encode. Always admit if nothing is in flight (a keyframe larger thanW_bytescannot deadlock). Paused: admit nothing. - Encoded frames are never dropped (not by the agent, not by the gateway). The queue between the
encoder and the sender holds 32 frames (2 × the largest window), so frames the window admitted
always fit, even while the socket is blocked. If one ever must be dropped, the next frame is
forced IDR (
FrameGate::post_encode_drop). If the encoder drops a frame that was to carry a requested IDR (cancel), the next frame carries it instead. - Viewer ACKs after decoder output (so decoder backlog counts toward the window), cumulative.
A window reset (resume after > 1.5 s with unacknowledged frames) forgets in-flight frames and
rtt_min(notenc) and forces an IDR. - Keyframes are forced for a new stream, on resume, on
keyframe_reqand after a window reset or post-encode drop. Akeyframe_reqis dropped when a keyframe the viewer has not seen yet is already on its way: requested but not yet admitted, in the encoder or the agent’s queues, or handed to the socket less thanrtt_minago (the request crossed it). Otherwise at most one per 500 ms; requests inside the interval are coalesced into one at its end. A static screen produces no captures, so the pipeline pollsFrameGate::refresh_wanted()(a keyframe is wanted, not paused, room in the window; false once a frame is admitted) and re-submits its most recent frame. - Stall: oldest in-flight > 1.5 s → log; > 8 s → agent closes the session (4006), viewer reconnects (not while paused).
- Bitrate: delay-based
BitrateController, ticking every 250 ms:serialization_i = bytes_i · 8 / max(delivery_rate, 1 Mbps)q_i = sent_to_ack_i − rtt_min − serialization_i − decode_i (sent non-key frames)q = median(q_i over the last 500 ms)delivery_rate = bits ACKed in the last 1 s; app_limited = encoded rate (1 s) < 0.7 · targetdrop_ratio = window drops / captured frames, last 1 sq > 150 ms → target = max(min, 0.7 · delivery); hold 2 sq > 50 ms for 2 ticks, or (q ≥ 15 ms and drop_ratio > 10 %),or (q ≥ 5 ms with window drops for 4 ticks)→ target = max(min, min(0.85 · target, 0.95 · delivery)); hold 2 sstartup (first 3 s), q < 15 ms, !app_limited → target · 1.25 per tick (window drops ignored)after the hold, q < 15 ms, !app_limited, no window drop for 2 s→ every 500 ms: target = min(max, 1.06 · target + 200 kbps)qand the drop ratio only use frames sent / captured after the most recent cut, so stale samples cannot cut twice. Cuts never raise the target.- Until the first full second has elapsed, rates are averaged over the elapsed time.
- A change is pushed to the encoder only when it is > 5 % from the last one pushed; in fixed
mode (
adaptive:false) the target stays atstart_kbpsand every change of it is pushed. - Why each refinement exists:
crates/rd-flow/src/bitrate.rs(tuned withtests/sim.rs).
Gateway HTTP surface (viewer port 8080)
Section titled “Gateway HTTP surface (viewer port 8080)”| Route | What |
|---|---|
GET / |
portal (index.html) |
GET /m/{machine} |
viewer page (viewer.html) — the page reads the machine from location.pathname |
GET /probe |
network probe page (probe.html) |
GET /latency-target |
latency target page (latency.html), opened ON the host to be filmed/detected |
GET /keytest |
key test page (keytest.html), opened ON the host |
GET /assets/* |
hashed Vite assets (Cache-Control: public, max-age=31536000, immutable) |
GET /api/me |
{"email":"…","uid":"…","dev":false,"devices":true} (uid may be null) (uid = X-authentik-uid, for RD_ALLOWED_UIDS; devices = Add device available) |
GET /api/machines |
[{"machine":"mbp","name":"MacBook Pro","managed":false,"online":true,"since_ms":1729160000000,"os":"macOS 26.3","agent_version":"1.5.0","state":{AgentState},"viewer":null|{"user":"…","since_ms":…},"recovery":[{"label":"Screen Sharing (VNC)","url":"https://…"}],"update":null|{UpdateStatus},"files":true}] (update: the agent’s last update_status, null when it never sent one; files: the agent supports file channels) |
GET /api/activity |
[{"at_ms":1730386900000,"event":"session_start","machine":"mbp","user":"…"}], newest first, at most 100, kept in memory (empty after a restart); optional user, from, to, duration_ms; events in Gateway |
POST /api/machines/{machine}/update |
JSON {"version":"1.6.0"} → 202 {"ok":true} once update is queued to the agent; 404 unknown machine; 409 {"error":"…"} when the agent is offline, does not report updates, cannot install them, has no or another update available, or is already updating |
POST /api/devices |
JSON {"machine":"studio","name":"Mac Studio"} → 201 {"machine","name","gateway","token","setup_code"} (no-store) |
POST /api/devices/{machine}/token |
JSON {} → 200, same fields; the agent’s control socket is closed with 4002 |
DELETE /api/devices/{machine} |
→ 204; the agent’s control socket is closed with 4002 |
GET /ws/view/{machine} |
viewer session WS |
GET /ws/files/{machine} |
file channel WS (File transfer) |
GET /ws/probe |
probe WS (below) |
POST /api/probe/report |
JSON body (≤ 1 MiB) logged as one line probe_report {...}; → 204 |
GET /api/probe/down?kbps=10000&seconds=10&chunk_ms=20 |
streamed HTTP body; each chunk: [u64 LE server_us][u32 LE seq][filler] sized kbps·chunk_ms/8 bytes |
POST /api/probe/up |
streamed request body; → {"bytes":N,"elapsed_ms":N} |
HTML pages are served with Cache-Control: no-cache. All responses on 8080 carry
Content-Security-Policy: default-src 'self'; connect-src 'self' ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; frame-ancestors 'none'
(ipc: / http://ipc.localhost: Tauri v2 IPC for the desktop app, see docs/desktop-client.md), X-Frame-Options: DENY,
Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Embedder-Policy: require-corp,
X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer.
The viewer build (viewer/dist) must therefore contain: index.html, viewer.html, probe.html,
latency.html, keytest.html, assets/…. The gateway embeds viewer/dist (rust-embed,
allow_missing) and --assets-dir <dir> overrides it at runtime.
Device routes take JSON bodies only (≤ 4 KiB) and answer errors as {"error":"…"}: 400 invalid id/name,
404 unknown device, 409 id exists or has a provisioned token, 503 no RD_STATE_DIR/RD_AGENT_PUBLIC_URL,
500 store not saved. The update route takes JSON only too (≤ 1 KiB). Details: docs/gateway.md.
Setup code
Section titled “Setup code”What Add device hands out and rd-agent setup reads (crates/rd-proto/src/setup_code.rs):
rds1_ + base64url (no padding) of {"gateway":"wss://rd-agent.example.com","token":"rd1.<machine>.<secret>"}.
gateway is ws:// or wss://; the machine id is the token’s. Whitespace inside the code is ignored
(line breaks from copying). The secret of a token the gateway creates is 32 random bytes, base64url.
Agent port 8081: GET /ws/agent, GET /ws/agent/session/{session}, GET /ws/agent/files/{channel}
only (everything else 404).
Metrics port 9090: GET /healthz → ok, GET /metrics (Prometheus text, optional).
Probe socket (/ws/probe)
Section titled “Probe socket (/ws/probe)”- On connect gateway →
{"t":"hello","server_time_us":…,"client_ip":"…","user":"…"}. - Viewer binary PING → gateway PONG immediately (also while a test runs, so RTT-under-load is measured through the same TCP stream).
{"t":"run","test":"down","kbps":10000,"fps":60,"seconds":20,"windowed":false}: gateway sends VIDEO frames (flags=0, epoch=0, seq=n, capture_us=gateway clock, encode_us=0, random payload ofkbps·1000/8/fpsbytes) paced atfps. Viewer sends VIDEO_ACK for each on receipt. Withwindowed:trueframes are gated byrd_flow::FlowGate(+BitrateControlleradaptingkbpsas the max) exactly like the agent. End →{"t":"done","test":"down","elapsed_ms":…,"frames":…,"bytes":…,"window_drops":…}.{"t":"run","test":"up","kbps":1000,"seconds":30}: viewer sends PROBE_UP frames; gateway counts untilsecondselapsed →donewith received totals.{"t":"run","test":"idle","seconds":600}: nothing sent;doneafterseconds(tests idle survival).{"t":"stop"}→ current run ends withdone{stopped:true}.- Limits: kbps ≤ 100000, seconds ≤ 7200, one run at a time per socket.
Gateway config (env / flags)
Section titled “Gateway config (env / flags)”| Env | Default | |
|---|---|---|
RD_VIEWER_ADDR |
0.0.0.0:8080 |
|
RD_AGENT_ADDR |
0.0.0.0:8081 |
|
RD_METRICS_ADDR |
0.0.0.0:9090 |
|
RD_PUBLIC_ORIGIN |
– (required unless --dev) |
e.g. https://rd.example.com |
RD_ALLOWED_EMAILS |
– (required unless --dev) |
comma separated |
RD_ALLOWED_UIDS |
– (optional) | comma separated X-authentik-uid values; when set, a viewer must match both lists |
RD_STATE_DIR |
– | writable dir for devices.json (portal devices); none → Add device off (dev: in memory) |
RD_AGENT_PUBLIC_URL |
– (dev: ws://<agent addr>) |
agent URL put into setup codes, e.g. wss://rd-agent.example.com |
RD_AGENT_TOKENS_DIR |
/etc/rd/agents |
provisioned tokens: one file per machine with hex sha256 |
RD_MACHINES_FILE |
– | JSON [{"machine":"mbp","name":"MacBook Pro","recovery":[{"label":"…","url":"…"}]}] |
RD_SESSION_IDLE_TIMEOUT |
3600 |
seconds without input |
RD_CHAOS |
– | dev only: bw_kbps=5000,delay_ms=50 applied agent→viewer in the join loop |
RUST_LOG |
info |
JSON logs unless --dev |
.rdv recording format
Section titled “.rdv recording format”Header RDV1 (4 bytes), then records until EOF:
[kind u8: 0=text(JSON) 1=binary][t_us u64 LE (agent clock)][len u32 LE][bytes].
Messages are exactly the agent→viewer wire messages (video_config, VIDEO, AUDIO…), so a recording can
be replayed by a fake agent or decoded by the viewer’s /dev/decoder-test.
Wire = USB HID usage (page << 16) | usage. Tables generated from Chromium dom_code_data.inc by
tools/gen-keymap into crates/rd-proto/src/keymap_gen.rs and viewer/src/input/keymap.gen.ts.
Viewer: KeyboardEvent.code → HID. macOS: HID → kVK. Windows: HID → scancode (0xE0xx extended),
injected with SendInput. The table follows the low-level keyboard hook’s convention, which differs
from injection for Num Lock (table E0 45, injected as 45) and Pause (table 45, injected as
VK_PAUSE).
TEXT (0x16, viewer → agent, [0x16][0] then UTF-8). Text committed by an IME or a phone
soft keyboard. The agent types it as Unicode (CGEventKeyboardSetUnicodeString on macOS,
SendInput with KEYEVENTF_UNICODE on Windows), so it is independent of the host’s keyboard
layout and works for accents, emoji and CJK. Shortcuts and named keys (Enter, arrows, …) stay
on the KEY path: TEXT only carries what an IME committed. Agents advertise the text cap; old
agents ignore the message, so the viewer does not send it to them.