Device control
protocol.

Devices live behind NAT on networks you don't administer, so the device dials the control plane and never the other way round. Everything an operator can do — updates, console, screenshots, a shell — rides down that one connection.

Working — implemented and in production

Overall shape

One persistent outbound WebSocket per device, opened by the device, held open indefinitely and reconnected forever with backoff if it drops. The device opens no listening socket, so nothing on the customer's LAN — and nothing on the internet — can reach it directly.

topology
  operator                control plane              device (behind NAT)
     │                          │                          │
     │  ── request ──────────▶  │                          │
     │                          │  ── command ──────────▶  │   (existing socket)
     │                          │  ◀──────── result ─────  │
     │  ◀──────────── result ─  │                          │
     │                          │  ◀──── telemetry ──────  │
     │                          │  ◀──── app records ────  │

The consequence worth internalising: a device that has never successfully connected can still run its application. Everything below is management, not operation.

Control channel

  • Transport: WebSocket over TLS to the control plane endpoint.
  • Frames are text, formatted as command|payload, split on the first | only — the payload may itself contain pipes.
  • payload is a JSON object, or empty.
  • Reconnect backoff climbs 250 ms → 1 s → 30 s → 60 s → 5 min and then holds. The device never gives up.
  • Read limit is 32 MiB per frame; keep downlink frames well under it.
  • The control plane sends ping periodically; the device replies pong. Missed pongs mark it offline.
  • On clean shutdown the device sends bye|<reason> and closes.
framing
eval|{"id":"abc","code":"1+1"}
pong|
bye|shutting down

Handshake

The device sends init immediately on every connect and reconnect. Receiving it is what binds the socket to a device ID; if that ID already had a socket, the old one is replaced.

init payload
{
  "deviceId":   "a1b2c3d4e5f6a1b2c3d4e5f6",
  "version":    "0.1.1",
  "device":     "generic-x86_64",
  "os":         "linux",
  "arch":       "amd64",
  "cpus":       4,
  "memMB":      2048,
  "kiosk":      true,
  "registered": false
}

deviceId is derived from the machine — a hash of the hardware and machine identity, stable across reboots and across application updates. It changes only when the OS is reinstalled or the machine identity is reset. It is the primary key for everything else.

CommandPayloadMeaning
initsee aboveIdentity and handshake, on every connect.
pongReply to ping.
byeplain text reasonClean disconnect.
sysinfo{ os, cpu, mem, disk, gpu, net, usb, printers }Hardware snapshot, on connect and on USB change.
res{ id, cmd, … }Successful result of an operator command.
err{ id, cmd, error }Failed result of an operator command.
console{ level, text, at }A console line, while capture is on.
txn{ id, data }A record from arda.sync.push().
get{ id, resource, params }A request from arda.sync.get().
updateStatus{ version, status, detail }Outcome of an update. status ∈ staged · applied · error.
CommandPayloadDevice action
pingReplies pong.
screenshot{ id, format?, quality? }Replies with a base64 image. Formats: png, jpeg, webp, avif.
watch{ id, format?, quality?, interval? }Streams frames on screen change. Minimum interval 500 ms.
watchStop{ id }Stops that watch.
dom{ id }Replies with the rendered HTML and styles.
eval{ id, code }Evaluates JavaScript in the live page, replies with the value.
exec{ id, cmd }Runs a shell command, replies with output and exit code. 30 s timeout.
captureConsoleStarts streaming console lines.
captureConsoleStopStops console streaming.
tunnel{ token, target? }Opens the reverse tunnel.
update{ kind, url, version, sha256, sig? }Downloads, verifies and applies an update.
get{ id, ok, data?, error? }Response to a device get request.
txnack{ id }Acknowledges a record; the device drops it from its queue.
configencrypted blobPushes new device configuration.
i
One verb, two directions get is a request when the device sends it and a response when the control plane sends it. Direction disambiguates. Unknown commands are ignored by the device rather than treated as errors, so newer control planes stay compatible with older runtimes.

Updates

