Files
2026-03-24 00:26:25 +01:00

922 lines
21 KiB
Markdown

# 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*