# 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 1. [Overview](#overview) 2. [Transport Layer](#transport-layer) 3. [Authentication](#authentication) 4. [Message Envelope](#message-envelope) 5. [Lifecycle](#lifecycle) 6. [Widgets](#widgets) 7. [Commands](#commands) 8. [Events](#events) 9. [Media Channel](#media-channel) 10. [Device Linking](#device-linking) 11. [RBAC](#rbac) 12. [Bridge SDK](#bridge-sdk) 13. [Error Codes](#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 ```bash # 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 ```json { "type": "auth", "id": "msg-001", "payload": { "method": "api_key", "key": "a1b2c3d4e5f6...", "device_id": "CDAP-SENSOR01" } } ``` ### Method 2: User/Password ```json { "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`: ```json { "type": "auth_2fa_required", "id": "msg-002", "payload": { "message": "TOTP code required" } } ``` Client must then send: ```json { "type": "auth_2fa", "id": "msg-003", "payload": { "code": "123456" } } ``` ### Method 3: Device Token For automated enrollment (one-time tokens generated by admin): ```json { "type": "auth", "id": "msg-004", "payload": { "method": "device_token", "token": "enroll-abc123def456", "device_id": "CDAP-NEW01" } } ``` ### Auth Response Success: ```json { "type": "auth_ok", "id": "msg-001", "payload": { "session_id": "sess-xyz789", "expires_in": 86400, "role": "operator", "permissions": ["read", "control", "media"] } } ``` Failure: ```json { "type": "auth_error", "id": "msg-001", "payload": { "code": "AUTH_INVALID_KEY", "message": "Invalid API key" } } ``` --- ## Message Envelope All CDAP messages use a JSON envelope: ```json { "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: ```json { "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: ```json { "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: ```json { "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. ```json { "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. ```json { "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. ```json { "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. ```json { "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. ```json { "id": "firmware_version", "type": "text", "label": "Firmware" } ``` State: `{ "value": "v3.5.2-stable" }` ### Slider Numeric range input with step. ```json { "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. ```json { "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. ```json { "id": "temp_history", "type": "chart", "label": "Temperature History", "chartType": "line", "maxPoints": 60, "unit": "°C" } ``` State: ```json { "points": [ { "t": 1711834500, "v": 42.1 }, { "t": 1711834560, "v": 42.3 }, { "t": 1711834620, "v": 42.0 } ] } ``` ### Table (v3.0) Dynamic sortable data table. ```json { "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: ```json { "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. ```json { "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 ```json { "type": "command", "id": "cmd-001", "payload": { "action": "set", "widget_id": "motor_speed", "value": 2000, "operator": "admin@example.com", "timestamp": 1711834567890 } } ``` ### Client → Server Response ```json { "type": "command_response", "id": "cmd-001", "payload": { "status": "ok", "message": "Motor speed set to 2000 RPM", "applied_value": 2000 } } ``` ### Command Error ```json { "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: ```json { "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 condition - `status_change` — Device state transition - `maintenance` — Scheduled or manual maintenance event - `security` — 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 1. Web client sends `media_offer` through CDAP gateway: ```json { "type": "media_offer", "id": "media-001", "payload": { "target_device": "CDAP-AGENT01", "codecs": ["h264", "vp9"], "resolution": { "width": 1920, "height": 1080 }, "fps": 30 } } ``` 2. Target device responds with `media_answer`: ```json { "type": "media_answer", "id": "media-001", "payload": { "accepted": true, "codec": "h264", "resolution": { "width": 1920, "height": 1080 }, "fps": 30, "encryption": "nacl" } } ``` 3. 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: ```json { "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: ```json { "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: ```json { "id": "factory_reset", "type": "button", "label": "Factory Reset", "dangerous": true, "confirm": true, "permissions": { "execute": "admin" } } ``` --- ## Bridge SDK ### Python SDK ```bash pip install betterdesk-cdap ``` ```python 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*