The control plane never pushes bytes. It names an artefact and the device fetches it.

update instruction
update|{
  "kind":    "package",          // package | binary
  "url":     "https://…/r-241.arda",
  "version": "1.4.0",
  "sha256":  "4f9c…a10e",
  "sig":     "base64…"          // required for kind: binary
}
KindDevice behaviour
packageDownload → check SHA-256 → verify it is a valid .arda by fully verifying the signature and decrypting it → write to the staging directory → report staged. The swap into the live slot happens at a safe moment.
binaryDownload → check SHA-256 → verify the Ed25519 signature against the embedded release public key → atomically replace the running executable, keeping a .old for rollback → re-exec → report applied.

Artefacts are capped at 256 MiB. Any verification failure discards the download, leaves the running application untouched and reports error with a detail string.

Application bridge

The sandboxed page has no network access of its own. arda.sync.* calls are translated by the runtime into control-channel messages — these are the only paths between your application and your server.

push → txn

arda.sync.push(record) becomes txn|{ id, data }. The device persists every record to its encrypted queue before the promise resolves and re-sends the whole queue on each reconnect. Every record must be acknowledged with txnack|{ id }; until then the device keeps it and will send it again.

!
Deduplicate by ID Delivery is at-least-once, not exactly-once. A record acknowledged by a server whose acknowledgement never arrives will be re-sent. Your server must treat the record ID as an idempotency key.

get → get

arda.sync.get(resource, params) becomes get|{ id, resource, params }. Answer with get|{ id, ok: true, data }, or get|{ id, ok: false, error }. The data value is delivered to the application's promise as-is. For images, return a data URL or base64 string — the page's content security policy permits img-src data:, so it still makes no network request of its own.

Reverse tunnel

Gets an operator a real shell on a device behind NAT, using only the device's outbound connectivity.

path
operator ── ssh ──▶ control plane:22NN ──(multiplexed over wss)──▶ device ──▶ 127.0.0.1:22
  1. An operator requests shell access to a device.
  2. The control plane mints a short-lived, single-use token bound to that device and sends tunnel|{ token } on the existing control socket.
  3. The device dials the tunnel endpoint with that token. The control plane validates it and maps the socket to the device.
  4. A stream multiplexer runs over the socket — the device is the multiplexer server, the control plane the client. All tunnel traffic is binary frames.
  5. Each operator TCP connection opens a new multiplexed stream, which the device forwards to its local sshd.
  6. Authentication is the device's own sshd. ArdaForm carries the bytes; it does not replace your SSH key management.

One command grants one session. Closing the socket tears everything down. Tokens are minted per request with a short TTL, so a leaked token is worth very little for very long.

Registration

A fresh device reports registered: false in its handshake and displays an activation code. An operator claims it — from the portal or with arda claim — which associates the device ID with an account, a fleet and a group. Registration state is held by the control plane keyed on device ID; the device's local flag is only a hint for what to show on screen.

Activation is validated against server time. A device never uses its own clock to decide whether it is activated, because the clock on an appliance that has been unplugged for a month is not evidence of anything.

A typical session

timeline
device →  init|{deviceId:X, registered:true, version:0.1.1}
device →  sysinfo|{os:…, cpu:…, usb:[…]}
plane  →  ping|                              // every 30s
device →  pong|
                                             // … application runs …
device →  txn|{id:"X-1699", data:{type:"sale", total:1299}}
plane  →  txnack|{id:"X-1699"}
device →  get|{id:"g1", resource:"products", params:{}}
plane  →  get|{id:"g1", ok:true, data:[…]}
                                             // … operator investigates …
plane  →  captureConsole|
device →  console|{level:"warn", text:"printer timeout", at:…}
plane  →  screenshot|{id:"s1", format:"webp", quality:70}
device →  res|{id:"s1", cmd:"screenshot", data:"…"}
                                             // … new release …
plane  →  update|{kind:"package", url:…, sha256:…}
device →  updateStatus|{version:"1.4.0", status:"staged"}
device →  (restart) init|{version:0.1.1, …}