21 KiB
CDAP — Custom Device API Protocol
Protocol Version: 1.0
Server Version: BetterDesk v3.0.0
Status: Production-ready (Phases 0-7 implemented)
Table of Contents
- Overview
- Transport Layer
- Authentication
- Message Envelope
- Lifecycle
- Widgets
- Commands
- Events
- Media Channel
- Device Linking
- RBAC
- Bridge SDK
- Error Codes
Overview
CDAP (Custom Device API Protocol) is a WebSocket-based protocol that connects non-RustDesk devices to the BetterDesk ecosystem. It provides:
- Real-time state synchronization — Devices push state updates; panel renders widgets
- Bidirectional commands — Operators send commands to devices; devices respond with results
- Media relay — Binary frame channel for remote desktop sessions (E2E encrypted)
- Device management — Enrollment, revocation, linking, grouping
- Bridge ecosystem — SDKs for Modbus TCP, SNMP, REST webhooks, and custom protocols
When to Use CDAP
| Scenario | Use CDAP? | Alternative |
|---|---|---|
| IoT sensor dashboard | ✅ Yes | — |
| SCADA/PLC monitoring | ✅ Yes | — |
| Network device management | ✅ Yes | — |
| Remote desktop (existing) | ❌ No | RustDesk client |
| Remote desktop (CDAP agent) | ✅ Yes | BetterDesk native agent |
| Custom automation agent | ✅ Yes | — |
Transport Layer
WebSocket Connection
ws://host:21122 (plain)
wss://host:21122 (TLS)
The CDAP gateway uses dual-mode listening — auto-detects TLS (first byte 0x16) and plain connections on the same port. No separate TLS port needed.
TLS Configuration
# Enable TLS on CDAP gateway
betterdesk-server --tls-cert /path/to/cert.pem --tls-key /path/to/key.pem
# CDAP auto-detects TLS when cert/key are provided
# Both plain and TLS connections accepted on port 21122
Connection Parameters
| Parameter | Default | Description |
|---|---|---|
CDAP_PORT |
21122 | Gateway listen port |
CDAP_MAX_CONNS |
10000 | Maximum concurrent connections |
CDAP_READ_LIMIT |
1048576 | Maximum message size (1 MiB) |
CDAP_PING_INTERVAL |
30s | WebSocket ping interval |
CDAP_PONG_TIMEOUT |
10s | Pong response deadline |
CDAP_WRITE_TIMEOUT |
10s | Write deadline per message |
Reconnection
Clients should implement exponential backoff reconnection:
Attempt 1: wait 1s
Attempt 2: wait 2s
Attempt 3: wait 4s
Attempt N: wait min(2^(N-1), 60)s
On reconnect, clients must re-authenticate and re-register. The server preserves device state for 5 minutes after disconnection.
Authentication
Authentication must be the first message after WebSocket upgrade. The server closes the connection after 10 seconds without auth.
Method 1: API Key
{
"type": "auth",
"id": "msg-001",
"payload": {
"method": "api_key",
"key": "a1b2c3d4e5f6...",
"device_id": "CDAP-SENSOR01"
}
}
Method 2: User/Password
{
"type": "auth",
"id": "msg-002",
"payload": {
"method": "user_password",
"username": "operator1",
"password": "secure-password",
"device_id": "CDAP-AGENT01"
}
}
If the user has TOTP 2FA enabled, the server responds with auth_2fa_required:
{
"type": "auth_2fa_required",
"id": "msg-002",
"payload": {
"message": "TOTP code required"
}
}
Client must then send:
{
"type": "auth_2fa",
"id": "msg-003",
"payload": {
"code": "123456"
}
}
Method 3: Device Token
For automated enrollment (one-time tokens generated by admin):
{
"type": "auth",
"id": "msg-004",
"payload": {
"method": "device_token",
"token": "enroll-abc123def456",
"device_id": "CDAP-NEW01"
}
}
Auth Response
Success:
{
"type": "auth_ok",
"id": "msg-001",
"payload": {
"session_id": "sess-xyz789",
"expires_in": 86400,
"role": "operator",
"permissions": ["read", "control", "media"]
}
}
Failure:
{
"type": "auth_error",
"id": "msg-001",
"payload": {
"code": "AUTH_INVALID_KEY",
"message": "Invalid API key"
}
}
Message Envelope
All CDAP messages use a JSON envelope:
{
"type": "message_type",
"id": "unique-message-id",
"payload": { ... },
"ts": 1711834567890
}
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | ✅ | Message type identifier |
id |
string | ✅ | Unique message ID (for request/response correlation) |
payload |
object | ✅ | Message-specific data |
ts |
number | ❌ | Unix timestamp in milliseconds |
Message Types (Client → Server)
| Type | Description |
|---|---|
auth |
Authentication request |
auth_2fa |
TOTP 2FA code |
register |
Device registration with manifest |
state_update |
Widget state push |
command_response |
Response to server command |
heartbeat |
Keep-alive with optional metrics |
media_offer |
SDP offer for media channel |
media_answer |
SDP answer for media channel |
media_frame |
Binary frame (uses binary WebSocket frames) |
event |
Client-originated event |
Message Types (Server → Client)
| Type | Description |
|---|---|
auth_ok |
Authentication success |
auth_error |
Authentication failure |
auth_2fa_required |
TOTP required |
registered |
Registration acknowledged |
command |
Command from operator |
state_request |
Server requests full state refresh |
media_offer |
SDP offer from peer |
media_answer |
SDP answer from peer |
config_update |
Server configuration change |
error |
Generic error |
ping |
Application-level ping |
Lifecycle
Connection Flow
Client Server
│ │
│──── WebSocket Upgrade ────────────>│
│<─── 101 Switching Protocols ───────│
│ │
│──── auth {method, credentials} ───>│
│<─── auth_ok {session, role} ───────│
│ │
│──── register {manifest} ──────────>│
│<─── registered {ack} ─────────────│
│ │
│──── state_update {widgets} ───────>│ (periodic)
│<─── command {action, params} ──────│ (on-demand)
│──── command_response {result} ────>│
│ │
│──── heartbeat {metrics} ──────────>│ (every 30s)
│<─── pong ─────────────────────────│
│ │
│──── close ────────────────────────>│
│<─── close ─────────────────────────│
Registration (Manifest)
The manifest describes the device's capabilities:
{
"type": "register",
"id": "msg-010",
"payload": {
"manifest": {
"name": "Factory Floor Controller",
"device_type": "scada",
"version": "2.1.0",
"firmware": "PLC-FW-3.5.2",
"capabilities": ["widgets", "commands", "media"],
"categories": [
{
"id": "temperature",
"label": "Temperature Sensors",
"icon": "thermostat"
},
{
"id": "actuators",
"label": "Actuators",
"icon": "settings"
}
],
"widgets": [
{
"id": "temp_zone1",
"type": "gauge",
"label": "Zone 1 Temperature",
"category": "temperature",
"unit": "°C",
"min": -10,
"max": 100,
"thresholds": {
"warning": 60,
"danger": 85
},
"permissions": {
"read": "viewer"
}
},
{
"id": "motor_speed",
"type": "slider",
"label": "Motor Speed",
"category": "actuators",
"unit": "RPM",
"min": 0,
"max": 3000,
"step": 50,
"permissions": {
"read": "viewer",
"control": "operator"
}
},
{
"id": "emergency_stop",
"type": "button",
"label": "Emergency Stop",
"category": "actuators",
"confirm": true,
"confirmMessage": "Are you sure you want to trigger emergency stop?",
"dangerous": true,
"permissions": {
"read": "viewer",
"execute": "admin"
}
}
],
"commands": [
{
"id": "reboot",
"label": "Reboot Device",
"confirm": true,
"permissions": {
"execute": "admin"
}
},
{
"id": "calibrate",
"label": "Calibrate Sensors",
"params": [
{
"id": "zone",
"type": "select",
"label": "Zone",
"options": ["zone1", "zone2", "zone3"]
}
],
"permissions": {
"execute": "operator"
}
}
]
}
}
}
State Updates
Devices push widget state periodically or on change:
{
"type": "state_update",
"id": "msg-020",
"payload": {
"widgets": {
"temp_zone1": {
"value": 42.5,
"status": "normal"
},
"motor_speed": {
"value": 1500
},
"emergency_stop": {
"active": false
}
}
}
}
Heartbeat
Sent every 30s (configurable) with optional system metrics:
{
"type": "heartbeat",
"id": "msg-030",
"payload": {
"uptime": 86400,
"cpu": 23.5,
"memory": 67.2,
"disk": 45.0,
"custom_metrics": {
"queue_depth": 42,
"error_rate": 0.01
}
}
}
Widgets
Toggle
Boolean on/off switch.
{
"id": "relay1",
"type": "toggle",
"label": "Main Relay",
"category": "actuators"
}
State: { "value": true }
Command: { "action": "set", "widget_id": "relay1", "value": false }
Gauge
Numeric value with thresholds and optional unit.
{
"id": "pressure",
"type": "gauge",
"label": "Pressure",
"unit": "bar",
"min": 0,
"max": 10,
"decimals": 2,
"thresholds": {
"warning": 7.5,
"danger": 9.0
}
}
State: { "value": 5.23 }
Button
Action trigger with optional confirmation dialog.
{
"id": "reset_counters",
"type": "button",
"label": "Reset Counters",
"icon": "restart_alt",
"confirm": true,
"confirmMessage": "Reset all production counters to zero?",
"cooldown": 5000
}
State: { "active": false, "lastTriggered": "2026-03-20T10:30:00Z" }
Command: { "action": "execute", "widget_id": "reset_counters" }
LED
Status indicator with color states.
{
"id": "connection_status",
"type": "led",
"label": "PLC Connection",
"states": {
"green": "Connected",
"yellow": "Reconnecting",
"red": "Disconnected"
}
}
State: { "color": "green", "label": "Connected" }
Text
Read-only text display.
{
"id": "firmware_version",
"type": "text",
"label": "Firmware"
}
State: { "value": "v3.5.2-stable" }
Slider
Numeric range input with step.
{
"id": "brightness",
"type": "slider",
"label": "LED Brightness",
"unit": "%",
"min": 0,
"max": 100,
"step": 5
}
State: { "value": 75 }
Command: { "action": "set", "widget_id": "brightness", "value": 50 }
Select
Dropdown selection.
{
"id": "operating_mode",
"type": "select",
"label": "Operating Mode",
"options": [
{ "value": "auto", "label": "Automatic" },
{ "value": "manual", "label": "Manual" },
{ "value": "maintenance", "label": "Maintenance" }
]
}
State: { "value": "auto" }
Command: { "action": "set", "widget_id": "operating_mode", "value": "manual" }
Chart
Time-series or categorical chart data.
{
"id": "temp_history",
"type": "chart",
"label": "Temperature History",
"chartType": "line",
"maxPoints": 60,
"unit": "°C"
}
State:
{
"points": [
{ "t": 1711834500, "v": 42.1 },
{ "t": 1711834560, "v": 42.3 },
{ "t": 1711834620, "v": 42.0 }
]
}
Table (v3.0)
Dynamic sortable data table.
{
"id": "alarm_log",
"type": "table",
"label": "Active Alarms",
"columns": [
{ "id": "time", "label": "Time", "type": "datetime" },
{ "id": "severity", "label": "Severity", "type": "badge" },
{ "id": "message", "label": "Message", "type": "text" },
{ "id": "ack", "label": "Acknowledge", "type": "action" }
],
"sortable": true,
"pagination": true,
"pageSize": 20
}
State:
{
"rows": [
{
"id": "alarm-001",
"time": "2026-03-20T10:30:00Z",
"severity": { "value": "critical", "color": "red" },
"message": "Zone 3 temperature exceeded 85°C",
"ack": { "label": "Acknowledge", "action": "ack_alarm", "params": { "id": "alarm-001" } }
}
],
"total": 42
}
Terminal (v3.0)
WebSocket shell relay for device management.
{
"id": "shell",
"type": "terminal",
"label": "Device Shell",
"permissions": {
"read": "operator",
"control": "admin"
}
}
Terminal widget uses a separate binary WebSocket channel for stdin/stdout/stderr.
Commands
Server → Client Command
{
"type": "command",
"id": "cmd-001",
"payload": {
"action": "set",
"widget_id": "motor_speed",
"value": 2000,
"operator": "admin@example.com",
"timestamp": 1711834567890
}
}
Client → Server Response
{
"type": "command_response",
"id": "cmd-001",
"payload": {
"status": "ok",
"message": "Motor speed set to 2000 RPM",
"applied_value": 2000
}
}
Command Error
{
"type": "command_response",
"id": "cmd-001",
"payload": {
"status": "error",
"code": "DEVICE_BUSY",
"message": "Motor is in calibration mode, cannot change speed"
}
}
Events
Devices can emit events for audit logging and alerting:
{
"type": "event",
"id": "evt-001",
"payload": {
"event_type": "alarm",
"severity": "critical",
"message": "Zone 3 temperature exceeded 85°C",
"data": {
"zone": 3,
"temperature": 87.2,
"threshold": 85
}
}
}
Event types:
alarm— Threshold violation or abnormal conditionstatus_change— Device state transitionmaintenance— Scheduled or manual maintenance eventsecurity— Security-related event (auth failure, tamper detection)custom— Application-specific event
Media Channel
The media channel provides binary frame relay between CDAP devices and web clients for remote desktop sessions.
Negotiation
- Web client sends
media_offerthrough CDAP gateway:
{
"type": "media_offer",
"id": "media-001",
"payload": {
"target_device": "CDAP-AGENT01",
"codecs": ["h264", "vp9"],
"resolution": { "width": 1920, "height": 1080 },
"fps": 30
}
}
- Target device responds with
media_answer:
{
"type": "media_answer",
"id": "media-001",
"payload": {
"accepted": true,
"codec": "h264",
"resolution": { "width": 1920, "height": 1080 },
"fps": 30,
"encryption": "nacl"
}
}
- Binary frames flow through the gateway:
- Device sends video frames as binary WebSocket messages
- Client sends input events (mouse, keyboard) as JSON commands
- Gateway relays without decryption (E2E between client and device)
Frame Encryption
Media frames use the same NaCl encryption as RustDesk:
- Key exchange: X25519 Diffie-Hellman
- Encryption: XSalsa20-Poly1305
- Frame format:
[24-byte nonce][encrypted payload]
Device Linking
CDAP devices can be linked to RustDesk peers for unified management:
{
"type": "register",
"id": "msg-050",
"payload": {
"manifest": {
"name": "Server Room Agent",
"device_type": "os_agent",
"linked_peer_id": "1340238749",
"version": "1.0.0",
"widgets": [ ... ]
}
}
}
Linked devices appear in the same device detail page. The panel shows:
- RustDesk tab: Remote desktop, file transfer
- CDAP tab: Widgets, commands, metrics
- Combined connection status
RBAC
Per-Widget Permissions
Each widget can specify minimum role requirements:
{
"permissions": {
"read": "viewer",
"control": "operator",
"execute": "admin"
}
}
Permission types:
read— View widget state (default:viewer)control— Change widget value (sliders, toggles, selects)execute— Trigger actions (buttons, commands)
Dangerous Widgets
Widgets marked "dangerous": true are hidden from non-admin users and require explicit confirmation:
{
"id": "factory_reset",
"type": "button",
"label": "Factory Reset",
"dangerous": true,
"confirm": true,
"permissions": {
"execute": "admin"
}
}
Bridge SDK
Python SDK
pip install betterdesk-cdap
from betterdesk_cdap import CDAPBridge, Widget, WidgetType
bridge = CDAPBridge(
server="ws://betterdesk.example.com:21122",
api_key="your-api-key",
device_id="BRIDGE-MODBUS01"
)
bridge.register(
name="Modbus Gateway",
device_type="scada",
widgets=[
Widget("coil_0", WidgetType.TOGGLE, "Output Coil 0"),
Widget("register_0", WidgetType.GAUGE, "Holding Register 0",
unit="mA", min=0, max=20, thresholds={"warning": 16, "danger": 19}),
]
)
@bridge.on_command("set", "coil_0")
async def handle_coil(value: bool):
# Write to Modbus device
await modbus_client.write_coil(0, value)
return {"status": "ok"}
bridge.run() # Starts event loop
Reference Bridges
| Bridge | Protocol | Status |
|---|---|---|
betterdesk-bridge-modbus |
Modbus TCP/RTU | v3.0 |
betterdesk-bridge-snmp |
SNMP v2c/v3 | v3.0 |
betterdesk-bridge-rest |
REST webhook | v3.0 |
betterdesk-bridge-mqtt |
MQTT 3.1.1/5.0 | Planned |
betterdesk-bridge-opcua |
OPC UA | Planned |
Error Codes
| Code | HTTP Equiv | Description |
|---|---|---|
AUTH_INVALID_KEY |
401 | Invalid API key |
AUTH_INVALID_CREDENTIALS |
401 | Wrong username/password |
AUTH_2FA_REQUIRED |
401 | TOTP code needed |
AUTH_2FA_INVALID |
401 | Wrong TOTP code |
AUTH_TOKEN_EXPIRED |
401 | Device token expired |
AUTH_TOKEN_REVOKED |
401 | Device token revoked |
AUTH_RATE_LIMITED |
429 | Too many auth attempts |
DEVICE_ALREADY_REGISTERED |
409 | Device ID already connected |
DEVICE_BANNED |
403 | Device is banned |
DEVICE_REVOKED |
403 | Device has been revoked |
DEVICE_SOFT_DELETED |
403 | Device has been soft-deleted |
PERMISSION_DENIED |
403 | Insufficient role for action |
INVALID_MANIFEST |
400 | Manifest validation failed |
INVALID_COMMAND |
400 | Unknown command or missing params |
DEVICE_NOT_FOUND |
404 | Target device not connected |
DEVICE_BUSY |
503 | Device cannot process command |
MEDIA_REJECTED |
403 | Media channel rejected by device |
MEDIA_CODEC_UNSUPPORTED |
406 | No common codec found |
INTERNAL_ERROR |
500 | Server internal error |
GATEWAY_DISABLED |
503 | CDAP gateway not enabled |
Last updated: March 2026 — BetterDesk v3.0.0