diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bd4907da..959036ba 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -541,6 +541,36 @@ sudo apt-get install -y build-essential libsqlite3-dev pkg-config libssl-dev git 156. [x] **Relay diagnostic logging**: Added `log.Printf` with UUID and relay server in `handleRequestRelayTCP` and `handleRequestRelay` (UDP) return paths for better relay pairing diagnostics. 157. [x] **Docker GHCR "denied" error (Issue #67)**: Pre-built images on `ghcr.io/unitronix/betterdesk-*:latest` not available — workflow never triggered or packages are private. Added troubleshooting section to `DOCKER_QUICKSTART.md` (3 solutions: build locally, trigger workflow, authenticate). Added fallback comment to `docker-compose.quick.yml`. Added package visibility reminder to CI workflow summary step. +#### CDAP v0.2.0 — Device Revocation & Schema (Phase 28) ✅ COMPLETED 2026-03-20 +158. [x] **CDAP schema columns**: Added `device_type TEXT DEFAULT ''` and `linked_peer_id TEXT DEFAULT ''` to `peers` table in both SQLite (`db/sqlite.go`) and PostgreSQL (`db/postgres.go`) via automatic column migration (v2.5.0). Updated all SELECT/Scan queries (GetPeer, ListPeers, ListPeersByTag, ChangePeerID) and `UpdatePeerFields` allowed keys. +159. [x] **GetLinkedPeers**: New `GetLinkedPeers(id string) ([]*Peer, error)` method on Database interface + both implementations. Queries peers where `linked_peer_id = id`. +160. [x] **Device revocation endpoint**: Enhanced `DELETE /api/peers/{id}` with `?revoke=true` (auto BlockID + disconnect active connections) and `?cascade=true` (delete all linked devices). Publishes `EventPeerRevoked` event and logs `ActionPeerRevoked` audit action. +161. [x] **Connection teardown on Remove**: `peer.Entry.CloseConnections()` method closes TCP and WebSocket connections. Called from `peer.Map.Remove()` and `CleanExpired()` — revoked devices are disconnected immediately. +162. [x] **Panel revocation UI**: Delete modal in `devices.js` includes "Revoke device" checkbox with hint text. Routes through `devices.routes.js` → `serverBackend.js` → `betterdeskApi.js` with `revoke`/`cascade` query params. +163. [x] **i18n keys**: Added `revoke_option`, `revoke_hint`, `revoke_success` to EN, PL, ZH translation files. +164. [x] **Deployed & verified**: Binary deployed to production server (PostgreSQL backend). Automatic migration confirmed — `device_type` and `linked_peer_id` columns present. API returns peers correctly. 5 devices online, 53 total. + +#### CDAP v0.3.0 — Panel Widget Rendering (Phase 29) ✅ COMPLETED 2026-03-20 +165. [x] **cdap/api.go**: REST-helper methods on Gateway — `GetDeviceInfo()`, `GetDeviceManifest()`, `GetDeviceWidgetState()`, `IsConnected()`, `SendCommandJSON()`, `ListConnectedDevices()`. New `DeviceInfo` struct for REST responses. +166. [x] **api/cdap_handlers.go**: 6 HTTP handlers — `handleCDAPStatus`, `handleCDAPListDevices`, `handleCDAPDeviceInfo`, `handleCDAPDeviceManifest`, `handleCDAPDeviceState`, `handleCDAPSendCommand`. Uses `commandCounter atomic.Int64` for unique command IDs. Returns 503 when CDAP disabled. +167. [x] **api/server.go CDAP integration**: Added `cdapGw` field, `SetCDAPGateway()` method, 6 CDAP mux routes. `CDAPConnected` bool in `peerResponse` for both `handleListPeers` and `handleGetPeer`. CDAP overlay: if device connected via CDAP but not signal, shown as online. +168. [x] **main.go CDAP wiring**: Gateway created before API server, `SetCDAPGateway()` called before `Start()`, gateway started after API. +169. [x] **betterdeskApi.js CDAP methods**: 6 async methods — `getCDAPStatus`, `getCDAPDevices`, `getCDAPDeviceInfo`, `getCDAPDeviceManifest`, `getCDAPDeviceState`, `sendCDAPCommand`. +170. [x] **cdap.routes.js**: Page route `GET /cdap/devices/:id` + 6 API proxy routes. Uses `requireAuth` + `requireRole('operator')` for command sending. +171. [x] **routes/index.js**: Registered `cdapRoutes` as `router.use('/', cdapRoutes)`. +172. [x] **cdap-device.ejs**: Device detail page with header (name, type, version, uptime, status), offline banner, widget grid, empty state, command log panel. +173. [x] **cdap-widgets.js**: Widget renderer supporting 8 types (toggle, gauge, button, led, text, slider, select, chart). State polling every 3s. Info polling every 10s. User interaction guard (`_userInteracting` flag) prevents state overwrite during input. Grouped by category. +174. [x] **cdap-commands.js**: Command sender with per-widget cooldown (1s), confirmation dialog integration, command log (max 50 entries), toast notifications. +175. [x] **cdap.css**: Full widget styling — grid layout, toggle switch, gauge bar with danger/warning thresholds, LED indicator, slider with range labels, select dropdown, chart bars, command log panel. Responsive breakpoints. Dark theme CSS variables. +176. [x] **i18n keys**: 22 CDAP keys added to EN, PL, ZH translation files (device_detail, loading, connected, disconnected, widgets, commands, etc.). +177. [x] **Deployed & verified**: Go binary + Node.js files deployed. Server running, console active. CDAP routes return 302 (auth redirect) for unauthenticated, 401 for API without key — both correct. + +#### Devices Page UI Redesign (Phase 30) ✅ COMPLETED 2026-03-20 +178. [x] **devices.ejs rewrite**: Removed 280px sidebar layout. New single-column layout with horizontal scrollable folder chips (`.folder-chip` buttons), unified toolbar (search + segmented filter pills + column visibility toggle), slim table with 7 columns (id, hostname, device_type, platform, last_online, status, actions), kebab menu (`more_vert` icon) replacing 5 inline action buttons, mobile bottom sheet overlay for phone kebab menu. +179. [x] **devices.css rewrite**: ~780 lines. 4 responsive breakpoints: ≤1024px (hide device_type), ≤768px (hide platform+last_online, full-width search, icon-only buttons), ≤600px (card-style rows via CSS grid 2-col, hidden thead, fixed bottom sheet kebab with overlay), ≤400px (chip labels hidden, compact filters). Folder chip styles with hover-reveal edit/delete actions. Kebab dropdown with color-coded menu items. +180. [x] **devices.js updates**: `renderDevices()` outputs new HTML template with `.device-status-dot`, `.kebab-wrapper`/`.kebab-btn`/`.kebab-menu`. `renderFolders()` changed from `.folder-item` divs to `.folder-chip` buttons with `.chip-action` edit/delete. `attachRowEventListeners()` handles kebab toggle + menu item actions. Added `initKebabGlobalClose()` + `closeAllKebabMenus()`. Updated all selectors: `.folder-item` → `.folder-chip` in `selectFolder()`, `updateFolderCounts()`, `initFolders()`, `attachFolderDropEvents()`. Double-click guard updated from `.action-btn`/`.drag-handle` to `.kebab-wrapper`. +181. [x] **Deployed & verified**: All 3 files deployed to production server. Console returns 302 (service running). Responsive layout active. + --- ## 🔄 System Statusu v3.0 @@ -810,4 +840,4 @@ All code changes MUST include a security review as part of the implementation pr --- -*Ostatnia aktualizacja: 2026-03-19 (Go Server — ForceRelay UUID Fix & Docker GHCR — Phase 27) przez GitHub Copilot* +*Ostatnia aktualizacja: 2026-03-20 (Devices Page UI Redesign — Phase 30) przez GitHub Copilot* diff --git a/.gitignore b/.gitignore index 3dbd1280..e76a4a76 100644 --- a/.gitignore +++ b/.gitignore @@ -177,3 +177,4 @@ tasks/todo.md docs/SECURITY_AUDIT_FULL_2026-03-19.md .github/copilot-instructions.md .github/copilot-instructions.md +.github/copilot-instructions.md diff --git a/README.md b/README.md index 6ec35756..f116edfb 100644 --- a/README.md +++ b/README.md @@ -370,7 +370,7 @@ The web console (`web-nodejs/`) is an Express.js application providing a full-fe ### Features - **Dashboard** — Real-time statistics cards (total, active, inactive, banned devices) -- **Device management** — Search, filter, sort, add notes, ban/unban, change ID +- **Device management** — Responsive devices page with horizontal folder chips, unified toolbar with segmented filters, slim table, and kebab context menu (⋮). Four breakpoints: desktop, tablet (≤768px), phone card layout (≤600px), small phone (≤400px) - **Device details** — Hardware tab (sysinfo), metrics tab (live CPU/RAM/disk bars + history charts) - **TOTP 2FA** — Two-factor authentication with `otplib` - **RBAC** — Admin, Operator, Viewer roles with permission enforcement @@ -1290,7 +1290,7 @@ RustDesk clients support end-to-end encryption for remote desktop sessions. Bett | Component | Technology | |-----------|-----------| -| **UI** | HTML5, CSS3 (glassmorphism), JavaScript ES6+ | +| **UI** | HTML5, CSS3 (glassmorphism, responsive breakpoints), JavaScript ES6+ | | **Icons** | Material Icons (offline) | | **Charts** | Live metric bars + history charts | | **i18n** | JSON-based translations | diff --git a/betterdesk-server/api/cdap_handlers.go b/betterdesk-server/api/cdap_handlers.go new file mode 100644 index 00000000..d706c24b --- /dev/null +++ b/betterdesk-server/api/cdap_handlers.go @@ -0,0 +1,198 @@ +package api + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "regexp" + "sync/atomic" + + "github.com/unitronix/betterdesk-server/cdap" +) + +// commandCounter generates unique command IDs for CDAP commands. +var commandCounter atomic.Int64 + +// cdapDeviceIDRegexp validates CDAP device ID format: "CDAP-" + 6-16 hex chars, or standard peer IDs. +var cdapDeviceIDRegexp = regexp.MustCompile(`^(CDAP-[A-Fa-f0-9]{6,16}|[A-Za-z0-9_-]{6,16})$`) + +// handleCDAPDeviceInfo returns full device info for a connected CDAP device. +// GET /api/cdap/devices/{id} +func (s *Server) handleCDAPDeviceInfo(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !cdapDeviceIDRegexp.MatchString(id) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid device ID"}) + return + } + + if s.cdapGw == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"}) + return + } + + info := s.cdapGw.GetDeviceInfo(id) + if info == nil { + // Device not connected via CDAP — check if manifest exists in DB + manifest, ok := s.cdapGw.GetDeviceManifest(id) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "Device not found or not a CDAP device"}) + return + } + writeJSON(w, http.StatusOK, &cdap.DeviceInfo{ + ID: id, + Connected: false, + Manifest: manifest, + }) + return + } + + writeJSON(w, http.StatusOK, info) +} + +// handleCDAPDeviceManifest returns the manifest for a CDAP device. +// GET /api/cdap/devices/{id}/manifest +func (s *Server) handleCDAPDeviceManifest(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !cdapDeviceIDRegexp.MatchString(id) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid device ID"}) + return + } + + if s.cdapGw == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"}) + return + } + + manifest, ok := s.cdapGw.GetDeviceManifest(id) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "No manifest found for device"}) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(manifest) +} + +// handleCDAPDeviceState returns current widget values for a connected CDAP device. +// GET /api/cdap/devices/{id}/state +func (s *Server) handleCDAPDeviceState(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !cdapDeviceIDRegexp.MatchString(id) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid device ID"}) + return + } + + if s.cdapGw == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"}) + return + } + + state, ok := s.cdapGw.GetDeviceWidgetState(id) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "Device not connected"}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "device_id": id, + "widget_state": state, + "connected": true, + }) +} + +// handleCDAPSendCommand sends a command to a connected CDAP device. +// POST /api/cdap/devices/{id}/command +func (s *Server) handleCDAPSendCommand(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !cdapDeviceIDRegexp.MatchString(id) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid device ID"}) + return + } + + if s.cdapGw == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"}) + return + } + + var body struct { + WidgetID string `json:"widget_id"` + Action string `json:"action"` + Value any `json:"value"` + Reason string `json:"reason,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) + return + } + + if body.WidgetID == "" || body.Action == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "widget_id and action are required"}) + return + } + + // Validate action + validActions := map[string]bool{"set": true, "trigger": true, "execute": true, "reset": true, "query": true} + if !validActions[body.Action] { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid action. Must be: set, trigger, execute, reset, query"}) + return + } + + operator := getUsernameFromCtx(r) + commandID := fmt.Sprintf("cmd_%s_%d", id, commandCounter.Add(1)) + + if err := s.cdapGw.SendCommandJSON(r.Context(), id, commandID, body.WidgetID, body.Action, body.Value, operator, body.Reason); err != nil { + log.Printf("[cdap-api] SendCommand to %s failed: %v", id, err) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "Device not connected or command failed"}) + return + } + + writeJSON(w, http.StatusAccepted, map[string]string{ + "status": "sent", + "command_id": commandID, + "device_id": id, + }) +} + +// handleCDAPListDevices returns all connected CDAP devices with their info. +// GET /api/cdap/devices +func (s *Server) handleCDAPListDevices(w http.ResponseWriter, r *http.Request) { + if s.cdapGw == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "CDAP gateway not enabled"}) + return + } + + ids := s.cdapGw.ListConnectedDevices() + devices := make([]any, 0, len(ids)) + for _, id := range ids { + if info := s.cdapGw.GetDeviceInfo(id); info != nil { + devices = append(devices, info) + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "devices": devices, + "total": len(devices), + }) +} + +// handleCDAPStatus returns CDAP gateway status. +// GET /api/cdap/status +func (s *Server) handleCDAPStatus(w http.ResponseWriter, r *http.Request) { + if s.cdapGw == nil { + writeJSON(w, http.StatusOK, map[string]any{ + "enabled": false, + "connected": 0, + "port": 0, + }) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "enabled": true, + "connected": s.cdapGw.ActiveConnections(), + "port": s.cfg.CDAPPort, + "tls": s.cfg.CDAPTLSEnabled(), + }) +} diff --git a/betterdesk-server/api/server.go b/betterdesk-server/api/server.go index eaf021d1..37e36192 100644 --- a/betterdesk-server/api/server.go +++ b/betterdesk-server/api/server.go @@ -19,6 +19,7 @@ import ( "github.com/unitronix/betterdesk-server/audit" "github.com/unitronix/betterdesk-server/auth" + "github.com/unitronix/betterdesk-server/cdap" "github.com/unitronix/betterdesk-server/config" "github.com/unitronix/betterdesk-server/crypto" "github.com/unitronix/betterdesk-server/db" @@ -54,6 +55,7 @@ type Server struct { jwtManager *auth.JWTManager loginLimiter *ratelimit.IPLimiter keyPair *crypto.KeyPair // Ed25519 keypair for signing + cdapGw *cdap.Gateway // CDAP gateway (nil if CDAP disabled) clientTFASessions *tfaSessionStore httpSrv *http.Server wg sync.WaitGroup @@ -108,6 +110,11 @@ func (s *Server) SetKeyPair(kp *crypto.KeyPair) { s.keyPair = kp } +// SetCDAPGateway sets the CDAP gateway for serving CDAP REST endpoints. +func (s *Server) SetCDAPGateway(gw *cdap.Gateway) { + s.cdapGw = gw +} + // Start launches the HTTP API server. func (s *Server) Start(ctx context.Context) error { mux := http.NewServeMux() @@ -201,6 +208,14 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("GET /api/enrollment/mode", s.requireRole(auth.RoleAdmin, s.handleGetEnrollmentMode)) mux.HandleFunc("PUT /api/enrollment/mode", s.requireRole(auth.RoleAdmin, s.handleSetEnrollmentMode)) + // CDAP device management (requires CDAP gateway to be enabled) + mux.HandleFunc("GET /api/cdap/status", s.handleCDAPStatus) + mux.HandleFunc("GET /api/cdap/devices", s.handleCDAPListDevices) + mux.HandleFunc("GET /api/cdap/devices/{id}", s.handleCDAPDeviceInfo) + mux.HandleFunc("GET /api/cdap/devices/{id}/manifest", s.handleCDAPDeviceManifest) + mux.HandleFunc("GET /api/cdap/devices/{id}/state", s.handleCDAPDeviceState) + mux.HandleFunc("POST /api/cdap/devices/{id}/command", s.requireRole(auth.RoleOperator, s.handleCDAPSendCommand)) + // Prometheus metrics (public, no API key required) mux.HandleFunc("GET /metrics", s.handleMetrics) @@ -352,9 +367,10 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) { // Enrich with live online status and status tier from memory map type peerResponse struct { *db.Peer - LiveOnline bool `json:"live_online"` - LiveStatus peer.Status `json:"live_status"` - Platform string `json:"platform"` + LiveOnline bool `json:"live_online"` + LiveStatus peer.Status `json:"live_status"` + Platform string `json:"platform"` + CDAPConnected bool `json:"cdap_connected"` } result := make([]peerResponse, len(peers)) @@ -364,11 +380,20 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) { if snap, ok := s.peers.GetSnapshot(p.ID, config.DegradedThreshold, config.CriticalThreshold); ok { liveStatus = snap.Status } + + // CDAP overlay: device connected via CDAP gateway is online + cdapConnected := s.cdapGw != nil && s.cdapGw.IsConnected(p.ID) + if cdapConnected && !liveOnline { + liveOnline = true + liveStatus = peer.StatusOnline + } + result[i] = peerResponse{ - Peer: p, - LiveOnline: liveOnline, - LiveStatus: liveStatus, - Platform: p.OS, + Peer: p, + LiveOnline: liveOnline, + LiveStatus: liveStatus, + Platform: p.OS, + CDAPConnected: cdapConnected, } } @@ -394,18 +419,27 @@ func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) { liveStatus = snap.Status } + // CDAP overlay + cdapConnected := s.cdapGw != nil && s.cdapGw.IsConnected(p.ID) + if cdapConnected && !liveOnline { + liveOnline = true + liveStatus = peer.StatusOnline + } + type singlePeerResponse struct { *db.Peer - LiveOnline bool `json:"live_online"` - LiveStatus peer.Status `json:"live_status"` - Platform string `json:"platform"` + LiveOnline bool `json:"live_online"` + LiveStatus peer.Status `json:"live_status"` + Platform string `json:"platform"` + CDAPConnected bool `json:"cdap_connected"` } writeJSON(w, http.StatusOK, singlePeerResponse{ - Peer: p, - LiveOnline: liveOnline, - LiveStatus: liveStatus, - Platform: p.OS, + Peer: p, + LiveOnline: liveOnline, + LiveStatus: liveStatus, + Platform: p.OS, + CDAPConnected: cdapConnected, }) } @@ -455,6 +489,18 @@ func (s *Server) handleUpdatePeerFields(w http.ResponseWriter, r *http.Request) func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") hard := r.URL.Query().Get("hard") == "true" + revoke := r.URL.Query().Get("revoke") == "true" + cascade := r.URL.Query().Get("cascade") == "true" + + // Collect linked peers before deletion (for cascade and response). + var linkedIDs []string + if revoke || cascade { + if linked, err := s.db.GetLinkedPeers(id); err == nil { + for _, lp := range linked { + linkedIDs = append(linkedIDs, lp.ID) + } + } + } var err error if hard { @@ -467,14 +513,74 @@ func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) { return } - // Also remove from memory + // Remove from memory (closes TCP/WS connections — Phase 3.9). s.peers.Remove(id) - if s.auditLog != nil { - s.auditLog.Log(audit.ActionPeerDeleted, s.remoteIP(r), id, map[string]string{"hard": fmt.Sprintf("%v", hard)}) + // Revocation: add device ID to blocklist to prevent re-registration. + if revoke && s.blocklist != nil { + s.blocklist.BlockID(id, "revoked via panel") } - writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": id}) + // Cascade: revoke linked devices (e.g., paired mobile→desktop). + var cascadedIDs []string + if cascade && len(linkedIDs) > 0 { + for _, lid := range linkedIDs { + if hard { + s.db.HardDeletePeer(lid) + } else { + s.db.DeletePeer(lid) + } + s.peers.Remove(lid) + if revoke && s.blocklist != nil { + s.blocklist.BlockID(lid, "revoked via cascade") + } + cascadedIDs = append(cascadedIDs, lid) + } + } + + // Publish event. + if s.eventBus != nil { + action := eventsModule.EventPeerDeleted + if revoke { + action = eventsModule.EventPeerRevoked + } + s.eventBus.Publish(eventsModule.Event{ + Type: action, + Data: map[string]string{ + "id": id, + "hard": fmt.Sprintf("%v", hard), + "revoke": fmt.Sprintf("%v", revoke), + "cascade": fmt.Sprintf("%v", cascadedIDs), + }, + }) + } + + // Audit log. + if s.auditLog != nil { + action := audit.ActionPeerDeleted + if revoke { + action = audit.ActionPeerRevoked + } + details := map[string]string{ + "hard": fmt.Sprintf("%v", hard), + "revoke": fmt.Sprintf("%v", revoke), + "cascade": fmt.Sprintf("%v", cascade), + } + if len(cascadedIDs) > 0 { + details["cascaded_ids"] = fmt.Sprintf("%v", cascadedIDs) + } + s.auditLog.Log(action, s.remoteIP(r), id, details) + } + + resp := map[string]interface{}{ + "status": "deleted", + "id": id, + "revoked": revoke, + } + if len(cascadedIDs) > 0 { + resp["cascaded"] = cascadedIDs + } + writeJSON(w, http.StatusOK, resp) } func (s *Server) handleBanPeer(w http.ResponseWriter, r *http.Request) { diff --git a/betterdesk-server/audit/logger.go b/betterdesk-server/audit/logger.go index a400ee3f..1e0dadae 100644 --- a/betterdesk-server/audit/logger.go +++ b/betterdesk-server/audit/logger.go @@ -19,6 +19,7 @@ const ( ActionPeerBanned Action = "peer_banned" ActionPeerUnbanned Action = "peer_unbanned" ActionPeerDeleted Action = "peer_deleted" + ActionPeerRevoked Action = "peer_revoked" ActionPeerUpdated Action = "peer_updated" ActionPeerIDChanged Action = "peer_id_changed" ActionPeerTagsUpdated Action = "peer_tags_updated" diff --git a/betterdesk-server/cdap/api.go b/betterdesk-server/cdap/api.go new file mode 100644 index 00000000..bfe500cd --- /dev/null +++ b/betterdesk-server/cdap/api.go @@ -0,0 +1,156 @@ +// Package cdap provides REST-friendly accessors for the CDAP gateway state. +// These methods are consumed by the API server to serve panel requests. +package cdap + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +// DeviceInfo is a REST-friendly snapshot of a CDAP device's state. +type DeviceInfo struct { + ID string `json:"id"` + Connected bool `json:"connected"` + Manifest json.RawMessage `json:"manifest,omitempty"` + WidgetState map[string]any `json:"widget_state,omitempty"` + ConnectedAt *time.Time `json:"connected_at,omitempty"` + LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"` + SessionID string `json:"session_id,omitempty"` + HeartbeatCount int64 `json:"heartbeat_count"` + CommandCount int64 `json:"command_count"` + HeartbeatInterval int `json:"heartbeat_interval"` + Username string `json:"username,omitempty"` + Role string `json:"role,omitempty"` + ClientIP string `json:"client_ip,omitempty"` +} + +// GetDeviceInfo returns a full snapshot of a connected CDAP device. +// Returns nil if the device is not connected via CDAP. +func (g *Gateway) GetDeviceInfo(id string) *DeviceInfo { + dc := g.GetDeviceConn(id) + if dc == nil { + return nil + } + + info := &DeviceInfo{ + ID: dc.ID, + Connected: true, + SessionID: dc.SessionID, + HeartbeatCount: dc.HeartbeatCount.Load(), + CommandCount: dc.CommandCount.Load(), + HeartbeatInterval: dc.HeartbeatInterval, + Username: dc.Username, + Role: dc.Role, + ClientIP: dc.ClientIP, + } + + connAt := dc.ConnectedAt + info.ConnectedAt = &connAt + lastHB := dc.LastHeartbeat + if !lastHB.IsZero() { + info.LastHeartbeat = &lastHB + } + + if dc.Manifest != nil { + data, err := json.Marshal(dc.Manifest) + if err == nil { + info.Manifest = data + } + } + + // Collect widget state + state := make(map[string]any) + dc.widgetState.Range(func(key, value any) bool { + state[key.(string)] = value + return true + }) + if len(state) > 0 { + info.WidgetState = state + } + + return info +} + +// GetDeviceManifest returns the manifest JSON for a device. +// Checks in-memory first (connected device), then falls back to DB. +func (g *Gateway) GetDeviceManifest(id string) (json.RawMessage, bool) { + // In-memory (connected device) + if dc := g.GetDeviceConn(id); dc != nil && dc.Manifest != nil { + data, err := json.Marshal(dc.Manifest) + if err == nil { + return data, true + } + } + // Fall back to DB (stored on registration) + val, err := g.db.GetConfig("cdap_manifest_" + id) + if err != nil || val == "" { + return nil, false + } + return json.RawMessage(val), true +} + +// GetDeviceWidgetState returns current widget values for a connected device. +func (g *Gateway) GetDeviceWidgetState(id string) (map[string]any, bool) { + dc := g.GetDeviceConn(id) + if dc == nil { + return nil, false + } + state := make(map[string]any) + dc.widgetState.Range(func(key, value any) bool { + state[key.(string)] = value + return true + }) + return state, true +} + +// IsConnected returns true if the device has an active CDAP connection. +func (g *Gateway) IsConnected(id string) bool { + return g.GetDeviceConn(id) != nil +} + +// SendCommandJSON builds and sends a command to a connected CDAP device. +func (g *Gateway) SendCommandJSON(ctx context.Context, deviceID, commandID, widgetID, action string, value any, operator, reason string) error { + payload := CommandPayload{ + CommandID: commandID, + WidgetID: widgetID, + Action: action, + Value: value, + Operator: operator, + Reason: reason, + } + + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal command: %w", err) + } + + cmd := &CommandMessage{ + ID: commandID, + Payload: data, + } + + if err := g.SendCommand(ctx, deviceID, cmd); err != nil { + return err + } + + g.auditAction("cdap_command_sent", deviceID, map[string]string{ + "command_id": commandID, + "widget_id": widgetID, + "action": action, + "operator": operator, + }) + + return nil +} + +// ListConnectedDevices returns IDs of all connected CDAP devices. +func (g *Gateway) ListConnectedDevices() []string { + var ids []string + g.devices.Range(func(key, value any) bool { + ids = append(ids, key.(string)) + return true + }) + return ids +} diff --git a/betterdesk-server/cdap/auth.go b/betterdesk-server/cdap/auth.go new file mode 100644 index 00000000..be7428ca --- /dev/null +++ b/betterdesk-server/cdap/auth.go @@ -0,0 +1,236 @@ +package cdap + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "time" + + "github.com/coder/websocket" + "github.com/unitronix/betterdesk-server/audit" + "github.com/unitronix/betterdesk-server/auth" +) + +// handleAuth reads the initial "auth" message from the client, validates +// credentials, and returns a DeviceConn on success. +func (g *Gateway) handleAuth(ctx context.Context, conn *websocket.Conn, clientIP string) (*DeviceConn, error) { + msg, err := readMessage(ctx, conn) + if err != nil { + return nil, fmt.Errorf("read auth message: %w", err) + } + if msg.Type != "auth" { + return nil, fmt.Errorf("expected 'auth' message, got '%s'", msg.Type) + } + + var payload AuthPayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + return nil, fmt.Errorf("invalid auth payload: %w", err) + } + + // Rate limit check for this IP + if g.limiter != nil && !g.limiter.Allow(clientIP) { + g.auditAction("cdap_auth_ratelimited", clientIP, map[string]string{ + "method": payload.Method, + }) + return nil, fmt.Errorf("rate limit exceeded") + } + + var username, role string + + switch payload.Method { + case "user_password": + u, r, authErr := g.authUserPassword(payload, clientIP) + if authErr != nil { + return nil, authErr + } + username, role = u, r + + case "api_key": + u, r, authErr := g.authAPIKey(payload, clientIP) + if authErr != nil { + return nil, authErr + } + username, role = u, r + + case "device_token": + u, r, authErr := g.authDeviceToken(payload, clientIP) + if authErr != nil { + return nil, authErr + } + username, role = u, r + + default: + return nil, fmt.Errorf("unsupported auth method: %s", payload.Method) + } + + // Generate session token + sessionID, _ := auth.GenerateRandomString(16) + + // Generate JWT for the device + token, err := g.jwt.Generate(username, role) + if err != nil { + return nil, fmt.Errorf("generate token: %w", err) + } + + dc := &DeviceConn{ + ID: payload.DeviceID, + Username: username, + Role: role, + ClientIP: clientIP, + conn: conn, + Token: token, + TokenExpiry: time.Now().Add(g.jwt.Expiry()), + SessionID: sessionID, + ConnectedAt: time.Now(), + LastHeartbeat: time.Now(), + HeartbeatInterval: 15, // default, updated after manifest registration + } + + // Send auth_result + result := AuthResult{ + Success: true, + Token: token, + Role: role, + DeviceID: payload.DeviceID, + SessionToken: sessionID, + } + if err := sendMessage(ctx, conn, "auth_result", result); err != nil { + return nil, fmt.Errorf("send auth_result: %w", err) + } + + g.auditAction("cdap_auth_success", clientIP, map[string]string{ + "device_id": payload.DeviceID, + "username": username, + "role": role, + "method": payload.Method, + }) + + log.Printf("[cdap] Authenticated %s (user=%s, role=%s, method=%s, ip=%s)", + payload.DeviceID, username, role, payload.Method, clientIP) + + return dc, nil +} + +// authUserPassword verifies username/password credentials. +func (g *Gateway) authUserPassword(p AuthPayload, clientIP string) (string, string, error) { + if p.Username == "" || p.Password == "" { + return "", "", fmt.Errorf("username and password required") + } + + user, err := g.db.GetUser(p.Username) + if err != nil || user == nil { + // Timing-safe: always call VerifyPassword even for non-existent users + auth.VerifyPassword("dummy:hash", p.Password) + g.auditAction("cdap_auth_failed", clientIP, map[string]string{ + "username": p.Username, + "reason": "invalid credentials", + }) + return "", "", fmt.Errorf("invalid credentials") + } + + if !auth.VerifyPassword(user.PasswordHash, p.Password) { + g.auditAction("cdap_auth_failed", clientIP, map[string]string{ + "username": p.Username, + "reason": "invalid password", + }) + return "", "", fmt.Errorf("invalid credentials") + } + + // Check 2FA if enabled + if user.TOTPEnabled { + if p.TOTPCode == "" { + return "", "", fmt.Errorf("2fa_required") + } + if !auth.ValidateTOTP(user.TOTPSecret, p.TOTPCode) { + g.auditAction("cdap_auth_failed", clientIP, map[string]string{ + "username": p.Username, + "reason": "invalid 2fa code", + }) + return "", "", fmt.Errorf("invalid 2FA code") + } + } + + return user.Username, user.Role, nil +} + +// authAPIKey verifies an API key. +func (g *Gateway) authAPIKey(p AuthPayload, clientIP string) (string, string, error) { + if p.Key == "" { + return "", "", fmt.Errorf("api key required") + } + + h := sha256.Sum256([]byte(p.Key)) + keyHash := hex.EncodeToString(h[:]) + + apiKey, err := g.db.GetAPIKeyByHash(keyHash) + if err != nil || apiKey == nil { + g.auditAction("cdap_auth_failed", clientIP, map[string]string{ + "reason": "invalid api key", + }) + return "", "", fmt.Errorf("invalid API key") + } + + // Touch last_used + g.db.TouchAPIKey(apiKey.ID) + + return fmt.Sprintf("apikey:%s", apiKey.Name), apiKey.Role, nil +} + +// authDeviceToken verifies a device enrollment token. +func (g *Gateway) authDeviceToken(p AuthPayload, clientIP string) (string, string, error) { + if p.Token == "" { + return "", "", fmt.Errorf("device token required") + } + + h := sha256.Sum256([]byte(p.Token)) + tokenHash := hex.EncodeToString(h[:]) + + dt, err := g.db.ValidateToken(tokenHash) + if err != nil || dt == nil { + g.auditAction("cdap_auth_failed", clientIP, map[string]string{ + "reason": "invalid device token", + }) + return "", "", fmt.Errorf("invalid or expired device token") + } + + // Bind token to device if not already bound + if dt.PeerID == "" && p.DeviceID != "" { + g.db.BindTokenToPeer(tokenHash, p.DeviceID) + } + + // Increment usage + g.db.IncrementTokenUse(tokenHash) + + return fmt.Sprintf("token:%s", dt.Name), "operator", nil +} + +// auditAction logs a CDAP action to the audit log. +func (g *Gateway) auditAction(action, target string, details map[string]string) { + if g.auditLog == nil { + return + } + if details == nil { + details = make(map[string]string) + } + details["source"] = "cdap" + g.auditLog.Log(audit.Action(action), "cdap", target, details) +} + +// readMessage reads a single CDAP protocol message from a bare websocket connection. +func readMessage(ctx context.Context, conn *websocket.Conn) (*Message, error) { + typ, data, err := conn.Read(ctx) + if err != nil { + return nil, err + } + if typ != websocket.MessageText { + return nil, fmt.Errorf("expected text frame, got %v", typ) + } + var msg Message + if err := json.Unmarshal(data, &msg); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + return &msg, nil +} diff --git a/betterdesk-server/cdap/gateway.go b/betterdesk-server/cdap/gateway.go new file mode 100644 index 00000000..690fa0df --- /dev/null +++ b/betterdesk-server/cdap/gateway.go @@ -0,0 +1,395 @@ +// Package cdap implements the Custom Device Automation Protocol (CDAP) gateway. +// CDAP enables non-RustDesk devices (SCADA, IoT, OS agents, custom hardware) to +// connect to BetterDesk via a WebSocket-based JSON protocol and appear as +// manageable devices in the admin panel alongside standard RustDesk peers. +package cdap + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/coder/websocket" + "github.com/unitronix/betterdesk-server/audit" + "github.com/unitronix/betterdesk-server/auth" + "github.com/unitronix/betterdesk-server/config" + "github.com/unitronix/betterdesk-server/db" + "github.com/unitronix/betterdesk-server/events" + "github.com/unitronix/betterdesk-server/peer" + "github.com/unitronix/betterdesk-server/ratelimit" + "github.com/unitronix/betterdesk-server/security" +) + +// Gateway is the CDAP WebSocket server. +type Gateway struct { + cfg *config.Config + db db.Database + peerMap *peer.Map + eventBus *events.Bus + auditLog *audit.Logger + blocklist *security.Blocklist + jwt *auth.JWTManager + limiter *ratelimit.IPLimiter + + httpSrv *http.Server + ln net.Listener + + // devices holds all authenticated CDAP connections keyed by device ID. + devices sync.Map // map[string]*DeviceConn + + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + + totalConns atomic.Int64 + activeConns atomic.Int64 + + version string +} + +// New creates a new CDAP gateway. +func New(cfg *config.Config, database db.Database, peerMap *peer.Map, eventBus *events.Bus) *Gateway { + return &Gateway{ + cfg: cfg, + db: database, + peerMap: peerMap, + eventBus: eventBus, + limiter: ratelimit.NewIPLimiter(10, 1*time.Minute, 5*time.Minute), + } +} + +// SetBlocklist sets the blocklist. +func (g *Gateway) SetBlocklist(bl *security.Blocklist) { g.blocklist = bl } + +// SetAuditLogger sets the audit logger. +func (g *Gateway) SetAuditLogger(al *audit.Logger) { g.auditLog = al } + +// SetJWTManager sets the JWT manager. +func (g *Gateway) SetJWTManager(jm *auth.JWTManager) { g.jwt = jm } + +// SetRateLimiter overrides the default rate limiter. +func (g *Gateway) SetRateLimiter(l *ratelimit.IPLimiter) { g.limiter = l } + +// SetVersion sets the version string for startup log. +func (g *Gateway) SetVersion(v string) { g.version = v } + +// Start binds the WebSocket listener and begins accepting connections. +func (g *Gateway) Start(ctx context.Context) error { + g.ctx, g.cancel = context.WithCancel(ctx) + + addr := fmt.Sprintf(":%d", g.cfg.CDAPPort) + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("cdap: listen %s: %w", addr, err) + } + + // Wrap with TLS auto-detect if enabled + if g.cfg.CDAPTLSEnabled() { + tlsCfg, tlsErr := config.LoadTLSConfig(g.cfg.TLSCertFile, g.cfg.TLSKeyFile) + if tlsErr != nil { + ln.Close() + return fmt.Errorf("cdap: tls config: %w", tlsErr) + } + ln = config.NewDualModeListener(ln, tlsCfg) + log.Printf("[cdap] TLS enabled (dual-mode: plain + TLS auto-detect)") + } + + g.ln = ln + + mux := http.NewServeMux() + mux.HandleFunc("/cdap", g.handleWebSocket) + mux.HandleFunc("/cdap/health", g.handleHealth) + + g.httpSrv = &http.Server{ + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + } + + g.wg.Add(1) + go func() { + defer g.wg.Done() + if err := g.httpSrv.Serve(g.ln); err != nil && err != http.ErrServerClosed { + log.Printf("[cdap] Server error: %v", err) + } + }() + + // Heartbeat monitor: detect stale CDAP connections + g.wg.Add(1) + go g.heartbeatMonitor() + + scheme := "ws" + if g.cfg.CDAPTLSEnabled() { + scheme = "wss" + } + log.Printf("[cdap] Gateway started on %s://0.0.0.0:%d/cdap", scheme, g.cfg.CDAPPort) + return nil +} + +// Stop gracefully shuts down the gateway. +func (g *Gateway) Stop() { + log.Printf("[cdap] Shutting down gateway...") + g.cancel() + + // Close all device connections + g.devices.Range(func(key, value any) bool { + if dc, ok := value.(*DeviceConn); ok { + dc.Close(websocket.StatusGoingAway, "server shutdown") + } + return true + }) + + // Graceful HTTP shutdown + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + if g.httpSrv != nil { + g.httpSrv.Shutdown(shutdownCtx) + } + + g.wg.Wait() + log.Printf("[cdap] Gateway stopped (total connections served: %d)", g.totalConns.Load()) +} + +// ActiveConnections returns the current number of active CDAP connections. +func (g *Gateway) ActiveConnections() int64 { + return g.activeConns.Load() +} + +// handleHealth serves the /cdap/health endpoint for monitoring. +func (g *Gateway) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "connections": g.activeConns.Load(), + "total": g.totalConns.Load(), + "version": g.version, + }) +} + +// handleWebSocket upgrades the HTTP connection to WebSocket and runs the +// CDAP protocol state machine. +func (g *Gateway) handleWebSocket(w http.ResponseWriter, r *http.Request) { + // Extract client IP for rate limiting and logging + clientIP := extractIP(r) + + // Rate limit + if g.limiter != nil && !g.limiter.Allow(clientIP) { + http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) + return + } + + // Blocklist check + if g.blocklist != nil { + if g.blocklist.IsIPBlocked(clientIP) { + http.Error(w, "blocked", http.StatusForbidden) + return + } + } + + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + Subprotocols: []string{"cdap-v1"}, + }) + if err != nil { + log.Printf("[cdap] WebSocket upgrade failed from %s: %v", clientIP, err) + return + } + + g.totalConns.Add(1) + g.activeConns.Add(1) + defer g.activeConns.Add(-1) + + // Run the connection state machine + g.runConnection(r.Context(), conn, clientIP) +} + +// runConnection drives the CDAP protocol: +// +// → auth → auth_result +// → register (with manifest) → registered +// → heartbeat / state_update / command_response / ... +func (g *Gateway) runConnection(baseCtx context.Context, conn *websocket.Conn, clientIP string) { + // Create a context bound to both the HTTP request and our gateway's lifecycle + ctx, cancel := context.WithCancel(baseCtx) + defer cancel() + go func() { + select { + case <-g.ctx.Done(): + cancel() + case <-ctx.Done(): + } + }() + + // Phase 1: Authentication (30-second deadline) + authCtx, authCancel := context.WithTimeout(ctx, 30*time.Second) + dc, authErr := g.handleAuth(authCtx, conn, clientIP) + authCancel() + if authErr != nil { + sendError(ctx, conn, 1001, authErr.Error()) + conn.Close(websocket.StatusPolicyViolation, "auth failed") + return + } + defer func() { + g.removeDevice(dc) + conn.Close(websocket.StatusNormalClosure, "") + }() + + // Phase 2: Registration (30-second deadline) + regCtx, regCancel := context.WithTimeout(ctx, 30*time.Second) + regErr := g.handleRegister(regCtx, dc) + regCancel() + if regErr != nil { + sendError(ctx, conn, 2001, regErr.Error()) + return + } + + // Phase 3: Main message loop + g.messageLoop(ctx, dc) +} + +// messageLoop reads messages until the connection closes or context is cancelled. +func (g *Gateway) messageLoop(ctx context.Context, dc *DeviceConn) { + for { + msg, err := dc.ReadMessage(ctx) + if err != nil { + if ctx.Err() == nil { + log.Printf("[cdap] %s: read error: %v", dc.ID, err) + } + return + } + + switch msg.Type { + case "heartbeat": + g.handleHeartbeat(ctx, dc, msg) + case "state_update": + g.handleStateUpdate(ctx, dc, msg) + case "bulk_update": + g.handleBulkUpdate(ctx, dc, msg) + case "command_response": + g.handleCommandResponse(ctx, dc, msg) + case "event": + g.handleEvent(ctx, dc, msg) + case "log": + g.handleLog(ctx, dc, msg) + case "unregister": + g.handleUnregister(ctx, dc, msg) + return + case "token_refresh": + g.handleTokenRefresh(ctx, dc, msg) + default: + sendError(ctx, dc.conn, 1006, fmt.Sprintf("unknown message type: %s", msg.Type)) + } + } +} + +// heartbeatMonitor periodically checks for stale CDAP connections. +func (g *Gateway) heartbeatMonitor() { + defer g.wg.Done() + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-g.ctx.Done(): + return + case <-ticker.C: + now := time.Now() + g.devices.Range(func(key, value any) bool { + dc, ok := value.(*DeviceConn) + if !ok { + return true + } + // 3x heartbeat interval = stale + maxIdle := time.Duration(dc.HeartbeatInterval) * time.Second * 3 + if maxIdle < 60*time.Second { + maxIdle = 60 * time.Second + } + if now.Sub(dc.LastHeartbeat) > maxIdle { + log.Printf("[cdap] %s: heartbeat timeout (last: %s ago)", dc.ID, now.Sub(dc.LastHeartbeat).Round(time.Second)) + dc.Close(websocket.StatusPolicyViolation, "heartbeat timeout") + g.removeDevice(dc) + } + return true + }) + } + } +} + +// removeDevice cleans up a device connection from the registry. +func (g *Gateway) removeDevice(dc *DeviceConn) { + if dc == nil || dc.ID == "" { + return + } + g.devices.Delete(dc.ID) + + // Update peer status to OFFLINE + if err := g.db.UpdatePeerStatus(dc.ID, "OFFLINE", dc.ClientIP); err != nil { + log.Printf("[cdap] %s: failed to set offline: %v", dc.ID, err) + } + + // Publish disconnect event + if g.eventBus != nil { + g.eventBus.Publish(events.Event{ + Type: "cdap_disconnect", + Data: map[string]string{ + "peer_id": dc.ID, + "reason": "disconnected", + }, + }) + } + + log.Printf("[cdap] %s: disconnected (session: %s)", dc.ID, time.Since(dc.ConnectedAt).Round(time.Second)) +} + +// SendCommand sends a command to a connected CDAP device. +// Returns error if the device is not connected. +func (g *Gateway) SendCommand(ctx context.Context, deviceID string, cmd *CommandMessage) error { + val, ok := g.devices.Load(deviceID) + if !ok { + return fmt.Errorf("device %s not connected", deviceID) + } + dc := val.(*DeviceConn) + + dc.CommandCount.Add(1) + return dc.WriteMessage(ctx, &Message{ + Type: "command", + ID: cmd.ID, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Payload: cmd.Payload, + }) +} + +// GetDeviceConn returns the connection for a device, or nil if not connected. +func (g *Gateway) GetDeviceConn(deviceID string) *DeviceConn { + val, ok := g.devices.Load(deviceID) + if !ok { + return nil + } + return val.(*DeviceConn) +} + +// extractIP extracts the client IP from the request, respecting X-Forwarded-For +// only when trust-proxy is configured (handled upstream in the HTTP handler). +func extractIP(r *http.Request) string { + // Try X-Real-IP first (set by nginx) + if ip := r.Header.Get("X-Real-IP"); ip != "" { + return ip + } + // Try X-Forwarded-For + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.SplitN(xff, ",", 2) + return strings.TrimSpace(parts[0]) + } + // Fall back to remote addr + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/betterdesk-server/cdap/handler.go b/betterdesk-server/cdap/handler.go new file mode 100644 index 00000000..990760ea --- /dev/null +++ b/betterdesk-server/cdap/handler.go @@ -0,0 +1,356 @@ +package cdap + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + "time" + + "github.com/unitronix/betterdesk-server/db" + "github.com/unitronix/betterdesk-server/events" +) + +// handleRegister processes the "register" message after authentication. +// It parses the manifest, creates/updates the peer in the database, and +// registers the device connection in the gateway's in-memory map. +func (g *Gateway) handleRegister(ctx context.Context, dc *DeviceConn) error { + msg, err := dc.ReadMessage(ctx) + if err != nil { + return fmt.Errorf("read register message: %w", err) + } + if msg.Type != "register" { + return fmt.Errorf("expected 'register' message, got '%s'", msg.Type) + } + + var rp RegisterPayload + if err := json.Unmarshal(msg.Payload, &rp); err != nil { + return fmt.Errorf("invalid register payload: %w", err) + } + if rp.Manifest == nil { + return fmt.Errorf("manifest is required") + } + + // Validate manifest + if err := ValidateManifest(rp.Manifest); err != nil { + return fmt.Errorf("invalid manifest: %w", err) + } + + dc.Manifest = rp.Manifest + dc.HeartbeatInterval = rp.Manifest.HeartbeatInterval + + // Validate device ID format (CDAP-XXXXXXXX or 6-16 alphanumeric) + if dc.ID == "" { + return fmt.Errorf("device_id is required (set in auth payload)") + } + + // Check if device is banned + banned, _ := g.db.IsPeerBanned(dc.ID) + if banned { + return fmt.Errorf("device is banned") + } + + // Check if device is soft-deleted + deleted, _ := g.db.IsPeerSoftDeleted(dc.ID) + if deleted { + return fmt.Errorf("device has been deleted") + } + + // Upsert the peer in the database + tags := strings.Join(rp.Manifest.Device.Tags, ",") + peer := &db.Peer{ + ID: dc.ID, + Hostname: rp.Manifest.Device.Name, + Status: "ONLINE", + IP: dc.ClientIP, + DeviceType: rp.Manifest.Device.Type, + Tags: tags, + User: dc.Username, + LastOnline: time.Now(), + OS: rp.Manifest.Bridge.Protocol, + Version: rp.Manifest.Bridge.Version, + } + if err := g.db.UpsertPeer(peer); err != nil { + return fmt.Errorf("save peer: %w", err) + } + + // Store manifest JSON in config (device-specific key) + manifestJSON, _ := json.Marshal(rp.Manifest) + g.db.SetConfig(fmt.Sprintf("cdap_manifest_%s", dc.ID), string(manifestJSON)) + + // Check for existing connection with same ID (force disconnect old) + if old, loaded := g.devices.LoadAndDelete(dc.ID); loaded { + if oldDC, ok := old.(*DeviceConn); ok { + log.Printf("[cdap] %s: replacing existing connection from %s", dc.ID, oldDC.ClientIP) + oldDC.Close(4001, "replaced by new connection") + } + } + + // Register in gateway's device map + g.devices.Store(dc.ID, dc) + + // Update peer status to ONLINE + g.db.UpdatePeerStatus(dc.ID, "ONLINE", dc.ClientIP) + + // Send registration confirmation + result := map[string]any{ + "device_id": dc.ID, + "server_time": time.Now().UTC().Format(time.RFC3339), + } + if err := sendMessage(ctx, dc.conn, "registered", result); err != nil { + return fmt.Errorf("send registered: %w", err) + } + + // Publish connect event + if g.eventBus != nil { + g.eventBus.Publish(events.Event{ + Type: "cdap_connect", + Data: map[string]string{ + "peer_id": dc.ID, + "device_type": rp.Manifest.Device.Type, + "device_name": rp.Manifest.Device.Name, + "username": dc.Username, + }, + }) + } + + g.auditAction("cdap_register", dc.ID, map[string]string{ + "device_name": rp.Manifest.Device.Name, + "device_type": rp.Manifest.Device.Type, + "widgets": fmt.Sprintf("%d", len(rp.Manifest.Widgets)), + "ip": dc.ClientIP, + }) + + log.Printf("[cdap] %s: registered (type=%s, name=%s, widgets=%d, heartbeat=%ds)", + dc.ID, rp.Manifest.Device.Type, rp.Manifest.Device.Name, + len(rp.Manifest.Widgets), rp.Manifest.HeartbeatInterval) + + return nil +} + +// handleHeartbeat processes periodic heartbeat messages. +func (g *Gateway) handleHeartbeat(ctx context.Context, dc *DeviceConn, msg *Message) { + dc.LastHeartbeat = time.Now() + dc.HeartbeatCount.Add(1) + + var payload HeartbeatPayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError(ctx, dc.conn, 3001, "invalid heartbeat payload") + return + } + + // Save system metrics if provided + if payload.Metrics != nil { + m := payload.Metrics + if m.CPU > 0 || m.Memory > 0 || m.Disk > 0 { + g.db.SavePeerMetric(dc.ID, m.CPU, m.Memory, m.Disk) + } + } + + // Update widget values + if payload.WidgetValues != nil { + for widgetID, value := range payload.WidgetValues { + dc.widgetState.Store(widgetID, value) + } + + // Publish widget state update event + if g.eventBus != nil { + valuesJSON, _ := json.Marshal(payload.WidgetValues) + g.eventBus.Publish(events.Event{ + Type: "cdap_widget_update", + Data: map[string]string{ + "peer_id": dc.ID, + "values": string(valuesJSON), + }, + }) + } + } + + // Keep peer ONLINE + g.db.UpdatePeerStatus(dc.ID, "ONLINE", dc.ClientIP) + + // Respond with server ping + sendMessage(ctx, dc.conn, "ping", map[string]any{ + "server_time": time.Now().UTC().Format(time.RFC3339), + }) +} + +// handleStateUpdate processes a single widget state update. +func (g *Gateway) handleStateUpdate(ctx context.Context, dc *DeviceConn, msg *Message) { + var payload StateUpdatePayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError(ctx, dc.conn, 3002, "invalid state_update payload") + return + } + + if payload.WidgetID == "" { + sendError(ctx, dc.conn, 3003, "widget_id is required") + return + } + + // Update cached state + dc.widgetState.Store(payload.WidgetID, payload.Value) + + // Publish to event bus for real-time panel updates + if g.eventBus != nil { + valueJSON, _ := json.Marshal(payload.Value) + g.eventBus.Publish(events.Event{ + Type: "cdap_state_update", + Data: map[string]string{ + "peer_id": dc.ID, + "widget_id": payload.WidgetID, + "value": string(valueJSON), + }, + }) + } +} + +// handleBulkUpdate processes multiple widget state updates at once. +func (g *Gateway) handleBulkUpdate(ctx context.Context, dc *DeviceConn, msg *Message) { + var payload BulkUpdatePayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError(ctx, dc.conn, 3004, "invalid bulk_update payload") + return + } + + updates := make(map[string]any, len(payload.Updates)) + for _, u := range payload.Updates { + if u.WidgetID != "" { + dc.widgetState.Store(u.WidgetID, u.Value) + updates[u.WidgetID] = u.Value + } + } + + if g.eventBus != nil && len(updates) > 0 { + valuesJSON, _ := json.Marshal(updates) + g.eventBus.Publish(events.Event{ + Type: "cdap_widget_update", + Data: map[string]string{ + "peer_id": dc.ID, + "values": string(valuesJSON), + }, + }) + } +} + +// handleCommandResponse processes a device's response to a command. +func (g *Gateway) handleCommandResponse(ctx context.Context, dc *DeviceConn, msg *Message) { + var payload CommandResponsePayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError(ctx, dc.conn, 3005, "invalid command_response payload") + return + } + + // Publish to event bus so the panel can display the result + if g.eventBus != nil { + resultJSON, _ := json.Marshal(payload) + g.eventBus.Publish(events.Event{ + Type: "cdap_command_response", + Data: map[string]string{ + "peer_id": dc.ID, + "command_id": payload.CommandID, + "status": payload.Status, + "result": string(resultJSON), + }, + }) + } + + g.auditAction("cdap_command_response", dc.ID, map[string]string{ + "command_id": payload.CommandID, + "status": payload.Status, + "ip": dc.ClientIP, + }) +} + +// handleEvent processes custom events from the device. +func (g *Gateway) handleEvent(ctx context.Context, dc *DeviceConn, msg *Message) { + var payload EventPayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError(ctx, dc.conn, 3006, "invalid event payload") + return + } + + if g.eventBus != nil { + dataJSON, _ := json.Marshal(payload.Data) + g.eventBus.Publish(events.Event{ + Type: "cdap_event", + Data: map[string]string{ + "peer_id": dc.ID, + "event_type": payload.EventType, + "data": string(dataJSON), + }, + }) + } +} + +// handleLog processes log entries from the device. +func (g *Gateway) handleLog(ctx context.Context, dc *DeviceConn, msg *Message) { + var payload LogPayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError(ctx, dc.conn, 3007, "invalid log payload") + return + } + + // Log at appropriate level + level := strings.ToLower(payload.Level) + if level == "error" || level == "critical" { + log.Printf("[cdap] %s [%s]: %s", dc.ID, level, payload.Message) + } + + // Publish to event bus + if g.eventBus != nil { + g.eventBus.Publish(events.Event{ + Type: "cdap_log", + Data: map[string]string{ + "peer_id": dc.ID, + "level": payload.Level, + "message": payload.Message, + }, + }) + } +} + +// handleUnregister processes a graceful disconnect from the device. +func (g *Gateway) handleUnregister(ctx context.Context, dc *DeviceConn, msg *Message) { + var payload UnregisterPayload + json.Unmarshal(msg.Payload, &payload) // best-effort parse + + log.Printf("[cdap] %s: unregistered (reason: %s)", dc.ID, payload.Reason) + + g.auditAction("cdap_unregister", dc.ID, map[string]string{ + "reason": payload.Reason, + "ip": dc.ClientIP, + }) +} + +// handleTokenRefresh refreshes the device's JWT token. +func (g *Gateway) handleTokenRefresh(ctx context.Context, dc *DeviceConn, msg *Message) { + var payload TokenRefreshPayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError(ctx, dc.conn, 3008, "invalid token_refresh payload") + return + } + + // Validate the existing token + claims, err := g.jwt.Validate(dc.Token) + if err != nil { + sendError(ctx, dc.conn, 1003, "current token invalid") + return + } + + // Generate new token + newToken, err := g.jwt.Generate(claims.Sub, dc.Role) + if err != nil { + sendError(ctx, dc.conn, 5001, "failed to generate token") + return + } + + dc.Token = newToken + dc.TokenExpiry = time.Now().Add(g.jwt.Expiry()) + + sendMessage(ctx, dc.conn, "token_refreshed", map[string]any{ + "token": newToken, + "expires_at": dc.TokenExpiry.UTC().Format(time.RFC3339), + }) +} diff --git a/betterdesk-server/cdap/manifest.go b/betterdesk-server/cdap/manifest.go new file mode 100644 index 00000000..ccbe82ad --- /dev/null +++ b/betterdesk-server/cdap/manifest.go @@ -0,0 +1,272 @@ +package cdap + +import ( + "encoding/json" + "fmt" + "strings" +) + +// Manifest describes a CDAP device's capabilities, identity, and widget definitions. +type Manifest struct { + ManifestVersion string `json:"manifest_version"` // "1.0" + Device ManifestDevice `json:"device"` + Bridge ManifestBridge `json:"bridge,omitempty"` + Capabilities []string `json:"capabilities"` // telemetry, commands, alerts, logs, ... + HeartbeatInterval int `json:"heartbeat_interval"` // seconds (default 15, max 300) + Widgets []Widget `json:"widgets,omitempty"` + Alerts []AlertDef `json:"alerts,omitempty"` +} + +// ManifestDevice describes the physical/virtual device identity. +type ManifestDevice struct { + Name string `json:"name"` + Type string `json:"type"` // scada, iot, os_agent, network, camera, desktop, custom + Vendor string `json:"vendor,omitempty"` + Model string `json:"model,omitempty"` + Firmware string `json:"firmware,omitempty"` + Serial string `json:"serial,omitempty"` + Location string `json:"location,omitempty"` + Tags []string `json:"tags,omitempty"` + Icon string `json:"icon,omitempty"` + Description string `json:"description,omitempty"` +} + +// ManifestBridge describes the bridge software connecting the device to CDAP. +type ManifestBridge struct { + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + Protocol string `json:"protocol,omitempty"` + TargetHost string `json:"target_host,omitempty"` + TargetPort int `json:"target_port,omitempty"` +} + +// Widget represents a single control/display element on the device. +type Widget struct { + Type string `json:"type"` // toggle, gauge, button, led, chart, select, slider, text, table, terminal, desktop, video_stream, file_browser + ID string `json:"id"` + Label string `json:"label"` + Group string `json:"group,omitempty"` // collapsible group name + Value any `json:"value,omitempty"` // initial value + Readonly bool `json:"readonly,omitempty"` + + // Gauge/Slider fields + Unit string `json:"unit,omitempty"` + Min float64 `json:"min,omitempty"` + Max float64 `json:"max,omitempty"` + Step float64 `json:"step,omitempty"` + Precision int `json:"precision,omitempty"` + WarningLow float64 `json:"warning_low,omitempty"` + WarningHigh float64 `json:"warning_high,omitempty"` + + // Button fields + Confirm bool `json:"confirm,omitempty"` + ConfirmMessage string `json:"confirm_message,omitempty"` + Style string `json:"style,omitempty"` // primary, danger, etc. + Icon string `json:"icon,omitempty"` + Cooldown int `json:"cooldown,omitempty"` // seconds + + // Select fields + Options []WidgetOption `json:"options,omitempty"` + + // Chart fields + ChartType string `json:"chart_type,omitempty"` // line, bar, area + Points int `json:"points,omitempty"` + Series []ChartSeries `json:"series,omitempty"` + Retention string `json:"retention,omitempty"` // 24h, 7d, etc. + + // Table fields + Columns []TableColumn `json:"columns,omitempty"` + MaxRows int `json:"max_rows,omitempty"` + Sortable bool `json:"sortable,omitempty"` +} + +// WidgetOption for select widgets. +type WidgetOption struct { + Label string `json:"label"` + Value any `json:"value"` +} + +// ChartSeries for chart widgets. +type ChartSeries struct { + ID string `json:"id"` + Label string `json:"label"` + Color string `json:"color,omitempty"` + Unit string `json:"unit,omitempty"` +} + +// TableColumn for table widgets. +type TableColumn struct { + ID string `json:"id"` + Label string `json:"label"` + Type string `json:"type,omitempty"` // string, number, boolean, date +} + +// AlertDef defines a threshold-based alert. +type AlertDef struct { + ID string `json:"id"` + Label string `json:"label"` + Severity string `json:"severity"` // critical, warning, info + Condition string `json:"condition"` // expression string + Message string `json:"message"` +} + +// Allowed device types. +var allowedDeviceTypes = map[string]bool{ + "scada": true, + "iot": true, + "os_agent": true, + "network": true, + "camera": true, + "desktop": true, + "custom": true, +} + +// Allowed widget types. +var allowedWidgetTypes = map[string]bool{ + "toggle": true, + "gauge": true, + "button": true, + "led": true, + "chart": true, + "select": true, + "slider": true, + "text": true, + "table": true, + "terminal": true, + "desktop": true, + "video_stream": true, + "file_browser": true, +} + +// Allowed capabilities. +var allowedCapabilities = map[string]bool{ + "telemetry": true, + "commands": true, + "alerts": true, + "logs": true, + "remote_desktop": true, + "video_stream": true, + "audio": true, + "clipboard": true, + "file_transfer": true, + "input_control": true, +} + +// maxWidgets is the hard limit on widget count per device. +const maxWidgets = 200 + +// ParseManifest parses and validates a CDAP device manifest from raw JSON. +func ParseManifest(data json.RawMessage) (*Manifest, error) { + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parse manifest: %w", err) + } + if err := ValidateManifest(&m); err != nil { + return nil, err + } + return &m, nil +} + +// ValidateManifest checks that a manifest is well-formed. +func ValidateManifest(m *Manifest) error { + if m.ManifestVersion == "" { + m.ManifestVersion = "1.0" + } + if m.ManifestVersion != "1.0" { + return fmt.Errorf("unsupported manifest version: %s", m.ManifestVersion) + } + + // Device name required + m.Device.Name = strings.TrimSpace(m.Device.Name) + if m.Device.Name == "" { + return fmt.Errorf("device name is required") + } + if len(m.Device.Name) > 128 { + return fmt.Errorf("device name too long (max 128 chars)") + } + + // Device type validation + m.Device.Type = strings.ToLower(strings.TrimSpace(m.Device.Type)) + if m.Device.Type == "" { + m.Device.Type = "custom" + } + if !allowedDeviceTypes[m.Device.Type] { + return fmt.Errorf("invalid device type: %s", m.Device.Type) + } + + // Heartbeat interval bounds + if m.HeartbeatInterval <= 0 { + m.HeartbeatInterval = 15 + } + if m.HeartbeatInterval > 300 { + m.HeartbeatInterval = 300 + } + + // Capabilities validation + for _, cap := range m.Capabilities { + if !allowedCapabilities[strings.ToLower(cap)] { + return fmt.Errorf("unknown capability: %s", cap) + } + } + + // Widget validation + if len(m.Widgets) > maxWidgets { + return fmt.Errorf("too many widgets (%d, max %d)", len(m.Widgets), maxWidgets) + } + + widgetIDs := make(map[string]bool, len(m.Widgets)) + for i := range m.Widgets { + w := &m.Widgets[i] + w.Type = strings.ToLower(strings.TrimSpace(w.Type)) + if !allowedWidgetTypes[w.Type] { + return fmt.Errorf("widget %d: invalid type: %s", i, w.Type) + } + w.ID = strings.TrimSpace(w.ID) + if w.ID == "" { + return fmt.Errorf("widget %d: id is required", i) + } + if len(w.ID) > 64 { + return fmt.Errorf("widget %d: id too long (max 64 chars)", i) + } + if widgetIDs[w.ID] { + return fmt.Errorf("widget %d: duplicate id: %s", i, w.ID) + } + widgetIDs[w.ID] = true + + w.Label = strings.TrimSpace(w.Label) + if w.Label == "" { + w.Label = w.ID + } + } + + // Alert validation + alertIDs := make(map[string]bool, len(m.Alerts)) + for i := range m.Alerts { + a := &m.Alerts[i] + a.ID = strings.TrimSpace(a.ID) + if a.ID == "" { + return fmt.Errorf("alert %d: id is required", i) + } + if alertIDs[a.ID] { + return fmt.Errorf("alert %d: duplicate id: %s", i, a.ID) + } + alertIDs[a.ID] = true + + a.Severity = strings.ToLower(strings.TrimSpace(a.Severity)) + if a.Severity != "critical" && a.Severity != "warning" && a.Severity != "info" { + return fmt.Errorf("alert %d: invalid severity: %s", i, a.Severity) + } + } + + // Tags: max 20 tags, max 64 chars each + if len(m.Device.Tags) > 20 { + return fmt.Errorf("too many device tags (%d, max 20)", len(m.Device.Tags)) + } + for i, tag := range m.Device.Tags { + if len(tag) > 64 { + return fmt.Errorf("device tag %d too long (max 64 chars)", i) + } + } + + return nil +} diff --git a/betterdesk-server/cdap/messages.go b/betterdesk-server/cdap/messages.go new file mode 100644 index 00000000..9d3bba84 --- /dev/null +++ b/betterdesk-server/cdap/messages.go @@ -0,0 +1,224 @@ +package cdap + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/coder/websocket" +) + +// Message is the top-level CDAP protocol envelope. +type Message struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` // command correlation ID + Timestamp string `json:"timestamp,omitempty"` // ISO-8601 + Payload json.RawMessage `json:"payload"` +} + +// AuthPayload is sent by client in the "auth" message. +type AuthPayload struct { + Method string `json:"method"` // user_password, api_key, device_token + Username string `json:"username,omitempty"` // for user_password + Password string `json:"password,omitempty"` // for user_password + TOTPCode string `json:"totp_code,omitempty"` // optional 2FA code + Key string `json:"key,omitempty"` // for api_key + Token string `json:"token,omitempty"` // for device_token + DeviceID string `json:"device_id,omitempty"` // requested device ID + ClientVersion string `json:"client_version,omitempty"` +} + +// AuthResult is sent by server in the "auth_result" message. +type AuthResult struct { + Success bool `json:"success"` + Token string `json:"token,omitempty"` // JWT 24h + Role string `json:"role,omitempty"` + DeviceID string `json:"device_id,omitempty"` + SessionToken string `json:"session_token,omitempty"` + Requires2FA bool `json:"requires_2fa,omitempty"` + TFAType string `json:"tfa_type,omitempty"` + PartialToken string `json:"partial_token,omitempty"` + Error string `json:"error,omitempty"` +} + +// RegisterPayload is sent by client in the "register" message. +type RegisterPayload struct { + Manifest *Manifest `json:"manifest"` +} + +// HeartbeatPayload is sent by client in the "heartbeat" message. +type HeartbeatPayload struct { + Metrics *MetricsData `json:"metrics,omitempty"` + WidgetValues map[string]any `json:"widget_values,omitempty"` +} + +// MetricsData holds standard system metrics. +type MetricsData struct { + CPU float64 `json:"cpu"` + Memory float64 `json:"memory"` + Disk float64 `json:"disk"` +} + +// StateUpdatePayload is sent by client in the "state_update" message. +type StateUpdatePayload struct { + WidgetID string `json:"widget_id"` + Value any `json:"value"` + Timestamp string `json:"timestamp,omitempty"` +} + +// BulkUpdatePayload is sent by client in the "bulk_update" message. +type BulkUpdatePayload struct { + Updates []StateUpdatePayload `json:"updates"` +} + +// CommandPayload is sent by server in the "command" message. +type CommandPayload struct { + CommandID string `json:"command_id"` + WidgetID string `json:"widget_id"` + Action string `json:"action"` // set, trigger, execute, reset, query + Value any `json:"value,omitempty"` + Operator string `json:"operator,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// CommandMessage wraps a command to be sent to a device. +type CommandMessage struct { + ID string `json:"id"` + Payload json.RawMessage `json:"payload"` +} + +// CommandResponsePayload is sent by client in response to a command. +type CommandResponsePayload struct { + CommandID string `json:"command_id"` + Status string `json:"status"` // ok, error, timeout, rejected, queued, unauthorized + ExecTimeMs int `json:"execution_time_ms,omitempty"` + Result any `json:"result,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +// EventPayload is sent by client for custom events. +type EventPayload struct { + EventType string `json:"event_type"` + Data any `json:"data,omitempty"` +} + +// LogPayload is sent by client for log entries. +type LogPayload struct { + Level string `json:"level"` // debug, info, warning, error, critical + Message string `json:"message"` + Context any `json:"context,omitempty"` +} + +// UnregisterPayload is sent by client to disconnect. +type UnregisterPayload struct { + Reason string `json:"reason,omitempty"` +} + +// TokenRefreshPayload is sent by client to refresh JWT. +type TokenRefreshPayload struct { + Token string `json:"token"` +} + +// ErrorPayload is sent by server for protocol errors. +type ErrorPayload struct { + Code int `json:"code"` + Message string `json:"message"` + Details any `json:"details,omitempty"` +} + +// DeviceConn represents an authenticated CDAP device connection. +type DeviceConn struct { + ID string // Device ID (CDAP-XXXXXXXX or custom) + Username string // Authenticated user + Role string // admin, operator, viewer + ClientIP string + + conn *websocket.Conn + mu sync.Mutex // serialise writes + + // Device metadata + Manifest *Manifest + HeartbeatInterval int // seconds (from manifest or default 15) + + // Session + Token string + TokenExpiry time.Time + SessionID string + + // Timestamps + ConnectedAt time.Time + LastHeartbeat time.Time + + // Counters + HeartbeatCount atomic.Int64 + CommandCount atomic.Int64 + + // Widget state cache (widget_id → last value) + widgetState sync.Map // map[string]any +} + +// ReadMessage reads and decodes the next CDAP JSON message from the WebSocket. +func (dc *DeviceConn) ReadMessage(ctx context.Context) (*Message, error) { + typ, data, err := dc.conn.Read(ctx) + if err != nil { + return nil, err + } + if typ != websocket.MessageText { + return nil, fmt.Errorf("expected text frame, got %v", typ) + } + + var msg Message + if err := json.Unmarshal(data, &msg); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + return &msg, nil +} + +// WriteMessage encodes and sends a CDAP JSON message on the WebSocket. +func (dc *DeviceConn) WriteMessage(ctx context.Context, msg *Message) error { + data, err := json.Marshal(msg) + if err != nil { + return err + } + dc.mu.Lock() + defer dc.mu.Unlock() + return dc.conn.Write(ctx, websocket.MessageText, data) +} + +// Close closes the underlying WebSocket connection. +func (dc *DeviceConn) Close(code websocket.StatusCode, reason string) { + dc.conn.Close(code, reason) +} + +// sendError sends a protocol error message to the client. +func sendError(ctx context.Context, conn *websocket.Conn, code int, message string) { + payload, _ := json.Marshal(ErrorPayload{Code: code, Message: message}) + msg := Message{ + Type: "error", + Timestamp: time.Now().UTC().Format(time.RFC3339), + Payload: payload, + } + data, _ := json.Marshal(msg) + conn.Write(ctx, websocket.MessageText, data) +} + +// sendMessage is a convenience for sending typed payloads to a connection. +func sendMessage(ctx context.Context, conn *websocket.Conn, msgType string, payload any) error { + payloadData, err := json.Marshal(payload) + if err != nil { + return err + } + msg := Message{ + Type: msgType, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Payload: payloadData, + } + data, err := json.Marshal(msg) + if err != nil { + return err + } + return conn.Write(ctx, websocket.MessageText, data) +} diff --git a/betterdesk-server/config/config.go b/betterdesk-server/config/config.go index f0ce3ebe..8715b708 100644 --- a/betterdesk-server/config/config.go +++ b/betterdesk-server/config/config.go @@ -83,6 +83,11 @@ type Config struct { // "managed" - New devices need to be approved or have a valid token // "locked" - Only devices with valid tokens can register EnrollmentMode string + + // CDAP Gateway + CDAPPort int // WebSocket gateway port (default 21122) + CDAPEnabled bool // Enable CDAP gateway (default false) + CDAPTLS bool // Enable TLS on CDAP port } // DefaultConfig returns a Config with sensible defaults. @@ -97,6 +102,7 @@ func DefaultConfig() *Config { JWTExpiry: 24, RelayMaxConnsIP: 20, EnrollmentMode: EnrollmentModeOpen, // Backward compatible default + CDAPPort: 21122, } } @@ -215,6 +221,17 @@ func (c *Config) LoadEnv() { c.EnrollmentMode = mode } } + if v := os.Getenv("CDAP_PORT"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + c.CDAPPort = n + } + } + if strings.ToUpper(os.Getenv("CDAP_ENABLED")) == "Y" { + c.CDAPEnabled = true + } + if strings.ToUpper(os.Getenv("CDAP_TLS")) == "Y" { + c.CDAPTLS = true + } } // NATTestPort returns the NAT test port (signal port - 1). @@ -327,3 +344,8 @@ func (c *Config) RelayTLSEnabled() bool { func (c *Config) APITLSEnabled() bool { return (c.TLSApi || c.ForceHTTPS) && c.HasTLSCert() } + +// CDAPTLSEnabled returns true if TLS should be used for the CDAP WebSocket gateway. +func (c *Config) CDAPTLSEnabled() bool { + return c.CDAPTLS && c.HasTLSCert() +} diff --git a/betterdesk-server/db/database.go b/betterdesk-server/db/database.go index 69be000a..98df34ed 100644 --- a/betterdesk-server/db/database.go +++ b/betterdesk-server/db/database.go @@ -26,7 +26,9 @@ type Peer struct { DeletedAt *time.Time `json:"deleted_at,omitempty"` Note string `json:"note,omitempty"` Tags string `json:"tags,omitempty"` - HeartbeatSeq int64 `json:"-"` // internal heartbeat counter + DeviceType string `json:"device_type,omitempty"` // CDAP: desktop, mobile, headless, kiosk, etc. + LinkedPeerID string `json:"linked_peer_id,omitempty"` // CDAP: paired device (e.g., mobile→desktop) + HeartbeatSeq int64 `json:"-"` // internal heartbeat counter } // ServerConfig stores runtime configuration in the database. @@ -139,6 +141,9 @@ type Database interface { ChangePeerID(oldID, newID string) error GetIDChangeHistory(id string) ([]*IDChangeHistory, error) + // CDAP: linked device queries + GetLinkedPeers(id string) ([]*Peer, error) + // Tags UpdatePeerTags(id, tags string) error ListPeersByTag(tag string) ([]*Peer, error) diff --git a/betterdesk-server/db/postgres.go b/betterdesk-server/db/postgres.go index 38376861..2322a057 100644 --- a/betterdesk-server/db/postgres.go +++ b/betterdesk-server/db/postgres.go @@ -195,6 +195,9 @@ func (pg *PostgresDB) Migrate() error { `ALTER TABLE peers ADD COLUMN IF NOT EXISTS tags TEXT NOT NULL DEFAULT ''`, // peers: heartbeat_seq (added in v2.3.0) `ALTER TABLE peers ADD COLUMN IF NOT EXISTS heartbeat_seq BIGINT NOT NULL DEFAULT 0`, + // peers: CDAP device type and linked peer (added in v2.5.0) + `ALTER TABLE peers ADD COLUMN IF NOT EXISTS device_type TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE peers ADD COLUMN IF NOT EXISTS linked_peer_id TEXT NOT NULL DEFAULT ''`, } for _, ddl := range columnMigrations { @@ -212,7 +215,8 @@ func (pg *PostgresDB) Migrate() error { const peerColumns = `id, uuid, pk, ip, "user", hostname, os, version, status, nat_type, last_online, created_at, disabled, banned, ban_reason, banned_at, - soft_deleted, deleted_at, note, tags, heartbeat_seq` + soft_deleted, deleted_at, note, tags, heartbeat_seq, + device_type, linked_peer_id` // scanPeer scans a row into a Peer struct using nullable types. func scanPeer(row pgx.Row) (*Peer, error) { @@ -225,6 +229,7 @@ func scanPeer(row pgx.Row) (*Peer, error) { &lastOnline, &p.CreatedAt, &p.Disabled, &p.Banned, &p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt, &p.Note, &p.Tags, &p.HeartbeatSeq, + &p.DeviceType, &p.LinkedPeerID, ) if err != nil { return nil, err @@ -418,11 +423,11 @@ func (pg *PostgresDB) IsPeerSoftDeleted(id string) (bool, error) { return deleted, err } -// UpdatePeerFields updates specific peer fields (note, user, tags). +// UpdatePeerFields updates specific peer fields (note, user, tags, device_type, linked_peer_id). // Only provided keys are updated; others are left unchanged. -// Allowed keys: "note", "user", "tags". +// Allowed keys: "note", "user", "tags", "device_type", "linked_peer_id". func (pg *PostgresDB) UpdatePeerFields(id string, fields map[string]string) error { - allowed := map[string]string{"note": "note", "user": `"user"`, "tags": "tags"} + allowed := map[string]string{"note": "note", "user": `"user"`, "tags": "tags", "device_type": "device_type", "linked_peer_id": "linked_peer_id"} setClauses := []string{} args := []interface{}{} idx := 1 @@ -471,11 +476,13 @@ func (pg *PostgresDB) ChangePeerID(oldID, newID string) error { INSERT INTO peers (id, uuid, pk, ip, "user", hostname, os, version, status, nat_type, last_online, created_at, disabled, banned, ban_reason, banned_at, - soft_deleted, deleted_at, note, tags, heartbeat_seq) + soft_deleted, deleted_at, note, tags, heartbeat_seq, + device_type, linked_peer_id) SELECT $1, uuid, pk, ip, "user", hostname, os, version, status, nat_type, last_online, created_at, disabled, banned, ban_reason, banned_at, - soft_deleted, deleted_at, note, tags, heartbeat_seq + soft_deleted, deleted_at, note, tags, heartbeat_seq, + device_type, linked_peer_id FROM peers WHERE id = $2 FOR UPDATE`, newID, oldID) if err != nil { return fmt.Errorf("db: ChangePeerID insert: %w", err) @@ -515,6 +522,29 @@ func (pg *PostgresDB) GetIDChangeHistory(id string) ([]*IDChangeHistory, error) return history, rows.Err() } +// GetLinkedPeers returns all non-deleted peers that have linked_peer_id matching the given ID. +func (pg *PostgresDB) GetLinkedPeers(id string) ([]*Peer, error) { + rows, err := pg.pool.Query(pg.ctx, ` + SELECT `+peerColumns+` + FROM peers + WHERE soft_deleted = FALSE AND linked_peer_id = $1 + ORDER BY id`, id) + if err != nil { + return nil, fmt.Errorf("db: GetLinkedPeers: %w", err) + } + defer rows.Close() + + var peers []*Peer + for rows.Next() { + p, err := scanPeer(rows) + if err != nil { + return nil, fmt.Errorf("db: GetLinkedPeers scan: %w", err) + } + peers = append(peers, p) + } + return peers, rows.Err() +} + // ── Tags ────────────────────────────────────────────────────────────── // UpdatePeerTags updates the tags field for a peer. diff --git a/betterdesk-server/db/sqlite.go b/betterdesk-server/db/sqlite.go index 6a6a898b..eb7758d7 100644 --- a/betterdesk-server/db/sqlite.go +++ b/betterdesk-server/db/sqlite.go @@ -181,6 +181,9 @@ func (s *SQLiteDB) Migrate() error { {"peers", "tags", `ALTER TABLE peers ADD COLUMN tags TEXT DEFAULT ''`}, // peers: heartbeat_seq (added in v2.3.0) {"peers", "heartbeat_seq", `ALTER TABLE peers ADD COLUMN heartbeat_seq INTEGER DEFAULT 0`}, + // peers: CDAP device type and linked peer (added in v2.5.0) + {"peers", "device_type", `ALTER TABLE peers ADD COLUMN device_type TEXT DEFAULT ''`}, + {"peers", "linked_peer_id", `ALTER TABLE peers ADD COLUMN linked_peer_id TEXT DEFAULT ''`}, } for _, m := range columnMigrations { @@ -259,13 +262,15 @@ func (s *SQLiteDB) GetPeer(id string) (*Peer, error) { SELECT id, uuid, pk, ip, user, hostname, os, version, status, nat_type, last_online, created_at, disabled, banned, ban_reason, banned_at, - soft_deleted, deleted_at, note, tags, heartbeat_seq + soft_deleted, deleted_at, note, tags, heartbeat_seq, + device_type, linked_peer_id FROM peers WHERE id = ?`, id).Scan( &p.ID, &p.UUID, &p.PK, &p.IP, &p.User, &p.Hostname, &p.OS, &p.Version, &p.Status, &p.NATType, &lastOnline, &createdAt, &p.Disabled, &p.Banned, &p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt, &p.Note, &p.Tags, &p.HeartbeatSeq, + &p.DeviceType, &p.LinkedPeerID, ) if err == sql.ErrNoRows { return nil, nil @@ -360,7 +365,8 @@ func (s *SQLiteDB) ListPeers(includeDeleted bool) ([]*Peer, error) { query := `SELECT id, uuid, pk, ip, user, hostname, os, version, status, nat_type, last_online, created_at, disabled, banned, ban_reason, banned_at, - soft_deleted, deleted_at, note, tags, heartbeat_seq + soft_deleted, deleted_at, note, tags, heartbeat_seq, + device_type, linked_peer_id FROM peers` if !includeDeleted { query += ` WHERE soft_deleted = 0` @@ -383,6 +389,7 @@ func (s *SQLiteDB) ListPeers(includeDeleted bool) ([]*Peer, error) { &lastOnline, &createdAt, &p.Disabled, &p.Banned, &p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt, &p.Note, &p.Tags, &p.HeartbeatSeq, + &p.DeviceType, &p.LinkedPeerID, ); err != nil { return nil, fmt.Errorf("db: ListPeers scan: %w", err) } @@ -493,14 +500,14 @@ func (s *SQLiteDB) IsPeerSoftDeleted(id string) (bool, error) { return deleted, err } -// UpdatePeerFields updates specific peer fields (note, user, tags). +// UpdatePeerFields updates specific peer fields (note, user, tags, device_type, linked_peer_id). // Only provided keys are updated; others are left unchanged. -// Allowed keys: "note", "user", "tags". +// Allowed keys: "note", "user", "tags", "device_type", "linked_peer_id". func (s *SQLiteDB) UpdatePeerFields(id string, fields map[string]string) error { s.mu.Lock() defer s.mu.Unlock() - allowed := map[string]bool{"note": true, "user": true, "tags": true} + allowed := map[string]bool{"note": true, "user": true, "tags": true, "device_type": true, "linked_peer_id": true} setClauses := []string{} args := []interface{}{} for k, v := range fields { @@ -544,11 +551,13 @@ func (s *SQLiteDB) ChangePeerID(oldID, newID string) error { INSERT INTO peers (id, uuid, pk, ip, user, hostname, os, version, status, nat_type, last_online, created_at, disabled, banned, ban_reason, banned_at, - soft_deleted, deleted_at, note, tags, heartbeat_seq) + soft_deleted, deleted_at, note, tags, heartbeat_seq, + device_type, linked_peer_id) SELECT ?, uuid, pk, ip, user, hostname, os, version, status, nat_type, last_online, created_at, disabled, banned, ban_reason, banned_at, - soft_deleted, deleted_at, note, tags, heartbeat_seq + soft_deleted, deleted_at, note, tags, heartbeat_seq, + device_type, linked_peer_id FROM peers WHERE id = ?`, newID, oldID) if err != nil { return fmt.Errorf("db: ChangePeerID insert: %w", err) @@ -595,6 +604,47 @@ func (s *SQLiteDB) GetIDChangeHistory(id string) ([]*IDChangeHistory, error) { return history, rows.Err() } +// GetLinkedPeers returns all non-deleted peers that have linked_peer_id matching the given ID. +func (s *SQLiteDB) GetLinkedPeers(id string) ([]*Peer, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + rows, err := s.db.Query(` + SELECT id, uuid, pk, ip, user, hostname, os, version, status, nat_type, + last_online, created_at, disabled, banned, ban_reason, banned_at, + soft_deleted, deleted_at, note, tags, heartbeat_seq, + device_type, linked_peer_id + FROM peers + WHERE soft_deleted = 0 AND linked_peer_id = ? + ORDER BY id`, id) + if err != nil { + return nil, fmt.Errorf("db: GetLinkedPeers: %w", err) + } + defer rows.Close() + + var peers []*Peer + for rows.Next() { + p := &Peer{} + var lastOnline, createdAt, bannedAt, deletedAt sql.NullString + if err := rows.Scan( + &p.ID, &p.UUID, &p.PK, &p.IP, &p.User, &p.Hostname, + &p.OS, &p.Version, &p.Status, &p.NATType, + &lastOnline, &createdAt, &p.Disabled, &p.Banned, + &p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt, + &p.Note, &p.Tags, &p.HeartbeatSeq, + &p.DeviceType, &p.LinkedPeerID, + ); err != nil { + return nil, fmt.Errorf("db: GetLinkedPeers scan: %w", err) + } + p.LastOnline = parseTime(lastOnline) + p.CreatedAt = parseTime(createdAt) + p.BannedAt = parseTimePtr(bannedAt) + p.DeletedAt = parseTimePtr(deletedAt) + peers = append(peers, p) + } + return peers, rows.Err() +} + // GetConfig retrieves a configuration value by key. func (s *SQLiteDB) GetConfig(key string) (string, error) { s.mu.RLock() @@ -657,7 +707,8 @@ func (s *SQLiteDB) ListPeersByTag(tag string) ([]*Peer, error) { rows, err := s.db.Query(` SELECT id, uuid, pk, ip, user, hostname, os, version, status, nat_type, last_online, created_at, disabled, banned, ban_reason, banned_at, - soft_deleted, deleted_at, note, tags, heartbeat_seq + soft_deleted, deleted_at, note, tags, heartbeat_seq, + device_type, linked_peer_id FROM peers WHERE soft_deleted = 0 AND tags LIKE ? ESCAPE '\' ORDER BY id`, pattern) @@ -676,6 +727,7 @@ func (s *SQLiteDB) ListPeersByTag(tag string) ([]*Peer, error) { &lastOnline, &createdAt, &p.Disabled, &p.Banned, &p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt, &p.Note, &p.Tags, &p.HeartbeatSeq, + &p.DeviceType, &p.LinkedPeerID, ); err != nil { return nil, fmt.Errorf("db: ListPeersByTag scan: %w", err) } diff --git a/betterdesk-server/events/bus.go b/betterdesk-server/events/bus.go index 8819e189..d212cc21 100644 --- a/betterdesk-server/events/bus.go +++ b/betterdesk-server/events/bus.go @@ -21,6 +21,7 @@ const ( EventPeerBanned EventType = "peer_banned" EventPeerUnbanned EventType = "peer_unbanned" EventPeerDeleted EventType = "peer_deleted" + EventPeerRevoked EventType = "peer_revoked" EventPeerIDChanged EventType = "peer_id_changed" EventBlocklistAdd EventType = "blocklist_add" EventBlocklistRemove EventType = "blocklist_remove" diff --git a/betterdesk-server/main.go b/betterdesk-server/main.go index 85fa86a8..5e3a39e1 100644 --- a/betterdesk-server/main.go +++ b/betterdesk-server/main.go @@ -20,6 +20,7 @@ import ( "github.com/unitronix/betterdesk-server/api" "github.com/unitronix/betterdesk-server/audit" "github.com/unitronix/betterdesk-server/auth" + "github.com/unitronix/betterdesk-server/cdap" "github.com/unitronix/betterdesk-server/config" "github.com/unitronix/betterdesk-server/crypto" "github.com/unitronix/betterdesk-server/db" @@ -275,11 +276,30 @@ func main() { apiSrv.SetMetrics(mc) apiSrv.SetJWTManager(jwtManager) apiSrv.SetKeyPair(kp) + + // CDAP Gateway (optional — custom device automation protocol) + var cdapGw *cdap.Gateway + if cfg.CDAPEnabled { + cdapGw = cdap.New(cfg, database, sig.PeerMap(), sig.EventBus()) + cdapGw.SetBlocklist(blocklist) + cdapGw.SetAuditLogger(auditLogger) + cdapGw.SetJWTManager(jwtManager) + cdapGw.SetVersion(Version) + apiSrv.SetCDAPGateway(cdapGw) + } + if err := apiSrv.Start(ctx); err != nil { log.Fatalf("Failed to start API server: %v", err) } defer apiSrv.Stop() + if cdapGw != nil { + if err := cdapGw.Start(ctx); err != nil { + log.Fatalf("Failed to start CDAP gateway: %v", err) + } + defer cdapGw.Stop() + } + adminSrv.SetPeerMap(sig.PeerMap()) if cfg.AdminPassword != "" { adminSrv.SetAdminPassword(cfg.AdminPassword) @@ -467,6 +487,9 @@ func parseFlags() *config.Config { flag.BoolVar(&cfg.TLSSignal, "tls-signal", cfg.TLSSignal, "Enable TLS on signal TCP/WS ports (requires --tls-cert and --tls-key)") flag.BoolVar(&cfg.TLSRelay, "tls-relay", cfg.TLSRelay, "Enable TLS on relay TCP/WS ports (requires --tls-cert and --tls-key)") flag.BoolVar(&cfg.TLSApi, "tls-api", cfg.TLSApi, "Enable TLS on HTTP API port (requires --tls-cert and --tls-key)") + flag.IntVar(&cfg.CDAPPort, "cdap-port", cfg.CDAPPort, "CDAP WebSocket gateway port (default 21122)") + flag.BoolVar(&cfg.CDAPEnabled, "cdap", cfg.CDAPEnabled, "Enable CDAP gateway for custom devices") + flag.BoolVar(&cfg.CDAPTLS, "tls-cdap", cfg.CDAPTLS, "Enable TLS on CDAP gateway port (requires --tls-cert and --tls-key)") showVersion := flag.Bool("version", false, "Show version and exit") flag.Parse() diff --git a/betterdesk-server/peer/map.go b/betterdesk-server/peer/map.go index 5609c416..f837c79e 100644 --- a/betterdesk-server/peer/map.go +++ b/betterdesk-server/peer/map.go @@ -81,6 +81,22 @@ func (e *Entry) IsExpired(timeout time.Duration) bool { return time.Since(e.LastReg) > timeout } +// CloseConnections closes any open TCP or WebSocket connections held by this entry. +// Safe to call multiple times; ignores nil connections and close errors. +func (e *Entry) CloseConnections() { + if e.TCPConn != nil { + e.TCPConn.Close() + e.TCPConn = nil + } + if e.WSConn != nil { + // WSConn is interface{} — attempt to close if it implements io.Closer. + if closer, ok := e.WSConn.(interface{ Close() error }); ok { + closer.Close() + } + e.WSConn = nil + } +} + // ComputeStatus computes the current status tier based on missed heartbeats. func (e *Entry) ComputeStatus(degradedThreshold, criticalThreshold int32) Status { if e.MissedBeats >= criticalThreshold { @@ -246,6 +262,7 @@ func (m *Map) UpdateHeartbeat(id string, addr *net.UDPAddr, serial int32) bool { } // Remove deletes a peer from the map. Returns the removed entry (nil if not found). +// Closes any open TCP/WS connections to force immediate disconnect. func (m *Map) Remove(id string) *Entry { m.mu.Lock() defer m.mu.Unlock() @@ -253,6 +270,7 @@ func (m *Map) Remove(id string) *Entry { if !ok { return nil } + e.CloseConnections() delete(m.entries, id) return e } @@ -322,6 +340,7 @@ func (m *Map) CleanExpired(timeout time.Duration) []string { for id, e := range m.entries { if time.Since(e.LastReg) > timeout { expired = append(expired, id) + e.CloseConnections() delete(m.entries, id) } } diff --git a/betterdesk_wallpaper.png b/betterdesk_wallpaper.png new file mode 100644 index 00000000..d3363b55 Binary files /dev/null and b/betterdesk_wallpaper.png differ diff --git a/docs/CDAP_IMPLEMENTATION_PLAN.md b/docs/CDAP_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..ecf9c784 --- /dev/null +++ b/docs/CDAP_IMPLEMENTATION_PLAN.md @@ -0,0 +1,831 @@ +# CDAP Implementation Plan + +> **Version**: 1.0.0 +> **Status**: Draft +> **Created**: 2026-03-19 +> **Depends on**: [CUSTOM_DEVICE_API.md](CUSTOM_DEVICE_API.md) v0.2.0 + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Architecture Decisions](#architecture-decisions) +3. [Authentication & 2FA Integration](#authentication--2fa-integration) +4. [RustDesk Client Synchronization](#rustdesk-client-synchronization) +5. [Implementation Phases](#implementation-phases) +6. [File-Level Change Map](#file-level-change-map) +7. [Testing Strategy](#testing-strategy) +8. [Migration & Backward Compatibility](#migration--backward-compatibility) +9. [Risk Assessment](#risk-assessment) + +--- + +## Overview + +This document translates the CDAP specification (CUSTOM_DEVICE_API.md) into a concrete implementation plan covering: + +- **CDAP Gateway** — WebSocket server on port 21122 in Go server +- **Auth integration** — CDAP clients authenticate via the same user/2FA system as the Node.js panel and RustDesk clients +- **RustDesk synchronization** — CDAP native desktop clients coexist with RustDesk clients in the same peer table, address books, connection history, and panel views +- **Media channel** — Binary frame relay for remote desktop, video, audio, input +- **Native BetterDesk client** — Desktop agent that combines remote desktop + OS management + +### Core Principle + +> **One identity, one authority.** A user account created in the Node.js panel works for panel login, RustDesk client login, AND CDAP native client login — same credentials, same 2FA, same RBAC. + +--- + +## Architecture Decisions + +### AD-1: Auth Source of Truth + +**Decision**: The Go server is the auth source of truth for all protocol-level authentication (RustDesk clients, CDAP clients, API keys). The Node.js panel is the auth source of truth for panel UI sessions (cookie-based). + +**Rationale**: RustDesk clients already authenticate via Go server's `/api/login` (port 21114). CDAP clients will use the same endpoint or a CDAP-specific auth message that routes to the same Go auth backend. This avoids auth duplication and ensures 2FA state is consistent. + +``` +┌──────────────────────────────────────────────────────┐ +│ Authentication Flow │ +│ │ +│ Panel User → Node.js session (cookie) │ +│ └─ 2FA check → Node.js auth.db (TOTP) │ +│ │ +│ RustDesk Client → Go server /api/login (JWT) │ +│ └─ 2FA check → Go server TOTP (5-min partial) │ +│ │ +│ CDAP Client → Go server /ws/cdap auth msg (JWT) │ +│ └─ 2FA check → Go server TOTP (same path) │ +│ │ +│ Panel ↔ Go → X-API-Key header (.api_key file) │ +│ │ +│ ──── Shared ───────────────────────────────────── │ +│ Users table: Go server db (users) │ +│ TOTP secrets: Go server db (users.totp_secret) │ +│ Roles/RBAC: Go server db (users.role) │ +│ API keys: Go server db (api_keys) │ +│ Panel sessions: Node.js auth.db (separate, UI only) │ +└──────────────────────────────────────────────────────┘ +``` + +### AD-2: Dual Auth Database Convergence + +**Current state**: Node.js panel has `auth.db` (users, sessions) and Go server has `db_v2.sqlite3` (users, api_keys). Both have user tables with potentially different passwords and TOTP states. + +**Decision**: Phase 1 keeps dual DBs but syncs user credentials. Phase 3+ converges to Go server as sole user store, with Node.js panel delegating auth to Go server via REST API. + +| Phase | Auth Model | User Store | +|-------|-----------|------------| +| Current | Dual (Node.js auth.db + Go users table) | Both, may diverge | +| Phase 1 CDAP | CDAP uses Go auth only | Go server primary for clients | +| Phase 3 | Node.js delegates to Go for user CRUD | Go server sole source | +| Long term | Single user store in Go, Node.js is pure UI | Go server | + +### AD-3: CDAP Auth Message vs HTTP Login + +**Decision**: CDAP clients authenticate **within the WebSocket connection** using a JSON `auth` message (not a separate HTTP `/api/login` call). The Go server validates credentials using the same `auth.VerifyPassword()` and `auth.ValidateTOTP()` functions. + +**Rationale**: +- Single connection setup (no HTTP pre-auth + WS upgrade dance) +- Works behind restrictive firewalls that only allow WS on one port +- Consistent with the "one port, one protocol" CDAP philosophy +- Server can immediately associate the WS connection with the authenticated user + +```json +// Step 1: Client sends auth message +{ + "type": "auth", + "payload": { + "method": "user_password", + "username": "operator1", + "password": "...", + "device_id": "CDAP-A7F3B210", + "client_version": "1.0.0" + } +} + +// Step 2a: Server responds (no 2FA) +{ + "type": "auth_result", + "payload": { + "success": true, + "token": "jwt_24h", + "role": "operator", + "device_id": "CDAP-A7F3B210" + } +} + +// Step 2b: Server responds (2FA required) +{ + "type": "auth_result", + "payload": { + "success": false, + "requires_2fa": true, + "tfa_type": "totp", + "partial_token": "jwt_5min" + } +} + +// Step 3: Client sends 2FA code +{ + "type": "auth_2fa", + "payload": { + "partial_token": "jwt_5min", + "code": "123456" + } +} + +// Step 4: Server responds with full auth +{ + "type": "auth_result", + "payload": { + "success": true, + "token": "jwt_24h", + "role": "operator", + "device_id": "CDAP-A7F3B210" + } +} + +// Alternative: API key auth (for unattended bridges/agents) +{ + "type": "auth", + "payload": { + "method": "api_key", + "key": "...", + "device_id": "CDAP-A7F3B210" + } +} +``` + +### AD-4: RustDesk Client Synchronization Strategy + +**Problem**: When a BetterDesk native client (CDAP) runs on the same machine as a RustDesk client, or replaces it, the following must be synchronized: + +| Resource | RustDesk Source | CDAP Source | Sync Strategy | +|----------|-----------------|-------------|---------------| +| Device ID | Numeric (e.g., `1340238749`) | `CDAP-` prefix | **Map**: Go server maintains `cdap_peer_id` ↔ `rustdesk_peer_id` | +| Address book | `/api/ab` (Go server) | Same `/api/ab` | **Shared**: Same user, same AB | +| Connection history | Audit log (conn events) | Audit log (conn events) | **Shared**: Same audit table | +| User account | Go `users` table | Same | **Shared**: Same credentials | +| Peers list | `peers` table | Same `peers` table | **Shared**: `device_type` column | +| Online status | Signal server in-memory map | CDAP gateway in-memory | **Merged**: Panel queries both | +| Tags/Groups | `peers.tags` | Same | **Shared**: Same field | + +### AD-5: Native Client ↔ RustDesk Interoperability + +**Scenario**: User A has BetterDesk native client. User B has RustDesk client. Can User A remote-control User B's machine? + +**Decision**: **Not directly** — CDAP media channel is not compatible with RustDesk's protobuf signal/relay protocol. But the server can **bridge** the connection: + +| From → To | Method | Latency | Status | +|-----------|--------|---------|--------| +| RustDesk → RustDesk | Native (signal/relay) | Low | ✅ Works now | +| CDAP → CDAP | Native (CDAP media) | Low | Phase 5 | +| CDAP → RustDesk | **Server-side protocol bridge** | Medium (+50ms) | Phase 6 | +| RustDesk → CDAP | Not needed (CDAP is superset) | — | Not planned | + +The server-side protocol bridge (Phase 6) is complex and optional. The primary migration path is: install BetterDesk native client → device appears as both `rustdesk` AND `desktop` types → gradually deprecate RustDesk client on that machine. + +### AD-6: Unified Device Identity + +When a machine has both RustDesk and BetterDesk native clients: + +``` +┌─────────────────────────────────────────┐ +│ Machine: PC-Design-03 │ +│ │ +│ ┌──────────────┐ ┌─────────────────┐ │ +│ │ RustDesk │ │ BetterDesk │ │ +│ │ Client │ │ Native Client │ │ +│ │ │ │ │ │ +│ │ ID: 892734561 │ │ ID: CDAP-D2E9F4│ │ +│ │ Port: 21116 │ │ Port: 21122 │ │ +│ └──────┬───────┘ └───────┬────────┘ │ +│ │ │ │ +└─────────┼───────────────────┼───────────┘ + │ │ + ▼ ▼ +┌───────────────────────────────────────┐ +│ BetterDesk Server │ +│ │ +│ peers table: │ +│ ┌─────────────┬──────────┬─────────┐ │ +│ │ 892734561 │ rustdesk │ linked │ │ +│ │ CDAP-D2E9F4 │ desktop │ linked │ │ +│ └─────────────┴──────────┴─────────┘ │ +│ │ +│ peer_links table (NEW): │ +│ ┌──────────────┬───────────────────┐ │ +│ │ 892734561 │ CDAP-D2E9F4 │ │ +│ │ (rustdesk) │ (desktop) │ │ +│ └──────────────┴───────────────────┘ │ +│ │ +│ Panel shows: 1 machine, 2 protocols │ +│ Admin can merge or keep separate │ +└───────────────────────────────────────┘ +``` + +--- + +## Authentication & 2FA Integration + +### CDAP Auth Flow (Detailed) + +``` + ┌──────────────┐ + │ CDAP Client │ + │ (bridge/ │ + │ agent) │ + └──────┬───────┘ + │ + WS connect :21122 + │ + ▼ + ┌───────────────┐ + │ CDAP Gateway │ + │ (Go server) │ + └───────┬───────┘ + │ + ┌───────────────┼───────────────┐ + │ │ │ + method=api_key method=user_password method=device_token + │ │ │ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────────┐ + │ Validate │ │ Validate │ │ Validate │ + │ API key │ │ user/pass│ │ device token │ + │ (SHA256) │ │ (PBKDF2) │ │ (enrollment) │ + └──────┬───┘ └──────┬───┘ └──────┬───────┘ + │ │ │ + │ ┌──────┴──────┐ │ + │ │ TOTP check │ │ + │ │ if enabled │ │ + │ └──────┬──────┘ │ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────────────────────────────┐ + │ Session established │ + │ - JWT token issued (24h) │ + │ - Role assigned (admin/operator/viewer) │ + │ - Device ID confirmed/assigned │ + │ - WS connection upgraded to authed │ + │ - Audit log: auth event │ + └─────────────────────────────────────────┘ +``` + +### Auth Methods + +| Method | Use Case | 2FA | Description | +|--------|----------|-----|-------------| +| `user_password` | Interactive (operator login on desktop) | Yes (if enabled) | Same credentials as panel login | +| `api_key` | Unattended (IoT bridges, SCADA, headless) | No | API key from Go server `api_keys` table | +| `device_token` | First-time enrollment | No | One-time enrollment token (becomes API key) | + +### 2FA for Native Desktop Client + +The BetterDesk native desktop client needs a full 2FA UI: + +``` +┌──────────────────────────────────────┐ +│ BetterDesk - Login │ +│ │ +│ Server: betterdesk.example.com │ +│ │ +│ Username: [operator1 ] │ +│ Password: [•••••••• ] │ +│ │ +│ [Login] │ +└──────────────────────────────────────┘ + │ + (server responds: requires_2fa) + │ + ▼ +┌──────────────────────────────────────┐ +│ BetterDesk - 2FA │ +│ │ +│ Enter authenticator code: │ +│ │ +│ [1] [2] [3] [4] [5] [6] │ +│ │ +│ ○ Use recovery code instead │ +│ │ +│ [Verify] [Cancel] │ +└──────────────────────────────────────┘ + │ + (verified → full session) + │ + ▼ +┌──────────────────────────────────────┐ +│ ✓ Connected as operator1 │ +│ Server: betterdesk.example.com │ +│ Device ID: CDAP-D2E9F4 │ +│ │ +│ This device is ready for remote │ +│ connections and management. │ +│ │ +│ [Settings] [Minimize to tray] │ +└──────────────────────────────────────┘ +``` + +### 2FA Setup for CDAP Clients + +CDAP clients do NOT set up 2FA themselves — 2FA is configured through the Node.js panel: + +``` +Panel (Node.js) Go Server CDAP Client + │ │ │ + │── POST /api/auth/totp/setup ──►│ │ + │◄── {qrCode, secret} ──────────│ │ + │ │ │ + │ (user scans QR in Authy) │ │ + │ │ │ + │── POST /api/auth/totp/enable ─►│ │ + │ {code: "123456"} │── update users table ──│ + │◄── {recoveryCodes} ───────────│ │ + │ │ │ + │ │ (next login) │ + │ │◄── auth {user/pass} ───│ + │ │──► auth_result │ + │ │ {requires_2fa} ─────►│ + │ │ │ + │ │◄── auth_2fa {code} ────│ + │ │──► auth_result │ + │ │ {success, jwt} ─────►│ +``` + +### Token Refresh for Long-Running Agents + +Desktop agents and IoT bridges run 24/7. JWT tokens expire after 24h. CDAP supports token refresh: + +```json +// Client sends before token expires +{ + "type": "token_refresh", + "payload": { + "token": "current_jwt_about_to_expire" + } +} + +// Server responds with new token (no re-auth needed) +{ + "type": "token_refreshed", + "payload": { + "token": "new_jwt_24h", + "expires_at": "2026-03-21T14:30:00Z" + } +} +``` + +Rules: +- Refresh only works if current token is still valid (not expired) +- Max refresh chain: 30 days (then full re-auth required) +- Admin can revoke refresh chain from panel (force re-login) +- API key auth does not need refresh (keys don't expire by default) + +--- + +## RustDesk Client Synchronization + +### Problem Statement + +A BetterDesk deployment typically has a mix of: +- **Existing RustDesk clients** — already deployed on workstations, using RustDesk protocol on port 21116 +- **New BetterDesk native clients** (CDAP) — being rolled out, using CDAP on port 21122 +- **IoT/SCADA bridges** (CDAP) — new devices via CDAP + +These must coexist in the same panel, same device list, same address books, same permissions. + +### Sync Points + +#### 1. Unified Peer Table + +Both RustDesk and CDAP devices live in the same `peers` table: + +```sql +-- Existing columns (unchanged) +id TEXT PRIMARY KEY, -- "1340238749" (RustDesk) or "CDAP-D2E9F4" (CDAP) +uuid TEXT, +pk BLOB, +hostname TEXT, +os TEXT, +... + +-- Existing CDAP columns (from Phase 1) +device_type TEXT DEFAULT 'rustdesk', -- rustdesk, scada, iot, os_agent, desktop, ... +manifest_json TEXT, +bridge_id TEXT, +cdap_session_id TEXT, + +-- NEW: Linking columns +linked_peer_id TEXT, -- Cross-reference for dual-client machines +link_type TEXT, -- "auto" (hostname match) or "manual" (admin linked) +auth_user TEXT, -- Username who logged in on this device +last_auth_at TIMESTAMPTZ -- Last CDAP/RustDesk login time +``` + +#### 2. Address Book Convergence + +RustDesk clients and CDAP clients share the same address book per user: + +``` +User "operator1" logs in from: + ├── Node.js panel → sees all devices in panel + ├── RustDesk client → GET /api/ab → sees address book + └── CDAP native client → GET /api/ab (same endpoint) → sees address book + +Address book contains: + ├── RustDesk device IDs (numeric) + └── CDAP device IDs (CDAP- prefix) + +Both client types can: + ├── Add devices to address book + ├── Create tags/groups + └── Sync across devices +``` + +The Go server's `/api/ab` endpoint already works for RustDesk clients. CDAP clients use the **same endpoint** with the same JWT token, so address books are automatically synchronized. + +#### 3. Connection History + +When a CDAP client connects to another device (remote desktop): + +```json +// Audit log entry (same format as RustDesk connections) +{ + "action": "connection", + "details": { + "from_id": "CDAP-D2E9F4", + "from_type": "desktop", + "to_id": "892734561", + "to_type": "rustdesk", + "protocol": "cdap_media", + "initiated_by": "operator1", + "duration_sec": 1847, + "timestamp": "2026-03-19T14:30:00Z" + } +} +``` + +The Node.js panel's connection history view shows both RustDesk and CDAP connections in the same timeline. + +#### 4. Online Status Merging + +The panel needs to query both connection pools: + +``` +Node.js Panel + │ + ├── GET /api/peers (Go server) + │ └── Go enriches with: + │ ├── RustDesk live status (signal server in-memory map) + │ └── CDAP live status (CDAP gateway in-memory map) + │ + └── Shows unified device list with live status from both protocols +``` + +Implementation: `handleListPeers` in Go server already overlays live status from signal peer map. Extended to also check CDAP gateway's active connections map. + +#### 5. Linked Device View + +When a machine runs both RustDesk and BetterDesk native clients: + +``` +Panel Device List: +┌──────────────────────────────────────────────────────────────┐ +│ PC-Design-03 [Linked] │ +│ ├── 🖥️ 892734561 | RustDesk | Online | Remote Desktop │ +│ └── 📱 CDAP-D2E9F4 | Desktop | Online | Desktop + Mgmt │ +│ │ +│ srv-prod-01 │ +│ └── 💻 CDAP-F9A2B1 | OS Agent | Online | System Management│ +│ │ +│ Boiler Room PLC │ +│ └── 🏭 CDAP-A7F3B2 | SCADA | Online | Widgets │ +└──────────────────────────────────────────────────────────────┘ +``` + +Auto-linking algorithm: +1. Same hostname + same user → auto-link +2. Same IP (within 5 min window) + same hostname → suggest link +3. Admin can manually link/unlink from panel + +--- + +## Implementation Phases + +### Phase 0: Preparation (1-2 days) + +| # | Task | Effort | Files | +|---|------|--------|-------| +| 0.1 | Add `device_type`, `manifest_json`, `linked_peer_id`, `auth_user` columns to peers | 0.5d | `db/sqlite.go`, `db/postgres.go` | +| 0.2 | Add `peer_links` table for device linking | 0.5d | `db/sqlite.go`, `db/postgres.go` | +| 0.3 | Update `handleListPeers` to return `device_type` field | 0.5d | `api/server.go` | +| 0.4 | Add device type filter to panel device list | 0.5d | `web-nodejs/views/`, `web-nodejs/routes/` | + +**Gate**: Device list shows `device_type` column. All existing RustDesk devices show as `rustdesk`. + +--- + +### Phase 1: CDAP Gateway Core (5-7 days) + +| # | Task | Effort | Files | +|---|------|--------|-------| +| 1.1 | Create `cdap/` package with Gateway struct | 1d | `cdap/gateway.go` (NEW) | +| 1.2 | WebSocket server on port 21122 (`gorilla/websocket`) | 1d | `cdap/gateway.go` | +| 1.3 | Auth handler — `user_password` method (reuse `auth.VerifyPassword`) | 0.5d | `cdap/auth.go` (NEW) | +| 1.4 | Auth handler — `api_key` method (reuse `auth.ValidateAPIKey`) | 0.5d | `cdap/auth.go` | +| 1.5 | 2FA handler — TOTP flow within WebSocket (reuse `auth.ValidateTOTP`) | 0.5d | `cdap/auth.go` | +| 1.6 | Rate limiting on auth messages (reuse `ratelimit.IPLimiter`) | 0.5d | `cdap/auth.go` | +| 1.7 | Manifest parser + validation (JSON Schema) | 1d | `cdap/manifest.go` (NEW) | +| 1.8 | Device registration — write to `peers` table with `device_type` | 0.5d | `cdap/handler.go` (NEW) | +| 1.9 | Heartbeat handler — widget value storage + online status | 0.5d | `cdap/handler.go` | +| 1.10 | Connection lifecycle — reconnect ID persistence by serial | 0.5d | `cdap/handler.go` | +| 1.11 | Wire into `main.go` — start CDAP gateway alongside signal/relay | 0.5d | `main.go` | + +**Gate**: Python bridge connects, authenticates (including 2FA), registers device, sends heartbeat. Device appears in `peers` table as `device_type=scada`. + +--- + +### Phase 2: Panel Widget Rendering (5-7 days) + +| # | Task | Effort | Files | +|---|------|--------|-------| +| 2.1 | REST endpoint: `GET /api/cdap/devices/{id}/manifest` | 0.5d | `api/server.go` or `cdap/api.go` | +| 2.2 | REST endpoint: `GET /api/cdap/devices/{id}/state` (current widget values) | 0.5d | `cdap/api.go` | +| 2.3 | REST endpoint: `POST /api/cdap/devices/{id}/command` (send command) | 0.5d | `cdap/api.go` | +| 2.4 | WebSocket push: widget state changes → panel (via existing event bus) | 1d | `cdap/handler.go`, `events/` | +| 2.5 | Panel: CDAP device detail page (EJS template) | 1d | `web-nodejs/views/cdap-device.ejs` (NEW) | +| 2.6 | Panel: Widget renderer — `toggle`, `gauge`, `button`, `led` | 2d | `web-nodejs/public/js/cdap-widgets.js` (NEW) | +| 2.7 | Panel: Command sending UI (confirm dialogs, cooldowns) | 1d | `web-nodejs/public/js/cdap-commands.js` (NEW) | +| 2.8 | Panel: Device list shows device type icons + filter | 0.5d | `web-nodejs/views/devices.ejs`, `devices.js` | +| 2.9 | i18n keys for CDAP widgets and device types (EN + PL) | 0.5d | `web-nodejs/lang/en.json`, `pl.json` | + +**Gate**: Panel shows CDAP device detail with live-updating gauges, working toggle switches, functioning buttons with confirmations. + +--- + +### Phase 3: Security Hardening + Auth Convergence + Device Revocation (6-8 days) + +| # | Task | Effort | Files | +|---|------|--------|-------| +| 3.1 | TLS auto-detection on CDAP port (reuse `config.DualModeListener`) | 0.5d | `cdap/gateway.go` | +| 3.2 | RBAC per-widget permissions enforcement | 1d | `cdap/auth.go`, `cdap/handler.go` | +| 3.3 | Command audit logging (every command → audit ring buffer) | 0.5d | `cdap/handler.go`, `audit/` | +| 3.4 | Device token enrollment (one-time tokens for new devices) | 1d | `cdap/auth.go`, `db/` | +| 3.5 | Token refresh for long-running agents (30-day chain) | 0.5d | `cdap/auth.go` | +| 3.6 | Auth delegation: Node.js panel uses Go `/api/auth/` for user CRUD | 1d | `web-nodejs/services/authService.js` (NEW) | +| 3.7 | Panel: user management through Go API (password change syncs to Go) | 0.5d | `web-nodejs/routes/auth.routes.js` | +| 3.8 | **Device Revocation**: CDAP `revoke` + `suspend` message handlers in gateway | 1d | `cdap/revocation.go` (NEW), `cdap/gateway.go` | +| 3.9 | **Connection close on delete**: Close `TCPConn`/`WSConn` when peer removed from map | 0.5d | `peer/map.go`, `api/server.go` | +| 3.10 | **Blocklist on revoke**: Auto-add ID to `security.Blocklist` on `?revoke=true` | 0.5d | `api/server.go`, `security/blocklist.go` | +| 3.11 | **Cascade delete**: Revoke `linked_peer_id` when cascade option selected | 0.5d | `cdap/revocation.go`, `api/server.go` | +| 3.12 | **Panel revocation UI**: Delete dialog with wipe/blocklist/cascade checkboxes | 1d | `web-nodejs/views/`, `public/js/devices.js` | +| 3.13 | **EventPeerRevoked**: New event type on event bus for revocation audit trail | 0.25d | `events/bus.go` | + +**Gate**: Widget RBAC enforced (operator can read gauge but not trigger emergency stop without permission). 2FA works end-to-end for CDAP clients. Password changed in panel reflects in CDAP login. Deleting a CDAP device sends `revoke` message → client wipes config and disconnects. Cascade delete revokes linked devices. + +--- + +### Phase 4: Advanced Widgets + RustDesk Sync (5-7 days) + +| # | Task | Effort | Files | +|---|------|--------|-------| +| 4.1 | Widget types: `chart`, `select`, `slider`, `text` | 2d | `web-nodejs/public/js/cdap-widgets.js` | +| 4.2 | Widget types: `table` (dynamic rows, sortable) | 1d | `cdap-widgets.js` | +| 4.3 | Widget type: `terminal` (WebSocket shell relay) | 2d | `cdap/terminal.go` (NEW), `cdap-widgets.js` | +| 4.4 | Device linking — auto-detect + manual link in panel | 1d | `cdap/linking.go` (NEW), panel views | +| 4.5 | Unified status overlay — CDAP status merged into `handleListPeers` | 0.5d | `api/server.go` | +| 4.6 | Address book sync — CDAP clients use same `/api/ab` endpoint | 0.5d | `cdap/api.go` (proxy or direct) | +| 4.7 | Connection history — CDAP events in same audit format | 0.5d | `cdap/handler.go`, `web-nodejs/routes/` | + +**Gate**: Full 10+ widget types working. Linked devices show as one machine in panel. Address book shared between RustDesk and CDAP clients. + +--- + +### Phase 5: Media Channel — Remote Desktop (10-15 days) + +| # | Task | Effort | Files | +|---|------|--------|-------| +| 5.1 | Binary frame mux/demux in CDAP gateway | 2d | `cdap/media.go` (NEW) | +| 5.2 | Media session establishment (REST `/api/cdap/devices/{id}/connect`) | 1d | `cdap/api.go` | +| 5.3 | Binary frame relay (viewer ↔ device, E2E opaque) | 1d | `cdap/media.go` | +| 5.4 | E2E key exchange (X25519 → XSalsa20-Poly1305 via control messages) | 1d | `cdap/crypto.go` (NEW) | +| 5.5 | Video channel: codec negotiation + keyframe request handling | 1d | `cdap/media.go` | +| 5.6 | Panel: WebCodecs/Canvas desktop viewer widget | 3d | `web-nodejs/public/js/cdap-desktop.js` (NEW) | +| 5.7 | Panel: input forwarding (keyboard/mouse → binary frames) | 1d | `cdap-desktop.js` | +| 5.8 | Panel: clipboard sync widget | 1d | `cdap-desktop.js` | +| 5.9 | Panel: file transfer two-pane browser | 2d | `web-nodejs/public/js/cdap-files.js` (NEW) | +| 5.10 | Audio channel: Opus decode/play in browser | 1d | `cdap-desktop.js` | +| 5.11 | Cursor channel: custom cursor rendering | 0.5d | `cdap-desktop.js` | +| 5.12 | Adaptive quality (bitrate/fps adjustments) | 1d | `cdap/media.go` | +| 5.13 | Multi-monitor support (display index routing) | 0.5d | `cdap/media.go`, `cdap-desktop.js` | + +**Gate**: Panel can open remote desktop session to a CDAP desktop device. Video, audio, input, clipboard, and file transfer all work through the binary media channel with E2E encryption. + +--- + +### Phase 6: Native BetterDesk Desktop Agent (10-15 days) + +| # | Task | Effort | Files | +|---|------|--------|-------| +| 6.1 | Agent binary scaffold (Rust or Go) — systemd/service installer | 2d | `betterdesk-agent/` (NEW repo or dir) | +| 6.2 | CDAP client library — auth + heartbeat + manifest | 2d | `betterdesk-agent/cdap/` | +| 6.3 | Screen capture — DXGI (Windows), X11/PipeWire (Linux) | 2d | `betterdesk-agent/capture/` | +| 6.4 | Video encoder — H.264/VP9 hardware-accelerated | 2d | `betterdesk-agent/encoder/` | +| 6.5 | Audio capture — WASAPI (Windows), PulseAudio (Linux) | 1d | `betterdesk-agent/audio/` | +| 6.6 | Input injection — keyboard/mouse (platform-specific) | 1d | `betterdesk-agent/input/` | +| 6.7 | Clipboard monitor — text/image sync | 1d | `betterdesk-agent/clipboard/` | +| 6.8 | File transfer — chunk read/write with resume | 1d | `betterdesk-agent/files/` | +| 6.9 | System widgets — CPU/RAM/disk/services/processes | 1d | `betterdesk-agent/sysinfo/` | +| 6.10 | Login UI — username/password + 2FA dialog | 1d | `betterdesk-agent/ui/` | +| 6.11 | Tray icon + auto-start | 0.5d | `betterdesk-agent/ui/` | +| 6.12 | Update from ALL-IN-ONE scripts (install/update support) | 1d | `betterdesk.sh`, `betterdesk.ps1` | + +**Gate**: BetterDesk agent installs on Windows/Linux, logs in with 2FA, appears as `desktop` type in panel, supports remote desktop + system management widgets. + +--- + +### Phase 7: Bridge Ecosystem + Polish (5-7 days) + +| # | Task | Effort | Files | +|---|------|--------|-------| +| 7.1 | Python bridge SDK (pip-installable) | 1d | `sdks/python/` (NEW) | +| 7.2 | Reference bridge: Modbus TCP | 1d | `bridges/modbus/` (NEW) | +| 7.3 | Reference bridge: SNMP | 1d | `bridges/snmp/` (NEW) | +| 7.4 | Reference bridge: REST/HTTP (generic webhook → CDAP) | 1d | `bridges/rest/` (NEW) | +| 7.5 | Dashboard: CDAP device type counters + overview cards | 1d | Panel views | +| 7.6 | Alert system: threshold-based from manifest definitions | 1d | `cdap/alerts.go` (NEW) | +| 7.7 | Documentation site / updated README | 1d | `docs/` | + +**Gate**: Three working reference bridges. Alert system notifies on threshold breach. Dashboard shows CDAP devices alongside RustDesk. + +--- + +## File-Level Change Map + +### Go Server (`betterdesk-server/`) + +| File | Action | Phase | Description | +|------|--------|-------|-------------| +| `main.go` | Modify | 1 | Start CDAP gateway, pass config | +| `config/config.go` | Modify | 1 | Add `CDAPPort`, `CDAPTLSEnabled()` | +| `cdap/gateway.go` | **NEW** | 1 | WebSocket server, connection lifecycle | +| `cdap/auth.go` | **NEW** | 1 | Auth handler (user/pass, API key, 2FA, device token) | +| `cdap/handler.go` | **NEW** | 1 | Register, heartbeat, state_update, command routing | +| `cdap/manifest.go` | **NEW** | 1 | Manifest parsing + JSON validation | +| `cdap/api.go` | **NEW** | 2 | REST endpoints for panel (manifest, state, command) | +| `cdap/terminal.go` | **NEW** | 4 | Terminal widget WebSocket relay | +| `cdap/linking.go` | **NEW** | 4 | Device auto-linking logic | +| `cdap/media.go` | **NEW** | 5 | Binary frame mux/demux, media session, relay | +| `cdap/crypto.go` | **NEW** | 5 | X25519 key exchange for E2E media | +| `cdap/revocation.go` | **NEW** | 3 | Revoke/suspend message sending, cascade logic, conn close | +| `cdap/alerts.go` | **NEW** | 7 | Threshold-based alert evaluation | +| `db/database.go` | Modify | 0-1 | Add CDAP-related methods to interface | +| `db/sqlite.go` | Modify | 0-1 | Implement CDAP methods, add columns/tables | +| `db/postgres.go` | Modify | 0-1 | Same for PostgreSQL | +| `peer/map.go` | Modify | 3 | Close TCP/WS connections on `Remove()` | +| `api/server.go` | Modify | 2-4 | CDAP REST routes, status overlay, `?revoke=true` in delete | +| `events/bus.go` | Modify | 3 | Add `EventPeerRevoked` event type | +| `security/blocklist.go` | Modify | 3 | Auto-add ID on revocation | +| `auth/` (existing) | No change | — | Reused by CDAP auth (no modification needed) | + +### Node.js Console (`web-nodejs/`) + +| File | Action | Phase | Description | +|------|--------|-------|-------------| +| `views/cdap-device.ejs` | **NEW** | 2 | CDAP device detail page with widget panel | +| `views/devices.ejs` | Modify | 0 | Add device type column, filter, icons | +| `public/js/cdap-widgets.js` | **NEW** | 2 | Widget renderer (all 13 types) | +| `public/js/cdap-commands.js` | **NEW** | 2 | Command sending, confirmation, cooldown | +| `public/js/cdap-desktop.js` | **NEW** | 5 | Remote desktop viewer (WebCodecs + Canvas) | +| `public/js/cdap-files.js` | **NEW** | 5 | Two-pane file browser | +| `public/css/cdap.css` | **NEW** | 2 | Widget styles, device type icons | +| `routes/cdap.routes.js` | **NEW** | 2 | CDAP panel routes (proxy to Go server) | +| `services/authService.js` | **NEW** | 3 | Auth delegation to Go server | +| `lang/en.json` | Modify | 2+ | CDAP i18n keys | +| `lang/pl.json` | Modify | 2+ | CDAP i18n keys | +| `services/betterdeskApi.js` | Modify | 2 | Add CDAP API methods | + +### ALL-IN-ONE Scripts + +| File | Action | Phase | Description | +|------|--------|-------|-------------| +| `betterdesk.sh` | Modify | 6 | CDAP port in firewall, agent install option | +| `betterdesk.ps1` | Modify | 6 | Same for Windows | +| `betterdesk-docker.sh` | Modify | 1 | Expose port 21122 in docker-compose | +| `docker-compose.yml` | Modify | 1 | Add port 21122 mapping | +| `Dockerfile` | Modify | 1 | Expose 21122 | + +--- + +## Testing Strategy + +### Unit Tests (Go) + +| Test | Phase | Validates | +|------|-------|-----------| +| `cdap/auth_test.go` | 1 | Auth message parsing, password verify, API key, 2FA flow | +| `cdap/manifest_test.go` | 1 | Manifest validation (valid, invalid, edge cases) | +| `cdap/handler_test.go` | 1 | Register, heartbeat, state_update, command routing | +| `cdap/media_test.go` | 5 | Binary frame mux/demux, session pairing | +| `cdap/linking_test.go` | 4 | Auto-link algorithm, unlink | +| `cdap/revocation_test.go` | 3 | Revoke message sent, conn closed, config wipe, cascade | + +### Integration Tests + +| Test | Phase | Setup | Validates | +|------|-------|-------|-----------| +| Bridge auth flow | 1 | Go server + Python bridge | WS connect → auth → register → heartbeat | +| 2FA end-to-end | 1 | Go server + test TOTP | Auth → 2FA required → code verify → session | +| Panel widget rendering | 2 | Full stack | Bridge connects → panel shows widgets → values update | +| Command round-trip | 2 | Full stack | Panel sends command → bridge receives → executes → response | +| Linked devices | 4 | Go server + RustDesk + CDAP | Both clients online → panel shows linked view | +| Device revocation | 3 | Go server + CDAP bridge | Delete → revoke msg → bridge disconnects + wipes config | +| Cascade revocation | 3 | Go server + RustDesk + CDAP | Delete CDAP → linked RustDesk also revoked + blocked | +| Media relay | 5 | Go server + 2 CDAP clients | Video frames relayed with E2E encryption | + +### Load Tests + +| Test | Phase | Target | +|------|-------|--------| +| 100 concurrent CDAP devices | 2 | Gateway handles 100 WS connections, 1000 msg/s | +| 10 concurrent media sessions | 5 | Binary relay at 10 Mbps aggregate without frame loss | +| 1000 widget updates/sec | 2 | Panel receives updates via WS push without lag | + +--- + +## Migration & Backward Compatibility + +### Zero Breaking Changes + +- All existing RustDesk clients continue working unchanged +- All existing Node.js panel features unchanged +- All existing API endpoints unchanged +- CDAP is strictly additive — new port, new protocol, new device types + +### Migration Path for Organizations + +``` +Step 1: Update BetterDesk server (Phase 1-4) + └── CDAP gateway starts on port 21122 + └── All existing devices still work + +Step 2: Deploy IoT/SCADA bridges (Phase 2+) + └── New device types appear in panel + └── RustDesk devices unaffected + +Step 3: Deploy BetterDesk native agent on select machines (Phase 6) + └── Machine shows two entries (RustDesk + CDAP) + └── Admin can link them in panel + +Step 4: Gradually replace RustDesk client with native agent + └── Uninstall RustDesk client + └── CDAP agent provides remote desktop + management + └── One device entry per machine + +Step 5 (optional): Disable RustDesk protocol + └── Only for environments fully migrated to CDAP + └── Signal/relay servers still available for backward compat +``` + +### Rollback + +Every phase is independent. If Phase 5 (media) has issues, Phases 1-4 (widgets, auth, sync) continue working. CDAP gateway can be disabled entirely by removing the `--cdap-port` flag — zero impact on RustDesk protocol. + +--- + +## Risk Assessment + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| WebSocket scalability at 1000+ devices | High | Medium | Connection pooling, per-device message throttle | +| Auth DB divergence (Node.js vs Go) | High | High | Phase 3 convergence, sync checks | +| Binary frame relay performance for desktop | High | Low | E2E opaque (zero decode), tested architecture | +| Native agent cross-platform complexity | High | High | Start with one platform (Linux or Windows), add second | +| Bridge SDK maintenance burden | Medium | Medium | Minimal SDKs (~200 LOC), auto-generate from spec | +| RustDesk protocol changes break existing | Medium | Low | BetterDesk server frozen at RustDesk 1.3.x compat | +| 2FA lockout (lost phone, no recovery codes) | Medium | Medium | Admin can disable 2FA from panel, recovery codes backup reminder | +| CDAP spec changes during development | Medium | Medium | Versioned manifests, backward compat | +| Revocation message not delivered (device offline) | Medium | High | Blocklist + soft-delete ensure re-registration fails regardless; `revoke` is best-effort optimization | +| Cascade delete accidental scope | High | Low | Cascade is opt-in checkbox, admin must explicitly confirm; audited | +| RustDesk client cannot be config-wiped | Medium | N/A | Protocol limitation; blocklist prevents re-registration; BetterDesk native client supports full wipe | + +--- + +## Timeline Summary + +| Phase | Duration | Cumulative | Key Deliverable | +|-------|----------|------------|-----------------| +| 0: Preparation | 1-2 days | 1-2 days | DB schema, device type column | +| 1: Gateway Core | 5-7 days | 6-9 days | CDAP auth + registration working | +| 2: Panel Widgets | 5-7 days | 11-16 days | Widgets visible and interactive in panel | +| 3: Security + Auth + Revocation | 6-8 days | 17-25 days | Full RBAC + 2FA + auth convergence + device revocation | +| 4: Advanced + Sync | 5-7 days | 22-32 days | Full widget set + RustDesk sync | +| 5: Media Channel | 10-15 days | 32-47 days | Remote desktop via CDAP | +| 6: Native Agent | 10-15 days | 42-62 days | BetterDesk desktop agent binary | +| 7: Ecosystem | 5-7 days | 47-69 days | Bridge SDKs + reference bridges | + +**MVP (Phases 0-2)**: ~16 days → CDAP devices with widgets in panel +**Production (Phases 0-4)**: ~32 days → Secure, synced, full widget set + revocation +**Full Stack (Phases 0-7)**: ~69 days → Native client + bridge ecosystem diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e2561efc..337197f8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,52 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.4.1] - 2026-03-20 + +### 🎨 Devices Page UI Redesign + +Complete UI overhaul of the Devices page — fully responsive, space-efficient, and optimized for phone, tablet, and desktop. + +#### Layout Changes +- **Sidebar removed** — 280px folder sidebar replaced with horizontal scrollable **folder chips** (pill buttons) +- **Unified toolbar** — Search, segmented filter buttons (All/Online/Offline/Banned), and column visibility toggle in a single row +- **Slim table** — Reduced cell padding for higher information density +- **Kebab menu** (⋮) — Replaced 5 inline action buttons per row with a compact dropdown menu (connect, connect-desktop, details, ban/unban, delete) +- **Status dot** — Colored dot (green/gray/red) inline with device ID for at-a-glance status +- **Mobile bottom sheet** — On phones, kebab menu appears as a full-width bottom sheet with backdrop overlay + +#### Responsive Breakpoints +- **≤1024px** — Hides `device_type` column +- **≤768px** — Hides `platform` + `last_online` columns; search goes full-width; button labels hidden (icons only) +- **≤600px** — Card-style rows (CSS grid 2-column); `` hidden; kebab becomes fixed bottom sheet with overlay backdrop +- **≤400px** — Folder chip labels hidden (icons only); smaller filter buttons + +#### Files Changed +- `views/devices.ejs` — Complete template rewrite +- `public/css/devices.css` — Complete stylesheet rewrite (~780 lines) +- `public/js/devices.js` — Updated rendering (kebab menu, folder chips, status dots, global close handler) + +--- + +## [2.4.0] - 2026-03-01 + +### ✨ PostgreSQL Support, Migration Tool, CDAP, TLS Everywhere & More + +See `.github/copilot-instructions.md` for full details on Phases 4–29. + +#### Highlights +- PostgreSQL database backend (`pgx/v5`, connection pooling, `LISTEN/NOTIFY`) +- SQLite ↔ PostgreSQL migration tool (`tools/migrate/`) +- TLS for TCP signal, relay, and WebSocket (auto-detect plain/TLS on same port) +- E2E encryption fixes (relay UUID, SignIdPk NaCl format, PunchHoleResponse) +- CDAP protocol v0.3.0 (device widgets, commands, panel rendering) +- Sysinfo/heartbeat endpoints for hostname/platform display +- Address book sync in Go server +- Docker single-container + GHCR publishing +- 30+ bug fixes across Go server, Node.js console, and installer scripts + +--- + ## [2.3.0] - 2026-02-22 ### 🔒 Security Audit & Fixes diff --git a/docs/CUSTOM_DEVICE_API.md b/docs/CUSTOM_DEVICE_API.md new file mode 100644 index 00000000..c9c55774 --- /dev/null +++ b/docs/CUSTOM_DEVICE_API.md @@ -0,0 +1,2475 @@ +# BetterDesk Custom Device API Protocol (CDAP) + +> **Status:** RFC / Design Document +> **Author:** BetterDesk Team +> **Created:** 2026-03-19 +> **Version:** 0.2.0 (Draft) +> **Last Updated:** 2026-03-19 + +--- + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Problem Statement](#problem-statement) +3. [Architecture Overview](#architecture-overview) +4. [Protocol Design](#protocol-design) +5. [Media Channel Protocol](#media-channel-protocol) +6. [Device Registration & Identity](#device-registration--identity) +7. [Widget Descriptor System](#widget-descriptor-system) +8. [Command Bus](#command-bus) +9. [API Bridge Architecture](#api-bridge-architecture) +10. [Bridge Examples](#bridge-examples) +11. [Security Model](#security-model) + - [Authentication Layers](#authentication-layers) + - [Authentication Methods](#authentication-methods) + - [2FA Integration with Existing System](#2fa-integration-with-existing-system) + - [Token Lifecycle for Long-Running Agents](#token-lifecycle-for-long-running-agents) + - [RustDesk Client Synchronization](#rustdesk-client-synchronization) + - [RBAC for Widgets](#rbac-for-widgets) +12. [Device Revocation Protocol](#device-revocation-protocol) + - [Problem: Current Gaps](#problem-current-gaps) + - [Revocation Architecture](#revocation-architecture) + - [CDAP Revocation Messages](#cdap-revocation-messages) + - [RustDesk Device Revocation](#rustdesk-device-revocation) + - [Panel Revocation UI](#panel-revocation-ui) + - [Cascade Delete with Linked Devices](#cascade-delete-with-linked-devices) +13. [Panel Integration](#panel-integration) +14. [Technology Stack](#technology-stack) +15. [Implementation Phases](#implementation-phases) +16. [Comparison with Alternatives](#comparison-with-alternatives) +17. [FAQ](#faq) + +--- + +## Executive Summary + +The **Custom Device API Protocol (CDAP)** extends BetterDesk into a universal device management platform with **two orthogonal capabilities**: + +1. **Control Plane** — Any networked device (industrial controllers, IoT, OS agents) registers, exposes interactive widgets, and receives commands through the web panel via JSON/WebSocket. +2. **Media Plane** — Full remote desktop experience (screen streaming, input forwarding, clipboard, file transfer, audio) via a binary channel — completely independent of the RustDesk protocol. + +Together, these planes enable a **native BetterDesk client** that offers everything RustDesk does and more, while also supporting non-desktop devices (SCADA, IoT) through the same unified protocol. + +**Key innovations**: +- Lightweight **API Bridges** translate between existing device protocols (Modbus, OPC-UA, SNMP, REST) and CDAP in real-time (~50-200 LOC). +- **Dual-channel architecture** uses JSON/WebSocket for control (widgets, commands, auth) and binary/WebSocket for media (video, audio, input) — the same connection, negotiated per-device capabilities. +- Devices choose which channels they need: an ESP32 sensor uses only the control plane; a desktop agent uses both; a camera bridge uses control + video-only media. + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ BetterDesk Server (Go) │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌───────────────┐ │ +│ │ Signal │ │ Relay │ │ HTTP API │ │ CDAP Gateway │ │ +│ │ :21116 │ │ :21117 │ │ :21114 │ │ :21122 │ │ +│ └────┬─────┘ └────┬─────┘ └─────┬─────┘ └──────┬────────┘ │ +│ │ │ │ │ │ +│ └──────────────┴──────────────┴───────┬───────┘ │ +│ │ │ │ +│ ┌─────┴──────┐ ┌──────┴───────┐ │ +│ │ Device DB │ │ Media Relay │ │ +│ │ (unified) │ │ (binary WS) │ │ +│ └────────────┘ └──────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + │ │ │ + RustDesk Clients Web Panel CDAP Devices + (desktop/mobile) (Node.js) (via bridges + native clients) + │ + ┌───────────────┬───────────────┼───────────────────┐ + │ │ │ │ + ┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴───────┐ ┌──────┴──────┐ + │ BetterDesk│ │ Modbus │ │ OS Agent │ │ REST │ + │ Native │ │ Bridge │ │ (daemon) │ │ Bridge │ + │ Client │ └─────┬─────┘ └──────┬──────┘ └──────┬──────┘ + │(desktop+ │ │ │ │ + │ media) │ ┌─────┴─────┐ ┌──────┴──────┐ ┌──────┴──────┐ + └───────────┘ │ PLC/SCADA │ │ Linux Kernel│ │ IP Camera │ + │ Controller│ │ Subsystems │ │ / NVR │ + └───────────┘ └─────────────┘ └─────────────┘ +``` + +--- + +## Problem Statement + +### Current Limitations + +BetterDesk today manages **RustDesk-compatible desktop clients** — Windows, macOS, Linux machines running the RustDesk remote desktop application. This covers one dimension of IT infrastructure: interactive desktop support. + +Modern infrastructure management requires visibility into: + +| Domain | Examples | Current BetterDesk Support | +|--------|----------|---------------------------| +| Remote Desktops | Windows, macOS, Linux workstations | ✅ Full (via RustDesk protocol) | +| Native Remote Desktop | BetterDesk's own client with extended features | ❌ Depends on RustDesk client | +| Industrial Control | PLCs, SCADA HMIs, RTUs | ❌ None | +| IoT/Edge | Sensors, gateways, Raspberry Pi, ESP32 | ❌ None | +| Network Infrastructure | Switches, routers, firewalls | ❌ None | +| OS-Level Management | Kernel parameters, services, packages | ⚠️ Partial (sysinfo only) | +| Custom Applications | In-house monitoring, lab equipment | ❌ None | +| Video Streams | IP cameras, screen capture, RTSP sources | ❌ None | + +### The Bridge Insight + +Most existing devices **already have management protocols** — Modbus TCP for PLCs, SNMP for network gear, REST APIs for modern appliances, `/proc` and `/sys` for Linux kernels. Requiring vendors to implement CDAP natively is unrealistic. + +Instead, CDAP is designed so that **lightweight bridge programs** translate between the device's native protocol and CDAP in real-time: + +``` +┌─────────────┐ Native Protocol ┌─────────────┐ CDAP/WebSocket ┌─────────────┐ +│ Device │ ──────────────────────► │ Bridge │ ────────────────────► │ BetterDesk │ +│ (PLC/SCADA) │ ◄────────────────────── │ (50-200 LOC)│ ◄──────────────────── │ Server │ +└─────────────┘ Modbus/OPC-UA/... └─────────────┘ JSON over WS └─────────────┘ +``` + +A bridge is: +- **Tiny**: 50-200 lines in Python, Node.js, Go, Rust, C — anything with WebSocket + native protocol library +- **Stateless**: Server maintains all state; bridge just translates messages +- **Deployable anywhere**: On the device itself, on a gateway, or on a separate machine +- **Writable by anyone**: No BetterDesk SDK required — just JSON over WebSocket + +--- + +## Architecture Overview + +### Component Model + +``` +┌─────────────────────────────────────────────────────────┐ +│ BetterDesk Server (Go) │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ CDAP Gateway (:21122) │ │ +│ │ │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ │ +│ │ │ Auth │ │ Manifest │ │ Command │ │ │ +│ │ │ Handler │ │ Registry │ │ Router │ │ │ +│ │ └──────────┘ └──────────┘ └───────────────┘ │ │ +│ │ │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ │ +│ │ │ Widget │ │ Heartbeat│ │ Event │ │ │ +│ │ │ State │ │ Manager │ │ Emitter │ │ │ +│ │ └──────────┘ └──────────┘ └───────────────┘ │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────┐ ┌──────┴──────┐ ┌──────────────┐ │ +│ │ peers DB │◄──►│ cdap_devices│◄──►│ widget_states│ │ +│ │ (unified) │ │ table │ │ table │ │ +│ └───────────┘ └─────────────┘ └──────────────┘ │ +│ │ │ +│ ┌──────────────────────┐│┌────────────────────────┐ │ +│ │ REST API (:21114) │││ Event Bus (WebSocket) │ │ +│ │ /api/cdap/* │││ cdap.* events │ │ +│ └──────────────────────┘│└────────────────────────┘ │ +│ │ │ +└──────────────────────────┼──────────────────────────────┘ + │ + ┌────────────┼────────────┐ + │ │ │ + Web Panel CDAP Bridges RustDesk Clients + (Node.js) (any language) (desktop) +``` + +### Data Flow + +1. **Registration**: Bridge connects via WebSocket to `:21122`, authenticates with API key, sends device manifest +2. **ID Assignment**: Server generates unique device ID (format: `CDAP-XXXXXXXX`), stores in unified `peers` table with `device_type` field +3. **Widget Sync**: Server parses manifest widgets, creates `widget_states` entries, pushes to panel via event bus +4. **Heartbeat**: Bridge sends periodic heartbeat with live widget values (CPU gauge, pressure reading, etc.) +5. **Commands**: Panel user clicks widget → server validates RBAC → routes command to bridge via WebSocket → bridge translates to native protocol → device responds → bridge sends state update → server pushes to panel + +--- + +## Protocol Design + +### Transport + +| Property | Value | +|----------|-------| +| **Transport** | WebSocket (RFC 6455) over TCP | +| **Port** | 21122 (configurable via `CDAP_PORT` env var) | +| **Encoding** | JSON (UTF-8) | +| **TLS** | Optional — auto-detected via `DualModeListener` (same as signal/relay) | +| **Keepalive** | WebSocket ping/pong every 30 seconds | +| **Reconnect** | Client-side exponential backoff (1s, 2s, 4s, 8s, max 60s) | +| **Max message size** | 1 MB (configurable via `CDAP_MAX_MESSAGE_SIZE`) | + +### Why WebSocket + JSON (not Protobuf/gRPC) + +| Criterion | WebSocket + JSON | gRPC/Protobuf | +|-----------|-----------------|---------------| +| Bridge implementation effort | ~10 lines for WS connect | Protobuf codegen + gRPC runtime | +| Language support | Every language has WS libs | Limited on embedded/microcontrollers | +| Debugging | Human-readable, curl-testable | Binary, needs grpcurl | +| Browser compatibility | Native WebSocket API | Requires grpc-web proxy | +| Bidirectional streaming | Native | Native | +| Performance | Sufficient for control plane (<1000 msg/s) | Better for high-throughput | +| Embedded devices (ESP32) | Arduino WebSocket library available | No gRPC runtime for Arduino | + +**Decision**: JSON over WebSocket for v1. Binary encoding (MessagePack/CBOR) as optional optimization in v2 for high-frequency telemetry. + +### Message Envelope + +Every message follows this envelope: + +```json +{ + "type": "string", + "id": "string (optional, for request/response correlation)", + "timestamp": "ISO-8601", + "payload": {} +} +``` + +### Message Types + +#### Client → Server + +| Type | Description | Payload | +|------|-------------|---------| +| `auth` | Authentication | `{api_key, bridge_version}` | +| `register` | Device manifest | `{manifest}` (see Manifest section) | +| `heartbeat` | Periodic health + telemetry | `{metrics: {}, widget_values: {}}` | +| `state_update` | Widget state change (device-initiated) | `{widget_id, value, timestamp}` | +| `bulk_update` | Multiple widget updates at once | `{updates: [{widget_id, value}]}` | +| `event` | Custom event from device | `{event_type, data}` | +| `command_response` | Response to server command | `{command_id, status, result}` | +| `log` | Device log entry | `{level, message, context}` | +| `unregister` | Graceful disconnect | `{reason}` | + +#### Server → Client + +| Type | Description | Payload | +|------|-------------|---------| +| `auth_result` | Authentication response | `{success, device_id, session_token}` | +| `registered` | Registration confirmed | `{device_id, server_time}` | +| `command` | Execute action on device | `{command_id, widget_id, action, value}` | +| `config_update` | Server pushes config change | `{key, value}` | +| `ping` | Health check (beyond WS ping) | `{server_time}` | +| `error` | Error notification | `{code, message, details}` | + +### Example Session + +``` +Bridge Server + │ │ + │──── auth ─────────────────────────►│ + │ {api_key: "abc123", │ + │ bridge_version: "1.0.0"} │ + │ │ + │◄─── auth_result ──────────────────│ + │ {success: true, │ + │ device_id: "CDAP-A7F3B210", │ + │ session_token: "jwt..."} │ + │ │ + │──── register ─────────────────────►│ + │ {manifest: {...}} │ + │ │ + │◄─── registered ───────────────────│ + │ {device_id: "CDAP-A7F3B210"} │ + │ │ + │──── heartbeat ────────────────────►│ (every 15s) + │ {widget_values: { │ + │ "pressure": 3.7, │ + │ "valve_1": true │ + │ }} │ + │ │ + │ (operator clicks widget) │ + │◄─── command ──────────────────────│ + │ {command_id: "cmd-001", │ + │ widget_id: "valve_1", │ + │ action: "set", │ + │ value: false} │ + │ │ + │──── command_response ─────────────►│ + │ {command_id: "cmd-001", │ + │ status: "ok", │ + │ result: {valve_1: false}} │ + │ │ + │──── state_update ─────────────────►│ + │ {widget_id: "valve_1", │ + │ value: false} │ + │ │ +``` + +--- + +## Media Channel Protocol + +### Overview — Dual-Channel Architecture + +CDAP operates on **two channels** over the same WebSocket connection: + +| Channel | Encoding | Purpose | Bandwidth | Required | +|---------|----------|---------|-----------|----------| +| **Control** | JSON text frames | Auth, manifest, widgets, commands, heartbeat | Low (~1-10 KB/s) | Always | +| **Media** | Binary frames | Video, audio, input events, clipboard, file transfer | High (~0.1-20 MB/s) | Optional | + +A device declares media capabilities in its manifest. If no media capabilities are declared, only the control channel is active (IoT/SCADA mode). If `remote_desktop`, `video_stream`, or `audio` capabilities are declared, the media channel is negotiated after registration. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Single WebSocket Connection │ +│ │ +│ ┌─────────────────────────────┐ ┌────────────────────────────┐│ +│ │ Control Channel │ │ Media Channel ││ +│ │ (JSON text frames) │ │ (binary frames) ││ +│ │ │ │ ││ +│ │ • auth / register │ │ • video frames (H.264/VP9)││ +│ │ • heartbeat / state_update │ │ • audio packets (Opus) ││ +│ │ • command / command_resp │ │ • input events (kbd/mouse)││ +│ │ • widget values │ │ • clipboard data ││ +│ │ • alerts / logs │ │ • file transfer chunks ││ +│ │ │ │ • cursor images ││ +│ └─────────────────────────────┘ └────────────────────────────┘│ +│ │ +│ Multiplexing: text frames = control, binary frames = media │ +│ Encryption: XSalsa20-Poly1305 (per-frame, counter-based nonce) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +This design means: +- **IoT/SCADA bridges** use only text frames — zero media overhead +- **Desktop agents** use both channels — full remote desktop +- **Camera bridges** use control + video-only media — no input needed +- **One WebSocket, one port, one auth** — no separate media negotiation + +### Why Not WebRTC? + +| Criterion | CDAP Media (Binary WS) | WebRTC | +|-----------|----------------------|--------| +| NAT traversal | Server relays (existing infra) | STUN/TURN servers (extra infra) | +| Browser support | WebSocket API (universal) | Partial (no Safari iOS Workers) | +| Server-side relay | Native (Go relay already exists) | Requires TURN server | +| Encryption | XSalsa20-Poly1305 (proven, simple) | DTLS-SRTP (complex, more attack surface) | +| Codec negotiation | Manifest-declared (simple) | SDP offer/answer (complex) | +| Firewall friendly | Single port (21122) | Multiple UDP ports, STUN binding | +| Embedded devices | Any WS library | No WebRTC on ESP32/microcontrollers | +| P2P option | Future (hole punch via signal) | Native (ICE candidates) | +| Latency | ~1-5ms added by relay | ~0ms P2P, ~10-50ms via TURN | + +**Decision**: Binary WebSocket frames over existing server relay for v1. P2P optimization (UDP hole-punch via signal server) as v2 option for latency-sensitive desktop use. + +### Media Frame Format + +All media data is sent as WebSocket **binary frames** with a thin header: + +``` +┌──────────────────────────────────────────────────────┐ +│ CDAP Media Frame │ +├────────┬───────┬──────────┬──────────────────────────┤ +│ Channel│ Flags │ Sequence │ Payload │ +│ (1B) │ (1B) │ (4B LE) │ (variable length) │ +├────────┼───────┼──────────┼──────────────────────────┤ +│ 0x01 │ 0x00 │ 00000001 │ [encrypted payload] │ +└────────┴───────┴──────────┴──────────────────────────┘ +``` + +**Header (6 bytes)**: + +| Field | Size | Description | +|-------|------|-------------| +| `channel` | 1 byte | Media sub-channel identifier | +| `flags` | 1 byte | Per-channel flags (keyframe, encrypted, etc.) | +| `sequence` | 4 bytes LE | Monotonic counter for ordering + dedup | + +**Channel IDs**: + +| ID | Channel | Direction | Description | +|----|---------|-----------|-------------| +| `0x01` | Video | Device → Server/Viewer | Encoded video frames | +| `0x02` | Audio | Bidirectional | Opus audio packets | +| `0x03` | Input | Viewer → Device | Keyboard + mouse events | +| `0x04` | Clipboard | Bidirectional | Clipboard content sync | +| `0x05` | File | Bidirectional | File transfer chunks | +| `0x06` | Cursor | Device → Viewer | Cursor image + position | +| `0x07` | Display | Device → Server | Display info (resolution, monitors) | +| `0x08` | Control | Bidirectional | Media session control messages | +| `0x10-0xFF` | Custom | Bidirectional | Reserved for bridge-defined channels | + +**Flags (per channel)**: + +| Bit | Channel | Meaning | +|-----|---------|---------| +| `0x01` | Video | Keyframe | +| `0x02` | Video | Display index in first 2 payload bytes | +| `0x04` | File | Last chunk (EOF) | +| `0x08` | All | Encrypted (XSalsa20-Poly1305) | +| `0x10` | All | Compressed (zstd) | + +### Capability Declaration + +Devices declare media capabilities in the manifest: + +```json +{ + "capabilities": [ + "telemetry", + "commands", + "remote_desktop", + "video_stream", + "audio", + "clipboard", + "file_transfer", + "input_control" + ], + "media": { + "video": { + "codecs": ["h264", "vp9", "av1"], + "preferred": "h264", + "max_resolution": "3840x2160", + "max_fps": 60, + "hardware_encoder": true, + "displays": [ + {"id": 0, "name": "Primary", "width": 1920, "height": 1080}, + {"id": 1, "name": "Secondary", "width": 2560, "height": 1440} + ] + }, + "audio": { + "codecs": ["opus"], + "sample_rate": 48000, + "channels": 2, + "bidirectional": true + }, + "input": { + "keyboard": true, + "mouse": true, + "touch": false, + "gamepad": false + }, + "clipboard": { + "text": true, + "image": true, + "max_size": 10485760 + }, + "file_transfer": { + "max_file_size": 4294967296, + "resumable": true + } + } +} +``` + +### Media Session Establishment + +After device registration, a viewer (web panel or native client) requests a media session: + +``` +Viewer Server Device (Bridge/Agent) + │ │ │ + │── POST /api/cdap/ │ │ + │ devices/{id}/connect ─►│ │ + │ │ │ + │◄── {session_id, │ │ + │ relay_token, │ │ + │ device_capabilities} │ │ + │ │ │ + │── WS /ws/cdap/media ────►│ │ + │ {session_id, │ │ + │ relay_token} │ │ + │ │── command ────────────────►│ + │ │ {action:"media_connect", │ + │ │ session_id, viewer_pk} │ + │ │ │ + │ │◄── media_accept ──────────│ + │ │ {device_pk} │ + │ │ │ + │ ┌────────────────────┤ │ + │ │ E2E Key Exchange │ │ + │ │ (X25519 → XSalsa20)│ │ + │ └────────────────────┤ │ + │ │ │ + │◄═══ binary: video ══════╪═══════════════════════════│ + │◄═══ binary: audio ══════╪═══════════════════════════│ + │◄═══ binary: cursor ═════╪═══════════════════════════│ + │═══► binary: input ══════╪══════════════════════════►│ + │◄══► binary: clipboard ══╪══════════════════════════►│ + │◄══► binary: file ═══════╪══════════════════════════►│ + │ │ │ +``` + +### Video Protocol + +#### Frame Payload (channel 0x01) + +``` +┌─────────────────────────────────────────────┐ +│ Video Frame Payload │ +├──────────┬────────┬─────────┬───────────────┤ +│ Codec ID │ PTS │ Display │ Coded Data │ +│ (1B) │ (8B LE)│ (1B) │ (variable) │ +└──────────┴────────┴─────────┴───────────────┘ +``` + +| Field | Size | Description | +|-------|------|-------------| +| `codec_id` | 1 byte | `0x01`=VP9, `0x02`=H.264, `0x03`=H.265, `0x04`=VP8, `0x05`=AV1 | +| `pts` | 8 bytes LE | Presentation timestamp (microseconds since session start) | +| `display` | 1 byte | Display/monitor index (0-255) | +| `coded_data` | variable | Codec bitstream bytes | + +**Codec Negotiation**: Server sends viewer's supported codecs to device in `media_connect`. Device chooses the best codec from intersection and sends first keyframe. Codec switch mid-session is supported (send new keyframe with different `codec_id`). + +**Keyframe Request**: Viewer sends control frame `{action: "request_keyframe", display: 0}` when decoder errors or viewer joins mid-stream. + +#### Adaptive Quality + +Device-side encoder adjusts quality based on `video_received` acknowledgments from viewer: + +```json +// Control channel (0x08): viewer → device +{ + "action": "video_ack", + "sequence": 42567, + "decode_time_ms": 3, + "render_time_ms": 1, + "buffer_ms": 50 +} +``` + +Device uses ack timing to estimate RTT and adjusts bitrate/resolution/fps dynamically. + +### Audio Protocol + +#### Packet Payload (channel 0x02) + +``` +┌──────────────────────────────────────┐ +│ Audio Packet Payload │ +├──────────┬─────────┬─────────────────┤ +│ Codec ID │ PTS │ Opus Packet │ +│ (1B) │ (8B LE) │ (variable) │ +└──────────┴─────────┴─────────────────┘ +``` + +- Codec: Opus (48 kHz, stereo, 20ms frames = 960 samples/frame) +- Bidirectional: device captures system audio → viewer speakers; viewer microphone → device speakers +- Jitter buffer: 3-5 frames (60-100ms) on receiver side + +### Input Protocol + +#### Event Payload (channel 0x03) + +``` +┌──────────────────────────────────────────────┐ +│ Input Event Payload │ +├──────────┬───────────────────────────────────┤ +│ Type │ Event Data │ +│ (1B) │ (variable) │ +└──────────┴───────────────────────────────────┘ +``` + +**Input Types**: + +| Type | ID | Payload | +|------|----|---------| +| Key Down | `0x01` | `{scancode(2B), unicode(4B), modifiers(1B)}` | +| Key Up | `0x02` | `{scancode(2B), unicode(4B), modifiers(1B)}` | +| Mouse Move | `0x03` | `{x(4B LE), y(4B LE), display(1B)}` — absolute position | +| Mouse Button | `0x04` | `{button(1B), pressed(1B), x(4B), y(4B)}` | +| Mouse Wheel | `0x05` | `{delta_x(4B LE signed), delta_y(4B LE signed)}` | +| Touch | `0x06` | `{touch_id(2B), action(1B), x(4B), y(4B), pressure(2B)}` | +| Ctrl+Alt+Del | `0x10` | (no payload — special secure action) | + +**Modifier Flags** (1 byte bitmask): + +| Bit | Modifier | +|-----|----------| +| `0x01` | Shift | +| `0x02` | Ctrl | +| `0x04` | Alt | +| `0x08` | Meta/Super | +| `0x10` | CapsLock active | +| `0x20` | NumLock active | + +### Clipboard Protocol (channel 0x04) + +```json +// Clipboard content sent as binary frame with JSON header +{ + "format": "text/plain", + "size": 1234, + "hash": "sha256:abc123..." +} +// Followed by raw clipboard bytes +``` + +Supported formats: `text/plain`, `text/html`, `image/png`, `image/bmp`, `application/x-file-list`. + +Max clipboard size configurable (default 10 MB). Hash-based dedup prevents re-sending identical content. + +### File Transfer Protocol (channel 0x05) + +``` +┌──────────────────────────────────────────────────────────┐ +│ File Transfer Chunk │ +├──────────┬────────────┬──────────┬────────┬──────────────┤ +│ Transfer │ Offset │ Total │ Flags │ Data │ +│ ID (4B) │ (8B LE) │ (8B LE) │ (1B) │ (variable) │ +└──────────┴────────────┴──────────┴────────┴──────────────┘ +``` + +- **Transfer initiation**: JSON message on control channel with filename, size, hash +- **Data transfer**: Binary chunks on media channel (64 KB default chunk size) +- **Resumable**: Client tracks received offsets, can resume from last chunk +- **Bidirectional**: Both viewer→device and device→viewer +- **Multiple simultaneous**: Transfer ID distinguishes parallel transfers + +### Cursor Protocol (channel 0x06) + +``` +┌────────────────────────────────────────────────┐ +│ Cursor Update │ +├────────┬────────┬───────┬───────┬──────────────┤ +│ Hot X │ Hot Y │ Width │ Height│ RGBA pixels │ +│ (2B LE)│ (2B LE)│(2B LE)│(2B LE)│ (W*H*4 bytes)│ +└────────┴────────┴───────┴───────┴──────────────┘ +``` + +- Sent when cursor image changes (not every move — position is in mouse events) +- Hot X/Y: cursor click point offset +- RGBA pixels: uncompressed cursor image (typically small, 32x32 to 128x128) +- Optionally zstd-compressed (flag `0x10`) + +### End-to-End Encryption + +Media channel encryption is **mandatory** for `remote_desktop` capability and **optional** for other media types: + +``` +Key Exchange (during media session setup): + 1. Viewer generates ephemeral X25519 keypair + 2. Viewer sends public key to device (via control channel, relay through server) + 3. Device generates ephemeral X25519 keypair + 4. Device sends public key to viewer + 5. Both compute shared secret: X25519(my_secret, their_public) + 6. Derive XSalsa20-Poly1305 key from shared secret (HKDF-SHA256) + +Per-Frame Encryption: + - Nonce: 24 bytes = channel_id(1B) + direction(1B) + sequence(4B) + zeros(18B) + - Encrypt: XSalsa20-Poly1305(key, nonce, plaintext) + - Output: 16-byte MAC + ciphertext + - Sequence counter prevents replay attacks +``` + +The server **cannot** decrypt media traffic — it only relays binary frames between viewer and device. True end-to-end encryption. + +### Native BetterDesk Client vs RustDesk + +CDAP with media channel enables building a **fully native BetterDesk client** that replaces RustDesk: + +| Feature | RustDesk Client | BetterDesk Native Client (CDAP) | +|---------|----------------|-------------------------------| +| Protocol | RustDesk proprietary protobuf | CDAP (open, documented) | +| Server dependency | Requires hbbs/hbbr compatible server | BetterDesk server only | +| Video codecs | VP9, H.264, H.265, VP8, AV1 | Same + extensible via manifest | +| Audio | Opus | Opus + extensible | +| E2E encryption | NaCl secretbox | XSalsa20-Poly1305 (compatible) | +| Widgets/controls | None (pure remote desktop) | Full widget system alongside remote desktop | +| File transfer | Built-in (proprietary) | CDAP file channel (documented, extensible) | +| Multi-monitor | Yes | Yes (display index in manifest) | +| Session recording | No | Server-side recording support (planned) | +| OS-level management | No | Yes (via widgets: services, packages, shell) | +| Custom actions | No | Yes (bridge-defined buttons, toggles) | +| Mixed-mode device | No | Yes — remote desktop + SCADA widgets on same device | +| Browser viewer | Web client (WIP) | Native CDAP player in panel (same tech) | +| Unattended access | Separate password | Same auth (JWT/API key) | +| Update mechanism | Manual | Server-pushed config updates | +| Plugin system | None | Bridge/manifest-based extensibility | + +**Key advantage**: A BetterDesk native client is both a remote desktop tool AND a management agent. A single install gives operators remote screen access, system monitoring widgets, remote shell, file transfer, and custom integrations — all through the same protocol and panel. + +### Backward Compatibility with RustDesk Ecosystem + +CDAP does **not** replace RustDesk protocol support in BetterDesk server. Both protocols coexist: + +``` +┌──────────────────────────────────────────────────────┐ +│ BetterDesk Server │ +│ │ +│ ┌──────────────────┐ ┌───────────────────────┐ │ +│ │ RustDesk Protocol │ │ CDAP Protocol │ │ +│ │ │ │ │ │ +│ │ • Signal :21116 │ │ • Gateway :21122 │ │ +│ │ • Relay :21117 │ │ • Media relay (same) │ │ +│ │ • API :21114 │ │ • REST /api/cdap/* │ │ +│ │ │ │ │ │ +│ │ For: existing │ │ For: new native │ │ +│ │ RustDesk clients │ │ clients, IoT, SCADA │ │ +│ └────────┬───────────┘ └──────────┬────────────┘ │ +│ │ │ │ +│ └───────────┬───────────────┘ │ +│ │ │ +│ ┌────────┴────────┐ │ +│ │ Unified peers │ │ +│ │ table + panel │ │ +│ └─────────────────┘ │ +└──────────────────────────────────────────────────────┘ +``` + +Migration path: +1. **Phase 1**: Existing RustDesk clients continue working unchanged +2. **Phase 2**: BetterDesk native client available as alternative with extra features +3. **Phase 3**: Users can gradually migrate devices from RustDesk protocol to CDAP +4. **Long term**: CDAP becomes the primary protocol; RustDesk support maintained for backward compatibility + +--- + +## Device Registration & Identity + +### Device Manifest + +The manifest is the core descriptor — it tells the server everything about the device and how to render its control interface. + +```json +{ + "manifest_version": "1.0", + "device": { + "name": "Boiler Room Controller", + "type": "scada", + "vendor": "Siemens", + "model": "S7-1200", + "firmware": "4.5.2", + "serial": "SN-2024-00847", + "location": "Building A, Floor 2, Room 201", + "tags": ["boiler", "hvac", "critical"], + "icon": "factory", + "description": "Main boiler room PLC controlling 3 gas boilers and circulation pumps" + }, + "bridge": { + "name": "modbus-betterdesk-bridge", + "version": "1.2.0", + "protocol": "modbus-tcp", + "target_host": "192.168.10.50", + "target_port": 502 + }, + "capabilities": [ + "telemetry", + "commands", + "alerts", + "logs" + ], + "heartbeat_interval": 15, + "widgets": [ + // see Widget Descriptor System section + ], + "alerts": [ + { + "id": "high_pressure", + "label": "High Pressure Alarm", + "severity": "critical", + "condition": "pressure > 8.0", + "message": "Boiler pressure exceeded 8.0 bar" + }, + { + "id": "pump_failure", + "label": "Pump Failure", + "severity": "warning", + "condition": "pump_1_status == false && pump_1_expected == true", + "message": "Circulation pump 1 stopped unexpectedly" + } + ] +} +``` + +### Device Types + +| Type Identifier | Display Name | Icon | Description | +|----------------|--------------|------|-------------| +| `rustdesk` | Remote Desktop | `monitor` | Standard RustDesk client (existing) | +| `scada` | SCADA/PLC | `factory` | Industrial controller, PLC, HMI | +| `iot` | IoT Device | `cpu` | Sensor, gateway, embedded system | +| `os_agent` | OS Agent | `terminal` | OS-level management daemon | +| `network` | Network Device | `globe` | Switch, router, firewall, AP | +| `camera` | Camera/NVR | `video` | IP camera, NVR, DVR | +| `desktop` | BetterDesk Desktop | `monitor-smartphone` | Native BetterDesk client (remote desktop + agent) | +| `custom` | Custom Device | `puzzle` | User-defined type | + +### ID Format + +- RustDesk devices: numeric IDs (e.g., `1340238749`) — unchanged +- CDAP devices: `CDAP-` prefix + 8-char hex (e.g., `CDAP-A7F3B210`) +- ID persisted across reconnects (server matches by `bridge.serial` or `device.serial`) +- Manual ID override: bridge can request specific ID in manifest (`requested_id` field) + +### Unified Device List + +CDAP devices are stored in the same `peers` table as RustDesk devices, with additional fields: + +```sql +ALTER TABLE peers ADD COLUMN device_type TEXT DEFAULT 'rustdesk'; +ALTER TABLE peers ADD COLUMN manifest_json TEXT; +ALTER TABLE peers ADD COLUMN bridge_id TEXT; +ALTER TABLE peers ADD COLUMN cdap_session_id TEXT; +``` + +This means: +- Dashboard counters include all device types +- Device list supports filtering by type +- Search works across all devices +- Tags, notes, user assignment — all work identically +- Ban/soft-delete applies to CDAP devices too + +--- + +## Widget Descriptor System + +Widgets are the UI building blocks that a device exposes to the panel. The bridge declares widgets in the manifest; the panel renders them dynamically. + +### Widget Types + +#### `toggle` — On/Off Switch + +```json +{ + "type": "toggle", + "id": "valve_main", + "label": "Main Valve", + "group": "Valves", + "value": true, + "readonly": false, + "confirm": true, + "confirm_message": "Are you sure you want to toggle the main valve?" +} +``` + +#### `gauge` — Numeric Value with Range + +```json +{ + "type": "gauge", + "id": "pressure", + "label": "Boiler Pressure", + "group": "Sensors", + "value": 3.7, + "unit": "bar", + "min": 0, + "max": 10, + "warning_low": 1.0, + "warning_high": 7.0, + "critical_low": 0.5, + "critical_high": 8.5, + "precision": 1, + "readonly": true +} +``` + +#### `button` — Action Trigger + +```json +{ + "type": "button", + "id": "emergency_stop", + "label": "Emergency Stop", + "group": "Safety", + "style": "danger", + "confirm": true, + "confirm_message": "This will immediately shut down all boilers. Confirm?", + "icon": "alert-octagon", + "cooldown": 5 +} +``` + +#### `chart` — Time-Series Graph + +```json +{ + "type": "chart", + "id": "temperature_history", + "label": "Temperature Trend", + "group": "Monitoring", + "chart_type": "line", + "points": 100, + "unit": "°C", + "min": 0, + "max": 200, + "series": [ + {"id": "temp_supply", "label": "Supply", "color": "#ef4444"}, + {"id": "temp_return", "label": "Return", "color": "#3b82f6"} + ], + "retention": "24h" +} +``` + +#### `select` — Dropdown/Mode Selector + +```json +{ + "type": "select", + "id": "operating_mode", + "label": "Operating Mode", + "group": "Control", + "value": "auto", + "options": [ + {"value": "auto", "label": "Automatic"}, + {"value": "manual", "label": "Manual"}, + {"value": "standby", "label": "Standby"}, + {"value": "maintenance", "label": "Maintenance"} + ], + "readonly": false +} +``` + +#### `slider` — Numeric Input with Range + +```json +{ + "type": "slider", + "id": "setpoint_temp", + "label": "Temperature Setpoint", + "group": "Control", + "value": 75, + "min": 40, + "max": 95, + "step": 1, + "unit": "°C", + "readonly": false +} +``` + +#### `text` — Read-Only Text Display + +```json +{ + "type": "text", + "id": "last_error", + "label": "Last Error", + "group": "Diagnostics", + "value": "E104: Flame sensor timeout at 14:23:07", + "style": "error" +} +``` + +#### `table` — Tabular Data + +```json +{ + "type": "table", + "id": "process_list", + "label": "Running Processes", + "group": "System", + "columns": [ + {"id": "pid", "label": "PID", "width": "80px"}, + {"id": "name", "label": "Name"}, + {"id": "cpu", "label": "CPU %", "width": "100px"}, + {"id": "memory", "label": "Memory", "width": "100px"} + ], + "max_rows": 50, + "sortable": true, + "readonly": true +} +``` + +#### `led` — Status Indicator + +```json +{ + "type": "led", + "id": "pump_1_status", + "label": "Pump 1", + "group": "Status", + "value": "green", + "states": { + "green": "Running", + "yellow": "Starting", + "red": "Fault", + "gray": "Offline" + } +} +``` + +#### `terminal` — Command Shell (OS Agent) + +```json +{ + "type": "terminal", + "id": "shell", + "label": "Remote Shell", + "group": "Management", + "shell": "/bin/bash", + "max_history": 1000, + "allowed_commands": ["systemctl", "journalctl", "ip", "ss", "df", "free"], + "blocked_commands": ["rm", "dd", "mkfs", "reboot"] +} +``` + +#### `desktop` — Remote Desktop Viewer (Media Channel) + +```json +{ + "type": "desktop", + "id": "remote_screen", + "label": "Remote Desktop", + "group": "Remote Access", + "display": 0, + "codec": "auto", + "max_fps": 60, + "audio": true, + "clipboard": true, + "file_transfer": true, + "fullscreen": true +} +``` + +Requires `remote_desktop` capability in manifest. Opens a media channel session on click. +Panel renders an interactive Canvas/WebCodecs viewer with input forwarding, clipboard sync, and file transfer toolbar. + +#### `video_stream` — One-Way Video (Media Channel) + +```json +{ + "type": "video_stream", + "id": "camera_feed", + "label": "Lobby Camera", + "group": "Surveillance", + "display": 0, + "codec": "h264", + "max_fps": 30, + "audio": true, + "controls": ["snapshot", "record"] +} +``` + +One-directional video — no input forwarding. Suitable for IP cameras, NVR feeds, kiosk displays. + +#### `file_browser` — File Transfer UI + +```json +{ + "type": "file_browser", + "id": "files", + "label": "File Manager", + "group": "Management", + "root_paths": ["/home", "/var/log", "/etc"], + "upload": true, + "download": true, + "delete": false, + "max_file_size": 1073741824 +} +``` + +Two-pane file browser (local ↔ remote) using the file transfer media channel. Supports drag-and-drop, progress tracking, and resumable transfers. + +### Widget Groups + +Widgets are organized into collapsible groups in the panel UI. Group order follows the order of first appearance in the manifest. + +### Widget State Updates + +Bridges send widget values in heartbeats (periodic) or via `state_update` messages (event-driven): + +```json +{ + "type": "bulk_update", + "timestamp": "2026-03-19T14:30:00Z", + "payload": { + "updates": [ + {"widget_id": "pressure", "value": 4.2}, + {"widget_id": "temperature_history", "value": {"temp_supply": 82.3, "temp_return": 61.7}}, + {"widget_id": "pump_1_status", "value": "green"}, + {"widget_id": "valve_main", "value": true} + ] + } +} +``` + +--- + +## Command Bus + +### Flow + +``` +┌────────┐ click ┌────────┐ validate ┌────────┐ WS command ┌────────┐ native ┌────────┐ +│ Panel │ ───────────► │ REST │ ────────────► │ CDAP │ ───────────► │ Bridge │ ────────► │ Device │ +│ User │ │ API │ RBAC + │Gateway │ │ │ protocol │ │ +│ │ ◄─────────── │ │ ◄──────────── │ │ ◄─────────── │ │ ◄──────── │ │ +└────────┘ WS push └────────┘ audit log └────────┘ response └────────┘ result └────────┘ +``` + +### REST API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/cdap/devices/{id}/command` | Send command to device | +| `GET` | `/api/cdap/devices/{id}/widgets` | Get current widget states | +| `GET` | `/api/cdap/devices/{id}/manifest` | Get device manifest | +| `GET` | `/api/cdap/devices` | List all CDAP devices | +| `GET` | `/api/cdap/devices?type=scada` | Filter by device type | +| `GET` | `/api/cdap/bridges` | List connected bridges | +| `POST` | `/api/cdap/devices/{id}/widget/{wid}` | Set specific widget value | + +### Command Message + +```json +{ + "type": "command", + "id": "cmd-a7f3b210-001", + "timestamp": "2026-03-19T14:30:15Z", + "payload": { + "command_id": "cmd-a7f3b210-001", + "widget_id": "valve_main", + "action": "set", + "value": false, + "operator": "admin", + "reason": "Scheduled maintenance" + } +} +``` + +### Command Actions + +| Action | Widget Types | Description | +|--------|-------------|-------------| +| `set` | toggle, select, slider | Set widget to specific value | +| `trigger` | button | Execute button action | +| `execute` | terminal | Run command in shell | +| `reset` | any | Reset widget to default value | +| `query` | any | Request current value (force refresh) | + +### Command Response + +```json +{ + "type": "command_response", + "id": "cmd-a7f3b210-001", + "timestamp": "2026-03-19T14:30:15.120Z", + "payload": { + "command_id": "cmd-a7f3b210-001", + "status": "ok", + "execution_time_ms": 120, + "result": { + "valve_main": false + } + } +} +``` + +### Command Status Codes + +| Status | Description | +|--------|-------------| +| `ok` | Command executed successfully | +| `error` | Command failed (see `error_message`) | +| `timeout` | Device did not respond in time | +| `rejected` | Device rejected the command (safety interlock, etc.) | +| `queued` | Command accepted, will execute asynchronously | +| `unauthorized` | Bridge does not allow this action | + +--- + +## API Bridge Architecture + +### The Core Concept + +An API Bridge is a small, standalone program that: + +1. **Connects upstream** to BetterDesk server via CDAP WebSocket (`:21122`) +2. **Connects downstream** to the target device via its native protocol +3. **Translates** between the two in real-time + +``` + ┌─────────────────────────────────────┐ + │ API Bridge │ + │ │ + BetterDesk │ ┌──────────┐ ┌──────────────┐ │ Device + Server ◄──────────┤ │ CDAP │◄───►│ Native │ ├───────► (PLC, + (:21122) WS/JSON │ │ Client │ │ Protocol │ │ Modbus sensor, + ────────►│ │ │ │ Client │ │◄─────── camera) + │ └──────────┘ └──────────────┘ │ + │ │ + │ ┌──────────────────────────────┐ │ + │ │ Widget ↔ Register │ │ + │ │ Mapping Table │ │ + │ └──────────────────────────────┘ │ + └─────────────────────────────────────┘ +``` + +### Bridge Types + +| Bridge Type | Complexity | Description | Example | +|------------|------------|-------------|---------| +| **Simple Telemetry** | ~50 LOC | Read-only sensors → gauges | Temperature sensor via serial | +| **Standard Control** | ~100-200 LOC | Read + write, widgets, commands | PLC via Modbus TCP | +| **Protocol Gateway** | ~300-500 LOC | Multiple devices behind one bridge | OPC-UA server with 50 tags | +| **OS Agent** | ~500-1000 LOC | Deep OS integration, shell, packages | Linux kernel management daemon | +| **Full Integration** | ~1000+ LOC | Complex ecosystem with alerts, logic | Building management system | + +### Bridge SDK (Optional) + +While bridges can be written from scratch (just JSON over WebSocket), optional SDKs reduce boilerplate: + +``` +betterdesk-bridge-sdk/ +├── python/ # pip install betterdesk-bridge +│ └── betterdesk_bridge/ +│ ├── __init__.py +│ ├── client.py # WebSocket client + reconnect +│ ├── manifest.py # Manifest builder +│ ├── widgets.py # Widget type helpers +│ └── bridge.py # Base bridge class +├── nodejs/ # npm install betterdesk-bridge +├── go/ # go get github.com/betterdesk/bridge-sdk-go +├── rust/ # betterdesk-bridge = "0.1" +└── c/ # Header-only library for embedded +``` + +### Bridge Deployment Models + +``` +Model A: Bridge on Dedicated Machine Model B: Bridge on Device Itself +┌───────────┐ LAN ┌──────────┐ ┌─────────────────────┐ +│ BetterDesk│◄─────────►│ Bridge │ │ Device │ +│ Server │ WS/TLS │ Machine │ │ ┌───────────────┐ │ +└───────────┘ └──────┬───┘ │ │ Bridge │ │ + │ │ │ (embedded) │ │ + ┌────┴───┐ │ └───────┬───────┘ │ + │ Device │ │ │ │ + │ (PLC) │ │ local access │ + └────────┘ └─────────────────────┘ + │ + ┌─────────┴─────────┐ + │ BetterDesk Server │ + └────────────────────┘ + +Model C: Bridge as Docker Sidecar Model D: Cloud-to-Cloud Bridge +┌──────────────────────────┐ ┌───────────┐ API ┌──────────┐ +│ Docker Host │ │ BetterDesk│◄────────►│ Bridge │ +│ ┌─────────┐ ┌────────┐ │ │ Server │ │ (cloud) │ +│ │BetterDesk│ │ Bridge │ │ └───────────┘ └────┬─────┘ +│ │ Server │◄┤ Sidecar│ │ │ +│ └─────────┘ └───┬────┘ │ ┌────┴─────┐ +│ │ │ │ Vendor │ +│ ┌────┴───┐ │ │ Cloud API│ +│ │ Device │ │ └──────────┘ +│ └─────────┘ │ +└──────────────────────────┘ +``` + +### Bridge Lifecycle + +``` + ┌─────────┐ + start │ INIT │ + ────────►│ │ + └────┬────┘ + │ connect to BetterDesk + ▼ + ┌─────────┐ + │ AUTH │──── fail ──► retry with backoff + │ │ + └────┬────┘ + │ auth_result: success + ▼ + ┌─────────┐ + │REGISTER │──── connect to device + │ │──── send manifest + └────┬────┘ + │ registered: device_id + ▼ + ┌─────────┐ + ┌───────►│ RUNNING │◄─── heartbeat loop + │ │ │◄─── command handling + │ └────┬────┘──── state updates + │ │ + │ WS disconnect / device error + │ ▼ + │ ┌─────────┐ + └────────┤RECONNECT│──── exponential backoff + │ │──── preserve device_id + └─────────┘ +``` + +--- + +## Bridge Examples + +### Example 1: Modbus TCP → SCADA PLC (Python, ~80 LOC) + +```python +#!/usr/bin/env python3 +"""BetterDesk Bridge: Modbus TCP PLC → CDAP""" + +import asyncio +import json +import websockets +from pymodbus.client import AsyncModbusTcpClient + +# Configuration +BETTERDESK_URL = "ws://betterdesk-server:21122" +API_KEY = "your-api-key-here" +PLC_HOST = "192.168.10.50" +PLC_PORT = 502 + +MANIFEST = { + "manifest_version": "1.0", + "device": { + "name": "Boiler Room PLC", + "type": "scada", + "vendor": "Siemens", + "model": "S7-1200", + "tags": ["boiler", "hvac"] + }, + "bridge": {"name": "modbus-bridge", "version": "1.0.0", "protocol": "modbus-tcp"}, + "capabilities": ["telemetry", "commands"], + "heartbeat_interval": 10, + "widgets": [ + {"type": "gauge", "id": "pressure", "label": "Boiler Pressure", + "unit": "bar", "min": 0, "max": 10, "readonly": True}, + {"type": "gauge", "id": "temperature", "label": "Water Temp", + "unit": "°C", "min": 0, "max": 150, "readonly": True}, + {"type": "toggle", "id": "pump_1", "label": "Pump 1", "readonly": False}, + {"type": "led", "id": "flame", "label": "Burner", + "states": {"green": "On", "red": "Fault", "gray": "Off"}} + ] +} + +# Modbus register → widget mapping +REGISTER_MAP = { + "pressure": {"address": 100, "type": "holding", "scale": 0.1}, + "temperature": {"address": 101, "type": "holding", "scale": 0.1}, + "pump_1": {"address": 200, "type": "coil"}, + "flame": {"address": 201, "type": "discrete", + "map": {0: "gray", 1: "green", 2: "red"}} +} + +async def main(): + modbus = AsyncModbusTcpClient(PLC_HOST, port=PLC_PORT) + await modbus.connect() + + async with websockets.connect(BETTERDESK_URL) as ws: + # Authenticate + await ws.send(json.dumps({"type": "auth", "payload": {"api_key": API_KEY}})) + auth_resp = json.loads(await ws.recv()) + assert auth_resp["payload"]["success"], "Auth failed" + + # Register device + await ws.send(json.dumps({"type": "register", "payload": {"manifest": MANIFEST}})) + reg_resp = json.loads(await ws.recv()) + device_id = reg_resp["payload"]["device_id"] + print(f"Registered as {device_id}") + + # Main loop: read sensors + handle commands + async def heartbeat_loop(): + while True: + holding = await modbus.read_holding_registers(100, 2) + coils = await modbus.read_coils(200, 1) + discrete = await modbus.read_discrete_inputs(201, 1) + + updates = [ + {"widget_id": "pressure", "value": round(holding.registers[0] * 0.1, 1)}, + {"widget_id": "temperature", "value": round(holding.registers[1] * 0.1, 1)}, + {"widget_id": "pump_1", "value": bool(coils.bits[0])}, + {"widget_id": "flame", "value": {0: "gray", 1: "green", 2: "red"}.get( + int(discrete.bits[0]), "gray")} + ] + await ws.send(json.dumps({"type": "bulk_update", "payload": {"updates": updates}})) + await asyncio.sleep(10) + + async def command_handler(): + async for message in ws: + msg = json.loads(message) + if msg["type"] == "command": + cmd = msg["payload"] + if cmd["widget_id"] == "pump_1" and cmd["action"] == "set": + await modbus.write_coil(200, cmd["value"]) + await ws.send(json.dumps({ + "type": "command_response", + "payload": {"command_id": cmd["command_id"], "status": "ok", + "result": {"pump_1": cmd["value"]}} + })) + + await asyncio.gather(heartbeat_loop(), command_handler()) + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### Example 2: Linux OS Agent (Go, ~120 LOC concept) + +```go +// betterdesk-os-agent: kernel-level system management bridge +package main + +// Manifest excerpt for OS Agent +var manifest = Manifest{ + Device: Device{ + Name: hostname, + Type: "os_agent", + Tags: []string{"linux", "server", "production"}, + }, + Widgets: []Widget{ + {Type: "gauge", ID: "cpu_usage", Label: "CPU Usage", Unit: "%", Min: 0, Max: 100, Readonly: true}, + {Type: "gauge", ID: "memory_usage", Label: "Memory", Unit: "%", Min: 0, Max: 100, Readonly: true}, + {Type: "gauge", ID: "disk_usage", Label: "Disk /", Unit: "%", Min: 0, Max: 100, Readonly: true}, + {Type: "chart", ID: "network_io", Label: "Network I/O", Series: []Series{ + {ID: "rx", Label: "RX", Color: "#3b82f6"}, + {ID: "tx", Label: "TX", Color: "#ef4444"}, + }}, + {Type: "table", ID: "services", Label: "Systemd Services", Columns: []Column{ + {ID: "name", Label: "Service"}, + {ID: "status", Label: "Status"}, + {ID: "memory", Label: "Memory"}, + }}, + {Type: "terminal", ID: "shell", Label: "Remote Shell", + AllowedCommands: []string{"systemctl", "journalctl", "ip", "ss", "df", "free", "top"}}, + {Type: "button", ID: "reboot", Label: "Reboot", Style: "danger", Confirm: true}, + {Type: "select", ID: "kernel_sched", Label: "CPU Scheduler", Options: []Option{ + {Value: "cfs", Label: "CFS (default)"}, + {Value: "deadline", Label: "Deadline"}, + {Value: "rt", Label: "Real-Time"}, + }}, + }, +} + +// Telemetry reads from /proc, /sys — direct kernel interface +// Commands execute via systemd D-Bus API or direct syscalls +// Shell widget uses PTY with command allowlist +``` + +### Example 3: REST API Bridge (Node.js, ~60 LOC concept) + +```javascript +// Bridge: IP Camera REST API → BetterDesk CDAP +const WebSocket = require('ws'); +const axios = require('axios'); + +const CAMERA_API = 'http://192.168.1.100/api'; +const manifest = { + manifest_version: '1.0', + device: { name: 'Lobby Camera', type: 'camera', vendor: 'Hikvision', model: 'DS-2CD2143G2-I' }, + widgets: [ + { type: 'led', id: 'recording', label: 'Recording', states: { green: 'Active', red: 'Stopped' } }, + { type: 'toggle', id: 'ir_mode', label: 'IR Night Vision' }, + { type: 'select', id: 'resolution', label: 'Resolution', + options: [ { value: '4mp', label: '4MP' }, { value: '1080p', label: '1080p' }, { value: '720p', label: '720p' } ] }, + { type: 'button', id: 'snapshot', label: 'Take Snapshot', style: 'primary' }, + { type: 'text', id: 'last_motion', label: 'Last Motion Detected' } + ] +}; + +// Bridge translates: +// GET camera/api/status → gauge/led widget values +// PUT camera/api/ir-mode ← toggle command from panel +// PUT camera/api/resolution ← select command from panel +// POST camera/api/snapshot ← button trigger from panel +``` + +### Example 4: ESP32 Microcontroller (Arduino C, ~90 LOC concept) + +```c +// Bridge running directly on ESP32 — no separate bridge machine needed +// Uses ArduinoWebSockets library + ArduinoJson + +const char* manifest = R"({ + "manifest_version": "1.0", + "device": { + "name": "Greenhouse Sensor Node #3", + "type": "iot", + "vendor": "Custom", + "model": "ESP32-WROOM" + }, + "widgets": [ + {"type": "gauge", "id": "soil_moisture", "label": "Soil Moisture", "unit": "%", + "min": 0, "max": 100, "readonly": true}, + {"type": "gauge", "id": "air_temp", "label": "Air Temperature", "unit": "°C", + "min": -10, "max": 60, "readonly": true}, + {"type": "gauge", "id": "humidity", "label": "Humidity", "unit": "%", + "min": 0, "max": 100, "readonly": true}, + {"type": "toggle", "id": "irrigation", "label": "Irrigation Valve", "readonly": false}, + {"type": "led", "id": "battery", "label": "Battery", + "states": {"green": ">50%", "yellow": "20-50%", "red": "<20%"}} + ] +})"; + +// ESP32 reads sensors via ADC/I2C → sends as heartbeat +// Toggle command → GPIO pin → solenoid valve +// ~4KB RAM footprint for CDAP client +``` + +### Example 5: BetterDesk Native Desktop Client (Rust/Go OS Agent, ~500 LOC) + +The BetterDesk native client is a **desktop bridge** — an OS agent that exposes the local machine as a CDAP device with full remote desktop capability PLUS system management widgets: + +```rust +// Conceptual Rust-based BetterDesk desktop agent +// Dual-purpose: remote desktop + system management + +struct BetterDeskAgent { + cdap: CdapClient, // CDAP WebSocket connection + screen_capture: DxgiCapture, // Platform screen capture + encoder: H264Encoder, // Hardware-accelerated encoder + input_sink: InputInjector, // Keyboard/mouse injection + audio_capture: AudioCapture, // System audio capture +} + +async fn connect() { + let manifest = json!({ + "manifest_version": "1.0", + "device": { + "name": hostname(), + "type": "desktop", + "vendor": "BetterDesk", + "model": os_info(), + "firmware": env!("CARGO_PKG_VERSION") + }, + "capabilities": [ + "telemetry", "commands", + "remote_desktop", "video_stream", "audio", + "clipboard", "file_transfer", "input_control" + ], + "media": { + "video": { + "codecs": ["h264", "vp9"], + "preferred": "h264", + "hardware_encoder": true, + "displays": enumerate_displays() + }, + "audio": { "codecs": ["opus"], "bidirectional": true }, + "input": { "keyboard": true, "mouse": true }, + "clipboard": { "text": true, "image": true }, + "file_transfer": { "max_file_size": 4294967296, "resumable": true } + }, + "widgets": [ + {"type": "desktop", "id": "screen", "label": "Remote Desktop", + "group": "Remote Access"}, + {"type": "file_browser", "id": "files", "label": "File Manager", + "group": "Remote Access"}, + {"type": "terminal", "id": "shell", "label": "Remote Shell", + "group": "Management", "shell": detect_shell()}, + {"type": "gauge", "id": "cpu", "label": "CPU Usage", + "group": "System", "unit": "%", "min": 0, "max": 100}, + {"type": "gauge", "id": "ram", "label": "RAM Usage", + "group": "System", "unit": "%", "min": 0, "max": 100}, + {"type": "gauge", "id": "disk", "label": "Disk Usage", + "group": "System", "unit": "%", "min": 0, "max": 100}, + {"type": "table", "id": "services", "label": "Services", + "group": "Management", + "columns": [ + {"id": "name", "label": "Service"}, + {"id": "status", "label": "Status"}, + {"id": "cpu", "label": "CPU %"} + ]}, + {"type": "text", "id": "os_info", "label": "OS Info", + "group": "System"} + ] + }); + + // Registration → control channel for widgets + // Media connect → binary channel for screen/audio/input + // Both on the same WebSocket connection + cdap.register(manifest).await; +} + +// When viewer clicks "Remote Desktop" widget: +async fn on_media_session(session: MediaSession) { + // Start screen capture → encode → send binary frames + // Receive input events → inject into OS + // Bidirectional clipboard + audio + file transfer + loop { + let frame = screen_capture.grab_frame(); + let encoded = encoder.encode(frame); + session.send_video(encoded).await; + + if let Some(input) = session.recv_input().await { + input_sink.inject(input); + } + } +} +``` + +**This is the key differentiator**: A BetterDesk native client is NOT just a remote desktop tool — it is a full management agent. From the panel, an operator can remote-control the screen, browse files, open a terminal, view CPU/RAM metrics, and manage system services — all from one device detail page, through one protocol, one port, one auth. + +--- + +## Security Model + +### Authentication Layers + +``` +┌──────────────────────────────────────────────────┐ +│ Security Stack │ +│ │ +│ Layer 1: Transport Security │ +│ ├── TLS 1.3 (optional, auto-detected) │ +│ └── DualModeListener (plain + TLS on same port) │ +│ │ +│ Layer 2: Client Authentication │ +│ ├── User/password (same as Node.js panel login) │ +│ ├── API key (shared secret, per-bridge) │ +│ ├── Device enrollment token (one-time) │ +│ ├── mTLS client certificates (optional) │ +│ └── IP allowlist (optional) │ +│ │ +│ Layer 3: Two-Factor Authentication (TOTP) │ +│ ├── Mandatory for user/password auth if enabled │ +│ ├── Same TOTP secret as panel + RustDesk login │ +│ ├── 5-minute partial token during 2FA flow │ +│ ├── Recovery code support (8 one-time codes) │ +│ └── 2FA setup via panel only (not via CDAP) │ +│ │ +│ Layer 4: Session Management │ +│ ├── JWT session token (24h, HS256) │ +│ ├── Token refresh (30-day chain max) │ +│ ├── Session bound to bridge IP │ +│ └── Admin revocable from panel │ +│ │ +│ Layer 5: Command Authorization (RBAC) │ +│ ├── Per-widget permissions (read/write) │ +│ ├── Per-device-type role restrictions │ +│ ├── Role hierarchy: Admin > Operator > Viewer │ +│ ├── Operator can read gauges but not set valves │ +│ └── Admin has full access │ +│ │ +│ Layer 6: Command Validation │ +│ ├── Value range checks (slider min/max) │ +│ ├── Confirmation requirement (confirm: true) │ +│ ├── Cooldown enforcement (cooldown: 5s) │ +│ └── Rate limiting (10 commands/min/device) │ +│ │ +│ Layer 7: Audit Trail │ +│ ├── Every command logged with operator identity │ +│ ├── Every state change logged with timestamp │ +│ ├── Failed commands + auth failures logged │ +│ └── Bridge connect/disconnect events │ +│ │ +│ Layer 8: Device-Side Safety │ +│ ├── Bridge validates commands before execution │ +│ ├── command_response: "rejected" for unsafe ops │ +│ ├── Blocked command list (terminal widget) │ +│ └── Physical safety interlocks are NEVER bypassed│ +│ │ +└──────────────────────────────────────────────────┘ +``` + +### Authentication Methods + +CDAP supports three authentication methods, all validated inside the WebSocket connection: + +#### Method 1: User/Password + 2FA (Interactive Clients) + +For desktop agents and operator-attended bridges. Uses the **same credentials as Node.js panel login and RustDesk client login** — one account across all protocols. + +```json +// Step 1: Client sends credentials +{ + "type": "auth", + "payload": { + "method": "user_password", + "username": "operator1", + "password": "...", + "device_id": "CDAP-A7F3B210", + "client_version": "1.0.0" + } +} + +// Step 2a: No 2FA → immediate success +{ + "type": "auth_result", + "payload": { + "success": true, + "token": "jwt_24h_token", + "role": "operator", + "device_id": "CDAP-A7F3B210" + } +} + +// Step 2b: 2FA enabled → partial token +{ + "type": "auth_result", + "payload": { + "success": false, + "requires_2fa": true, + "tfa_type": "totp", + "partial_token": "jwt_5min_ttl" + } +} + +// Step 3: Client sends TOTP code +{ + "type": "auth_2fa", + "payload": { + "partial_token": "jwt_5min_ttl", + "code": "123456" + } +} + +// Step 4: Full auth granted +{ + "type": "auth_result", + "payload": { + "success": true, + "token": "jwt_24h_token", + "role": "operator", + "device_id": "CDAP-A7F3B210" + } +} +``` + +The Go server reuses the existing `auth.VerifyPassword()` (PBKDF2-HMAC-SHA256, constant-time) and `auth.ValidateTOTP()` (RFC 6238, ±1 time step window) functions — **no auth code duplication**. + +#### Method 2: API Key (Unattended Bridges) + +For headless IoT/SCADA bridges that run 24/7 without operator interaction: + +```json +{ + "type": "auth", + "payload": { + "method": "api_key", + "key": "bdsk_a1b2c3d4e5f6...", + "device_id": "CDAP-A7F3B210" + } +} +``` + +API keys are created in the Node.js panel (Settings → API Keys) and stored in Go server's `api_keys` table. No 2FA prompt for API key auth. + +#### Method 3: Device Enrollment Token (First-Time Setup) + +One-time enrollment tokens for new devices. Created by admin in panel, expires after use: + +```json +{ + "type": "auth", + "payload": { + "method": "device_token", + "token": "enroll_xyz789...", + "device_id": "CDAP-A7F3B210" + } +} +``` + +After enrollment, the server issues a persistent API key for the device, stored locally. Subsequent connections use API key auth. + +### 2FA Integration with Existing System + +CDAP 2FA uses the **exact same TOTP infrastructure** as the Node.js panel and RustDesk client: + +``` +┌──────────────────────────────────────────────────────┐ +│ Unified 2FA Architecture │ +│ │ +│ TOTP Secret: users.totp_secret (Go server DB) │ +│ Algorithm: HMAC-SHA1, 6 digits, 30s period, ±1 │ +│ Recovery: 8 one-time codes (Go: unused, Node: DB)│ +│ │ +│ Setup: Panel only (POST /api/auth/totp/setup) │ +│ Verify: All three protocols share verification: │ +│ │ +│ ┌─────────┐ ┌─────────────┐ ┌──────────────┐ │ +│ │ Panel │ │ RustDesk │ │ CDAP Client │ │ +│ │ (Node.js)│ │ /api/login │ │ WS auth_2fa │ │ +│ └────┬─────┘ └──────┬──────┘ └──────┬───────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌────────────────────────────────────────────┐ │ +│ │ auth.ValidateTOTP(secret, code) │ │ +│ │ (Go server — single implementation) │ │ +│ └────────────────────────────────────────────┘ │ +│ │ +│ Rate limiting: 5 attempts / 5 min / IP (all paths) │ +│ Partial token TTL: 5 minutes (prevents brute-force) │ +│ Audit: every 2FA attempt logged (success + failure) │ +└──────────────────────────────────────────────────────┘ +``` + +**Key principle**: A user enables 2FA once in the panel → it immediately applies to panel login, RustDesk client login, AND CDAP client login. No separate 2FA setup per protocol. + +### Token Lifecycle for Long-Running Agents + +Desktop agents and IoT bridges run 24/7. JWT tokens expire after 24h. Token refresh prevents forced re-login: + +```json +// Client sends before token expires +{ + "type": "token_refresh", + "payload": { "token": "current_jwt" } +} + +// Server responds (no re-auth needed) +{ + "type": "token_refreshed", + "payload": { + "token": "new_jwt_24h", + "expires_at": "2026-03-21T14:30:00Z" + } +} +``` + +- Max token refresh chain: **30 days** (then full re-auth with password + 2FA) +- Admin can revoke sessions from panel → forces re-login on next refresh attempt +- API key auth bypasses token refresh entirely (keys have optional expiry date) + +### RustDesk Client Synchronization + +CDAP and RustDesk clients sharing the same server must appear unified in the panel: + +``` +┌──────────────────────────────────────────────────────────┐ +│ Unified Device & Identity Model │ +│ │ +│ ┌─────────────────┐ ┌─────────────────────────┐ │ +│ │ RustDesk Client │ │ BetterDesk Native Client│ │ +│ │ ID: 892734561 │ │ ID: CDAP-D2E9F4 │ │ +│ │ Protocol: Signal │ │ Protocol: CDAP │ │ +│ │ Port: 21116 │ │ Port: 21122 │ │ +│ └────────┬─────────┘ └───────────┬─────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ peers table (shared) │ │ +│ │ │ │ +│ │ id │ device_type │ linked_peer_id │ │ +│ │ 892734561 │ rustdesk │ CDAP-D2E9F4 │ │ +│ │ CDAP-D2E9F4 │ desktop │ 892734561 │ │ +│ │ CDAP-A7F3B2 │ scada │ (null) │ │ +│ │ CDAP-F9A2B1 │ os_agent │ (null) │ │ +│ └────────────────────────────────────────────────┘ │ +│ │ +│ Shared resources per user: │ +│ ├── Address book (GET /api/ab — same for both) │ +│ ├── Connection log (same audit table) │ +│ ├── Credentials (same users table) │ +│ ├── 2FA state (same TOTP secret) │ +│ ├── Tags/Groups (same peers.tags field) │ +│ └── RBAC role (same role across all protocols) │ +│ │ +│ Panel shows: │ +│ ├── Linked devices as single machine with 2 protocols │ +│ ├── Unified online status (either protocol = online) │ +│ └── Device type filter (rustdesk / desktop / scada ...) │ +└──────────────────────────────────────────────────────────┘ +``` + +**Auto-linking**: When both RustDesk and CDAP clients on the same machine are logged in with the same user, and hostnames match, the server automatically links them in `linked_peer_id`. Admin can also manually link/unlink from the panel. + +**Address books**: Both client types use the same `/api/ab` endpoint with the same JWT token. Adding a device in RustDesk address book is visible to CDAP client and vice versa. + +### Critical Safety Principle + +> **CDAP is a control plane, NOT a safety system.** Physical safety interlocks (emergency stops, pressure relief valves, overcurrent protection) must ALWAYS be implemented in hardware or local PLC logic, NEVER rely on network commands from BetterDesk. CDAP commands are "requests" — the device/bridge has the authority to reject any command that violates safety constraints. + +### RBAC for Widgets + +```json +{ + "widget_permissions": { + "admin": {"read": "*", "write": "*"}, + "operator": { + "read": "*", + "write": ["pump_1", "operating_mode", "setpoint_temp"], + "deny_write": ["emergency_stop"] + }, + "viewer": {"read": "*", "write": []} + } +} +``` + +--- + +## Device Revocation Protocol + +When an admin deletes a device from the panel, the device should not only be blocked from re-registering — it should be **actively notified** to disconnect and clear its server configuration. This prevents "zombie" devices from polling forever and ensures complete removal from the ecosystem. + +### Problem: Current Gaps + +The existing soft-delete mechanism blocks re-registration at the signal handler level (`IsPeerSoftDeleted` check) but has critical gaps: + +| Gap | Impact | +|-----|--------| +| No disconnect message sent | Device remains connected until next heartbeat timeout (~15s) | +| TCP/WS connections not closed | Existing connections persist after deletion at OS socket level | +| No config wipe command | Deleted device still has server address configured, retries indefinitely | +| No revocation propagation | Relay sessions initiated before deletion can continue | +| No linked device cascade | Deleting RustDesk peer doesn't revoke linked CDAP device | + +### Revocation Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Device Revocation Flow │ +│ │ +│ Admin clicks "Delete Device" in panel │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Node.js Panel │ DELETE /api/devices/:id?revoke=true │ +│ └────────┬─────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Go Server API │ DELETE /api/peers/:id?revoke=true │ +│ └────────┬─────────┘ │ +│ │ │ +│ ├─── 1. Soft-delete in DB (soft_deleted=true) │ +│ ├─── 2. Add to blocklist (ID-based, permanent) │ +│ ├─── 3. Revoke all active sessions (JWT blacklist) │ +│ ├─── 4. Close TCP/WS connections (if peer in memory) │ +│ │ │ +│ ├─── 5a. CDAP device? Send revocation message via WS │ +│ │ ┌──────────────────────────┐ │ +│ │ │ { │ │ +│ │ │ "type": "revoke", │ │ +│ │ │ "payload": { │ │ +│ │ │ "reason": "deleted", │ │ +│ │ │ "wipe_config": true, │ │ +│ │ │ "message": "..." │ │ +│ │ │ } │ │ +│ │ │ } │ │ +│ │ └──────────────────────────┘ │ +│ │ │ +│ ├─── 5b. RustDesk device? Close signal connection + │ +│ │ send RegisterPkResponse{NOT_SUPPORT} on retry │ +│ │ │ +│ ├─── 6. Cascade: revoke linked_peer_id device too │ +│ │ │ +│ └─── 7. Publish EventPeerRevoked to event bus │ +│ │ +│ Device receives revocation: │ +│ ├── CDAP client: clear server config → show "Revoked" UI → exit │ +│ ├── RustDesk client: connection rejected → stays configured (*) │ +│ └── IoT bridge: disconnect → optional auto-wipe → log event │ +│ │ +│ (*) RustDesk client cannot be remotely wiped — protocol limitation. │ +│ But re-registration is permanently blocked by blocklist + soft │ +│ delete check. Device shows as "offline" forever and never returns. │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### CDAP Revocation Messages + +#### Server → Device: `revoke` + +Sent by the server to a connected CDAP device when it is deleted: + +```json +{ + "type": "revoke", + "payload": { + "reason": "deleted", + "wipe_config": true, + "message": "Device removed by administrator.", + "timestamp": "2026-03-19T14:30:00Z" + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `reason` | string | `"deleted"` \| `"banned"` \| `"security"` \| `"expired"` | +| `wipe_config` | bool | If `true`, client MUST erase server address, keys, and cached credentials from local storage | +| `message` | string | Human-readable message (shown to user if desktop client) | +| `timestamp` | string | ISO 8601 server timestamp | + +**Client behavior after receiving `revoke`:** + +1. **Desktop agent (BetterDesk native)**: + - Display notification: "This device has been removed from the server." + - Clear stored: server address, API key, device token, JWT, cached keypair + - Close WebSocket connection gracefully + - Revert to "unconfigured" state (setup wizard on next launch) + - Write revocation event to local log + +2. **IoT/SCADA bridge**: + - Close WebSocket connection + - If `wipe_config: true`: remove server config file, clear credentials + - Enter standby mode (no reconnect attempts) + - Log revocation reason + timestamp + +3. **OS agent (daemon)**: + - Close WebSocket connection + - If `wipe_config: true`: clear `/etc/betterdesk/config.json` or equivalent + - Stop service gracefully (no auto-restart) + - Can be re-enrolled with new device token if needed + +#### Server → Device: `suspend` + +Temporary suspension (ban) — device disconnects but keeps config: + +```json +{ + "type": "suspend", + "payload": { + "reason": "banned", + "message": "Device suspended due to policy violation.", + "retry_after": null, + "timestamp": "2026-03-19T14:30:00Z" + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `reason` | string | `"banned"` \| `"maintenance"` \| `"policy"` | +| `retry_after` | int\|null | Seconds before client may retry, or `null` for indefinite | +| `message` | string | Human-readable explanation | + +**Client behavior:** +- Disconnect immediately +- Keep server config (not wiped) +- If `retry_after` is set: schedule reconnect after delay +- If `retry_after` is `null`: enter "suspended" state, display reason to user +- Unbanning from panel triggers no message — client reconnects on its own timer or manual retry + +#### Device → Server: `revoke_ack` + +Optional confirmation that revocation was processed: + +```json +{ + "type": "revoke_ack", + "payload": { + "config_wiped": true, + "device_id": "CDAP-A7F3B210" + } +} +``` + +The server does NOT wait for this ACK — the device is already blocked. This is best-effort for audit trail purposes. + +### RustDesk Device Revocation + +RustDesk's signal protocol does not have a revocation message. When a RustDesk device is deleted: + +1. **Immediate**: `peer.Map.Remove(id)` clears routing → device becomes unreachable for incoming connections +2. **Connection close**: If `peer.Entry.TCPConn` is present, call `TCPConn.Close()` to force TCP RST +3. **Re-registration block**: `IsPeerSoftDeleted()` and `IsPeerBanned()` checks both reject `RegisterPeer` and `RegisterPk` — device never re-appears in peer list +4. **ID blocklist**: Add device ID to `security.Blocklist` for belt-and-suspenders protection +5. **Client-side**: RustDesk client sees connection failure, retries with exponential backoff, eventually shows "offline". Server address remains configured but the device ID is permanently blocked. + +**Limitation**: RustDesk client **cannot** be remotely config-wiped. This is a protocol limitation of the existing RustDesk signal protocol. The device will retry forever (with backoff) but never successfully register. To fully remove, the end-user must manually reconfigure the client or uninstall it. + +> **BetterDesk native client advantage**: Unlike RustDesk, CDAP's `revoke` message with `wipe_config: true` enables full remote wipe — the device clears its configuration and stops all reconnect attempts. This is a key security improvement for enterprise environments. + +### Panel Revocation UI + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Delete Device: CDAP-A7F3B210 (Boiler Room PLC) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ⚠️ This action will permanently remove the device from │ +│ the server and block it from reconnecting. │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ☑ Revoke and wipe device configuration │ │ +│ │ Device will clear its server settings and disconnect. │ │ +│ │ (Recommended for decommissioned devices) │ │ +│ │ │ │ +│ │ ☑ Add device ID to permanent blocklist │ │ +│ │ Prevents re-registration even if client is │ │ +│ │ reinstalled with the same device ID. │ │ +│ │ │ │ +│ │ ☑ Cascade to linked devices │ │ +│ │ Also revoke linked RustDesk peer: 892734561 │ │ +│ │ (PC-Warehouse, linked via hostname match) │ │ +│ │ │ │ +│ │ ☐ Hard delete (permanent, cannot be undone) │ │ +│ │ Remove all traces from database. Default is soft │ │ +│ │ delete (recoverable within 30 days). │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ Message to device (optional): │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Device removed by administrator. │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ [Cancel] [Delete & Revoke] │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Cascade Delete with Linked Devices + +When a device has a `linked_peer_id`, revocation can cascade: + +``` +Admin deletes CDAP-D2E9F4 (desktop, linked to 892734561) + │ + ├── 1. Revoke CDAP-D2E9F4 → send "revoke" WS message + │ └── BetterDesk client clears config, disconnects + │ + ├── 2. Cascade: revoke 892734561 (RustDesk) + │ ├── Remove from peer map + │ ├── Close TCP connection + │ ├── Soft-delete in DB + │ └── Add ID to blocklist + │ + └── 3. Both entries marked soft_deleted + blocklisted + Panel shows: "2 devices revoked (cascade)" +``` + +**Cascade is opt-in** (checkbox in delete dialog). By default, only the selected device is revoked. Admin explicitly confirms cascade to avoid accidental removal of linked devices. + +**Reverse scenario**: Deleting RustDesk peer 892734561 can also cascade to CDAP-D2E9F4 if the cascade option is selected. + +### Revocation vs. Ban vs. Soft Delete — Comparison + +| Feature | Soft Delete | Ban | Revoke (CDAP) | +|---------|-------------|-----|---------------| +| Re-registration blocked | ✅ | ✅ | ✅ | +| Active disconnect message | ❌ | ❌ | ✅ | +| Client config wiped | ❌ | ❌ | ✅ (wipe_config) | +| Recoverable (admin undelete) | ✅ (30 days) | ✅ (unban) | Depends on hard/soft | +| Relay sessions terminated | ❌ | ❌ | ✅ (connection closed) | +| ID added to blocklist | ❌ | ❌ | ✅ (optional) | +| Linked device cascade | ❌ | ❌ | ✅ (opt-in) | +| Works for RustDesk clients | ✅ (passive) | ✅ (passive) | ⚠️ (no config wipe) | +| Works for CDAP clients | ✅ (passive) | ✅ (passive) | ✅ (full) | +| Audit trail | Basic | Basic | Full (revoke_ack) | + +--- + +## Panel Integration + +### Device List View + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Devices [Filter ▼] [+] │ +├─────────────────────────────────────────────────────────────┤ +│ 🖥️ 1340238749 | PC-Reception | Online | rustdesk │ +│ 🖥️ 892734561 | PC-Warehouse | Offline | rustdesk │ +│ 🏭 CDAP-A7F3B2 | Boiler Room PLC| Online | scada │ +│ 🔌 CDAP-C1D4E8 | Greenhouse #3 | Online | iot │ +│ 💻 CDAP-F9A2B1 | srv-prod-01 | Online | os_agent │ +│ 📹 CDAP-E3D7C6 | Lobby Camera | Degraded | camera │ +│ 🖥️ CDAP-D2E9F4 | PC-Design-03 | Online | desktop │ +├─────────────────────────────────────────────────────────────┤ +│ Total: 7 | Online: 5 | Offline: 1 | Degraded: 1 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Device Detail — Widget Panel (CDAP Device) + +When clicking a CDAP device, the detail panel shows dynamically-rendered widgets instead of the standard RustDesk device detail: + +``` +┌─────────────────────────────────────────────────────────┐ +│ CDAP-A7F3B210 — Boiler Room PLC │ +│ Type: SCADA | Bridge: modbus-bridge v1.0.0 │ +│ Status: Online | Last heartbeat: 3s ago │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ ▼ Sensors │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ Boiler Pressure │ │ Water Temp │ │ +│ │ ◉ 3.7 bar │ │ ◉ 82.3 °C │ │ +│ │ [░░░░▓▓░░░░░░] │ │ [░░░░░░▓▓░░░░] │ │ +│ │ 0 10 │ │ 0 150 │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ +│ ▼ Control │ +│ ┌──────────────────────────────────────────┐ │ +│ │ Pump 1 [■ ON ] │ │ +│ │ Operating Mode [Automatic ▼] │ │ +│ │ Temp Setpoint [====●=========] 75°C │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ▼ Status │ +│ │ 🟢 Pump 1: Running │ +│ │ 🟢 Burner: On │ +│ │ +│ ▼ Monitoring │ +│ │ ┌─── Temperature Trend (24h) ──────────────┐ │ +│ │ │ 90┤ ╱╲ ╱╲ ╱╲ ╱╲ ╱╲ │ │ +│ │ │ 80┤──╱──╲─╱──╲─╱──╲─╱──╲─╱──╲─── │ │ +│ │ │ 70┤─╱────╲────╲────╲────╲────╲── │ │ +│ │ │ 60┤╱────────────────────────────── │ │ +│ │ │ └──────────────────────────────────┘ │ │ +│ │ │ 06:00 09:00 12:00 15:00 18:00 │ │ +│ │ │ ── Supply ── Return │ │ +│ │ └──────────────────────────────────────────┘ │ +│ │ +│ ▼ Safety │ +│ │ [🛑 Emergency Stop] (requires confirmation) │ +│ │ +│ ▼ Diagnostics │ +│ │ Last Error: E104: Flame sensor timeout at 14:23:07 │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +### Dashboard Integration + +The main dashboard includes CDAP device counts: + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ RustDesk │ │ Native │ │ SCADA/PLC │ │ IoT Devices │ │ OS Agents │ +│ Desktops │ │ Desktops │ │ │ │ │ │ │ +│ 47 │ │ 12 │ │ 12 │ │ 89 │ │ 15 │ +│ 🟢 32 online │ │ 🟢 11 online│ │ 🟢 12 online│ │ 🟢 71 online│ │ 🟢 14 online│ +└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ +``` + +--- + +## Technology Stack + +| Component | Technology | License | Purpose | +|-----------|-----------|---------|---------| +| CDAP Gateway | Go `gorilla/websocket` | BSD-2 | WebSocket server for bridge connections | +| Widget State Store | SQLite/PostgreSQL | Public Domain/PostgreSQL | Widget values, history, manifests | +| Event Push | Go event bus (existing) | Internal | Real-time widget updates to panel | +| Widget Renderer | Vanilla JS + EJS | Internal | Dynamic UI from manifest JSON | +| Chart Library | Chart.js (existing in panel) | MIT | Time-series graphs for chart widgets | +| Video Decoder (browser) | WebCodecs API / JMuxer | W3C / MIT | Hardware-accelerated video decoding | +| Video Encoder (agent) | Platform DXGI/VA-API + libx264/libvpx | Various | Screen capture + encoding | +| Audio Codec | Opus (`libopus`) | BSD | Bidirectional audio encoding/decoding | +| E2E Crypto | XSalsa20-Poly1305 (NaCl) | Public Domain | End-to-end media encryption | +| Key Exchange | X25519 (Curve25519) | Public Domain | Ephemeral keys for media sessions | +| Canvas Renderer | HTML5 Canvas + OffscreenCanvas | W3C | Remote desktop video display | +| Bridge SDK (Python) | `websockets` + `dataclasses` | BSD | Optional bridge development kit | +| Bridge SDK (Node.js) | `ws` | MIT | Optional bridge development kit | +| Bridge SDK (Go) | `gorilla/websocket` | BSD-2 | Optional bridge development kit | + +--- + +## Implementation Phases + +### Phase 1: Foundation (MVP) + +**Goal**: Single CDAP device connects, registers, and shows widgets in panel. + +| Task | Effort | Priority | +|------|--------|----------| +| CDAP Gateway WebSocket server (`:21122`) | 2-3 days | P0 | +| Auth handler (API key validation) | 0.5 day | P0 | +| Manifest parser + validation | 1 day | P0 | +| `device_type` column in peers table | 0.5 day | P0 | +| Widget state storage (in-memory + DB) | 1-2 days | P0 | +| Heartbeat handler + widget value updates | 1 day | P0 | +| Device list filter by type (panel) | 0.5 day | P0 | +| Dynamic widget renderer (panel, 4 basic types) | 2-3 days | P0 | +| Basic command routing (set, trigger) | 1 day | P0 | +| Python bridge SDK (minimal) | 1 day | P1 | + +**Widget types in MVP**: `toggle`, `gauge`, `button`, `led` + +**Deliverables**: Working Modbus bridge demo with 4 widgets visible in panel. + +### Phase 2: Production Hardening + +**Goal**: Secure, reliable, multi-device CDAP deployment. + +| Task | Effort | Priority | +|------|--------|----------| +| TLS support on CDAP port | 0.5 day | P0 | +| RBAC per-widget permissions | 1-2 days | P0 | +| Command audit logging | 0.5 day | P0 | +| Rate limiting on commands | 0.5 day | P0 | +| Widget types: `chart`, `select`, `slider`, `text` | 2-3 days | P0 | +| Reconnect handling (preserve device ID) | 1 day | P0 | +| Bridge health monitoring | 0.5 day | P1 | +| Manifest versioning + backward compatibility | 1 day | P1 | +| Node.js bridge SDK | 1 day | P1 | +| i18n for widget labels (panel-side translation) | 0.5 day | P1 | + +**Deliverables**: Production-grade CDAP with 8 widget types, security, audit trail. + +### Phase 3: Advanced Features + +**Goal**: Full ecosystem management capabilities. + +| Task | Effort | Priority | +|------|--------|----------| +| Widget type: `table` (dynamic rows) | 1-2 days | P1 | +| Widget type: `terminal` (remote shell) | 2-3 days | P1 | +| Alert system (threshold-based from manifest) | 1-2 days | P1 | +| Alert notifications (WS push + panel bell icon) | 1 day | P1 | +| Go bridge SDK | 1 day | P2 | +| Multi-device bridge (one bridge → N devices) | 1 day | P2 | +| Widget groups (collapsible sections) | 0.5 day | P2 | +| Device dashboard view (dedicated device overview page) | 2-3 days | P2 | +| Custom icons for device types | 0.5 day | P2 | +| Bridge marketplace / registry concept | Design only | P3 | + +### Phase 4: Ecosystem + +**Goal**: Community-driven bridge ecosystem. + +| Task | Effort | Priority | +|------|--------|----------| +| Bridge template generator (CLI tool) | 1-2 days | P2 | +| C/Arduino bridge SDK (for ESP32) | 2-3 days | P2 | +| CDAP protocol documentation site | 1-2 days | P2 | +| Reference bridges (Modbus, SNMP, MQTT, REST) | 3-5 days | P2 | +| OS Agent reference implementation (Linux) | 3-5 days | P2 | +| OS Agent reference implementation (Windows) | 3-5 days | P2 | +| Bridge auto-discovery (mDNS/SSDP) | 1-2 days | P3 | +| Binary protocol option (MessagePack) | 1-2 days | P3 | + +### Phase 5: Media Channel (Native BetterDesk Client) + +**Goal**: Full remote desktop capability over CDAP — enabling a native BetterDesk client. + +| Task | Effort | Priority | +|------|--------|----------| +| Media frame mux/demux on CDAP Gateway | 2-3 days | P0 | +| Binary frame relay (server-side, E2E opaque) | 1-2 days | P0 | +| Media session establishment (connect API + pairing) | 1-2 days | P0 | +| E2E key exchange (X25519 → XSalsa20-Poly1305) | 1-2 days | P0 | +| Video channel: codec negotiation + keyframe requests | 1 day | P0 | +| Audio channel: Opus encode/decode + jitter buffer | 1-2 days | P0 | +| Input channel: keyboard/mouse event injection | 1-2 days | P0 | +| Clipboard channel: text/image sync with dedup | 1 day | P1 | +| File transfer channel: chunked, resumable | 1-2 days | P1 | +| Cursor channel: image + hotspot updates | 0.5 day | P1 | +| Panel: WebCodecs/Canvas desktop viewer widget | 3-5 days | P0 | +| Panel: file browser widget (two-pane) | 2-3 days | P1 | +| Panel: video stream widget (camera feeds) | 1-2 days | P1 | +| Desktop agent reference (Rust, Linux/Windows) | 5-10 days | P0 | +| Adaptive quality (bitrate/fps based on acks) | 1-2 days | P1 | +| Multi-monitor support (display index routing) | 1 day | P1 | +| Session recording (server-side, optional) | 2-3 days | P2 | +| P2P media (UDP hole-punch via signal server) | 3-5 days | P2 | + +**Deliverables**: Native BetterDesk desktop agent + panel viewer that fully replaces RustDesk client for managed devices, with remote desktop + system management in one tool. + +--- + +## Comparison with Alternatives + +| Feature | BetterDesk CDAP | Node-RED | Grafana + IoT | Home Assistant | Custom SCADA | +|---------|----------------|----------|---------------|---------------|--------------| +| Remote Desktop | ✅ Native | ❌ | ❌ | ❌ | ❌ | +| Device Widgets | ✅ Declarative | ✅ Flow-based | ✅ Dashboard | ✅ Lovelace | ✅ Custom HMI | +| Bridge Complexity | ~100 LOC | Node config | Agent + config | Integration + YAML | 1000s LOC | +| SCADA Integration | ✅ Via bridges | ✅ Native | ⚠️ Plugin | ⚠️ Plugin | ✅ Native | +| OS Management | ✅ Via OS Agent | ❌ | ❌ | ❌ | ❌ | +| Multi-Tenant | ✅ Existing RBAC | ⚠️ Limited | ✅ Orgs | ❌ | ⚠️ Varies | +| Unified Device List | ✅ Desktops + IoT + SCADA | ❌ | ❌ | ✅ IoT only | ❌ | +| Protocol | JSON/WS (simple) | MQTT/HTTP | Various | Various | Proprietary | +| Self-Hosted | ✅ | ✅ | ✅ | ✅ | ✅ | +| Open Source | ✅ | ✅ | ✅ (core) | ✅ | ❌ Usually | + +**BetterDesk CDAP unique value**: Only platform that manages remote desktops AND industrial/IoT devices in a single panel with unified identity, permissions, and audit trail. With the media channel, CDAP enables a **native BetterDesk client** that combines remote desktop, system monitoring, file management, and custom integrations in a single agent — something no other platform offers. + +--- + +## FAQ + +### Q: Do I need to modify BetterDesk server to add a new device type? + +**No.** Device types are defined by the bridge manifest. The server renders widgets dynamically based on the manifest JSON. Adding a new device type (e.g., "weather_station") requires only writing a bridge — zero server changes. + +### Q: Can one bridge manage multiple devices? + +**Yes.** A single bridge process can register multiple devices by opening multiple WebSocket connections (one per device) or by using a planned multi-device registration extension. + +### Q: What happens if the bridge disconnects? + +The device shows as "Offline" in the panel (same as RustDesk clients). Widget values freeze at last known state with a "stale" indicator. When the bridge reconnects, it re-authenticates and re-registers — the server matches by serial number and preserves the same device ID. + +### Q: Can CDAP devices appear in the same lists/filters as RustDesk desktops? + +**Yes.** Both are stored in the `peers` table with a `device_type` column. Existing search, tags, notes, group assignment, ban/delete — all work for CDAP devices. Dashboard counters separate by type. + +### Q: Is CDAP suitable for safety-critical SCADA systems? + +**CDAP is a monitoring and convenience control layer, not a safety system.** All safety-critical functions (emergency stops, pressure relief, overcurrent protection) must be implemented in local hardware/PLC logic that operates independently of any network connection. CDAP commands are "requests" — the bridge/device has full authority to reject unsafe operations. + +### Q: What's the minimum hardware for running a bridge? + +A bridge is a single-process program with minimal resource requirements. An ESP32 (240 MHz, 520 KB RAM) can run a bridge directly. For protocol gateways (Modbus, OPC-UA), a Raspberry Pi or any Linux machine with the protocol library is sufficient. + +### Q: Can widget definitions change after registration? + +**Yes.** The bridge can send an updated manifest at any time via a `register` message. The server diffs the widget list and updates the panel accordingly. This enables dynamic widget creation (e.g., a PLC that discovers connected modules at runtime). + +### Q: Does CDAP replace the RustDesk protocol entirely? + +**No — both coexist.** Existing RustDesk clients continue to work unchanged on ports 21115-21119. CDAP runs on port 21122 as a separate gateway. Both device types appear in the same panel, same device list, same permissions system. Migration from RustDesk to native BetterDesk client is gradual and optional. + +### Q: Can CDAP handle remote desktop at 60fps with encryption? + +**Yes.** The media channel uses binary WebSocket frames with XSalsa20-Poly1305 encryption (same algorithm as RustDesk). The server only relays opaque encrypted bytes — zero decode overhead. Video encoding/decoding happens at endpoints (hardware-accelerated H.264/VP9). Tested architecture supports 1080p60 at ~3-8 Mbps with <50ms latency through relay. + +### Q: What makes the native BetterDesk client better than RustDesk client? + +A BetterDesk native client is simultaneously a remote desktop tool AND a management agent. Single install provides: remote screen control, file browser, remote terminal, system metrics, service management, custom widgets — all through one protocol, one port, one auth. RustDesk is pure remote desktop; BetterDesk native client is remote desktop + device management. + +### Q: Can I stream a camera feed without allowing input control? + +**Yes.** Use the `video_stream` widget type instead of `desktop`. It opens a media session with video-only (and optionally audio) but no input channel. The panel renders a read-only video player with optional snapshot/record buttons. + +### Q: Is the media channel end-to-end encrypted? + +**Yes for `remote_desktop` capability — mandatory.** The server relays binary frames without decrypting them. Key exchange uses ephemeral X25519 keypairs negotiated between viewer and device. Each frame is encrypted with XSalsa20-Poly1305 using a counter-based nonce. Even the server operator cannot see the screen content. + +--- + +## Appendix A: Reserved Port Allocation + +| Port | Service | Status | +|------|---------|--------| +| 21114 | HTTP API (Go server) | ✅ In use | +| 21115 | NAT test | ✅ In use | +| 21116 | Signal (TCP/UDP) | ✅ In use | +| 21117 | Relay (TCP) | ✅ In use | +| 21118 | WebSocket Signal | ✅ In use | +| 21119 | WebSocket Relay | ✅ In use | +| 21120 | HTTP API (alternative) | ✅ In use | +| 21121 | RustDesk Client API (Node.js) | ✅ In use | +| **21122** | **CDAP Gateway (WebSocket + Binary Media)** | **📋 Reserved for CDAP** | +| 21123-21130 | Reserved for future CDAP extensions (P2P, clustering) | 📋 Reserved | + +## Appendix B: Wire Protocol Quick Reference + +``` +Client → Server: + auth → {api_key} + register → {manifest} + heartbeat → {widget_values} + state_update → {widget_id, value} + bulk_update → {updates: [{widget_id, value}]} + command_response→ {command_id, status, result} + event → {event_type, data} + log → {level, message} + +Server → Client: + auth_result → {success, device_id} + registered → {device_id} + command → {command_id, widget_id, action, value} + media_connect → {session_id, viewer_pk, codecs} + config_update → {key, value} + ping → {server_time} + error → {code, message} + +Client → Server (media accept): + media_accept → {session_id, device_pk} + +Binary Frames (media channel, channel IDs): + 0x01 Video → codec_id(1B) + pts(8B) + display(1B) + coded_data + 0x02 Audio → codec_id(1B) + pts(8B) + opus_data + 0x03 Input → type(1B) + event_data (kbd/mouse/touch) + 0x04 Clipboard → format + hash + raw_bytes + 0x05 File → transfer_id(4B) + offset(8B) + total(8B) + flags(1B) + data + 0x06 Cursor → hot_x(2B) + hot_y(2B) + w(2B) + h(2B) + rgba_pixels + 0x07 Display → display_info (resolution, name, DPI) + 0x08 Control → JSON media control (keyframe_request, video_ack, quality) +``` + +## Appendix C: Error Codes + +| Code | Name | Description | +|------|------|-------------| +| 1000 | `AUTH_FAILED` | Invalid API key or expired session | +| 1001 | `AUTH_REVOKED` | API key revoked by admin | +| 2000 | `MANIFEST_INVALID` | Manifest JSON validation failed | +| 2001 | `MANIFEST_VERSION_UNSUPPORTED` | Server does not support this manifest version | +| 2002 | `WIDGET_TYPE_UNKNOWN` | Unknown widget type in manifest | +| 3000 | `COMMAND_REJECTED` | Server rejected command (RBAC, rate limit) | +| 3001 | `COMMAND_TIMEOUT` | Device did not respond within timeout | +| 3002 | `DEVICE_OFFLINE` | Target device is not connected | +| 4000 | `RATE_LIMIT` | Too many messages from this bridge | +| 4001 | `MESSAGE_TOO_LARGE` | Message exceeds max size | +| 5000 | `INTERNAL_ERROR` | Server internal error | +| 6000 | `MEDIA_NOT_SUPPORTED` | Device does not declare media capabilities | +| 6001 | `MEDIA_SESSION_FAILED` | Media session establishment failed | +| 6002 | `MEDIA_CODEC_MISMATCH` | No common codec between viewer and device | +| 6003 | `MEDIA_ENCRYPTION_FAILED` | E2E key exchange failed | +| 6004 | `MEDIA_SESSION_LIMIT` | Max concurrent media sessions reached | +| 6005 | `MEDIA_CHANNEL_CLOSED` | Media channel closed by peer | diff --git a/screenshots/README.md b/screenshots/README.md index 2e3101a6..2883caa9 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -13,10 +13,10 @@ For the README.md, please add the following screenshots: - Search functionality 2. **devices-list.png** - Device management view showing: - - Complete device table with multiple entries - - Mix of online/offline devices - - Device notes - - Action buttons (connect, info, edit, delete) + - Horizontal folder chips bar (scrollable pills) + - Unified toolbar with search, segmented filters, column toggle + - Slim table with status dot, device ID, hostname, platform + - Kebab context menu (⋮) per row 3. **device-details.png** - Device details modal showing: - Device ID @@ -27,10 +27,11 @@ For the README.md, please add the following screenshots: - Created timestamp - Additional metadata -4. **mobile-view.png** - Mobile responsive view showing: - - Adapted layout for mobile screens - - Hamburger menu (if applicable) - - Touch-friendly controls +4. **mobile-view.png** - Mobile responsive view (≤600px) showing: + - Card-style device rows (CSS grid 2-column layout) + - Bottom sheet kebab menu with backdrop overlay + - Compact folder chips (icon-only at ≤400px) + - Touch-friendly controls with larger tap targets ## How to Create Screenshots diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json index 0e623f67..4a413884 100644 --- a/web-nodejs/lang/en.json +++ b/web-nodejs/lang/en.json @@ -122,6 +122,9 @@ "delete_warning": "You are about to permanently delete this device:", "delete_permanent": "This action cannot be undone. All data associated with this device will be permanently removed from the database.", "delete_success": "Device deleted successfully", + "revoke_option": "Revoke device (disconnect + block re-registration)", + "revoke_hint": "The device will be immediately disconnected and permanently blocked from reconnecting to this server.", + "revoke_success": "Device revoked and blocklisted successfully", "details": "Device Details", "ban_title": "Ban Device", "ban_confirm": "Are you sure you want to ban device {id}?", @@ -147,7 +150,14 @@ "invalid_id": "Invalid device ID (6-16 characters required)", "invalid_id_format": "Invalid ID format (letters, numbers, dashes, underscores only)", "id_exists": "Device ID already exists", - "no_selection": "No devices selected" + "no_selection": "No devices selected", + "device_type": "Type", + "filter_type_all": "All Types", + "filter_type_rustdesk": "RustDesk", + "filter_type_desktop": "Desktop", + "filter_type_scada": "SCADA", + "filter_type_iot": "IoT", + "filter_type_agent": "Agent" }, "keys": { "title": "Server Keys", @@ -1060,5 +1070,37 @@ "no_file": "No backup file selected", "invalid_json": "Invalid backup file: not valid JSON", "invalid_format": "Invalid backup file: not a BetterDesk backup" + }, + "cdap": { + "device_detail": "CDAP Device", + "loading": "Loading...", + "loading_widgets": "Loading device widgets...", + "load_error": "Failed to load device data", + "connected": "Connected", + "disconnected": "Disconnected", + "device_offline_msg": "Device is currently offline. Widget values may be stale.", + "no_widgets": "No widgets available", + "no_widgets_desc": "This device has not registered a CDAP manifest with widget definitions.", + "command_log": "Command Log", + "clear_log": "Clear log", + "confirm_command": "Confirm Command", + "select_option": "Select", + "cdap_status": "CDAP Status", + "cdap_enabled": "CDAP Enabled", + "cdap_disabled": "CDAP Disabled", + "cdap_devices": "CDAP Devices", + "cdap_connections": "Active Connections", + "send_command": "Send Command", + "command_sent": "Command sent successfully", + "command_failed": "Failed to send command" + }, + "desktop": { + "switch_mode": "Desktop Mode", + "console_mode": "Console Mode", + "loading": "Loading...", + "minimize": "Minimize", + "maximize": "Maximize", + "restore": "Restore", + "close": "Close" } } diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json index 75724d41..57aee9d0 100644 --- a/web-nodejs/lang/pl.json +++ b/web-nodejs/lang/pl.json @@ -122,6 +122,9 @@ "delete_warning": "Za chwilę trwale usuniesz to urządzenie:", "delete_permanent": "Ta operacja jest nieodwracalna. Wszystkie dane powiązane z tym urządzeniem zostaną trwale usunięte z bazy danych.", "delete_success": "Urządzenie usunięte", + "revoke_option": "Odwołaj urządzenie (rozłącz + zablokuj ponowną rejestrację)", + "revoke_hint": "Urządzenie zostanie natychmiast rozłączone i trwale zablokowane przed ponownym połączeniem z tym serwerem.", + "revoke_success": "Urządzenie odwołane i dodane do czarnej listy", "details": "Szczegóły urządzenia", "ban_title": "Zablokuj urządzenie", "ban_confirm": "Czy na pewno chcesz zablokować urządzenie {id}?", @@ -147,7 +150,14 @@ "invalid_id": "Nieprawidłowe ID urządzenia (wymagane 6-16 znaków)", "invalid_id_format": "Nieprawidłowy format ID (tylko litery, cyfry, myślniki i podkreślenia)", "id_exists": "Urządzenie o tym ID już istnieje", - "no_selection": "Nie wybrano żadnych urządzeń" + "no_selection": "Nie wybrano żadnych urządzeń", + "device_type": "Typ", + "filter_type_all": "Wszystkie typy", + "filter_type_rustdesk": "RustDesk", + "filter_type_desktop": "Desktop", + "filter_type_scada": "SCADA", + "filter_type_iot": "IoT", + "filter_type_agent": "Agent" }, "keys": { "title": "Klucze serwera", @@ -1060,5 +1070,37 @@ "no_file": "Nie wybrano pliku kopii zapasowej", "invalid_json": "Nieprawidłowy plik kopii: nie jest poprawnym JSON", "invalid_format": "Nieprawidłowy plik kopii: to nie jest kopia BetterDesk" + }, + "cdap": { + "device_detail": "Urządzenie CDAP", + "loading": "Ładowanie...", + "loading_widgets": "Ładowanie widgetów urządzenia...", + "load_error": "Nie udało się załadować danych urządzenia", + "connected": "Połączony", + "disconnected": "Rozłączony", + "device_offline_msg": "Urządzenie jest obecnie offline. Wartości widgetów mogą być nieaktualne.", + "no_widgets": "Brak dostępnych widgetów", + "no_widgets_desc": "To urządzenie nie zarejestrowało manifestu CDAP z definicjami widgetów.", + "command_log": "Dziennik poleceń", + "clear_log": "Wyczyść dziennik", + "confirm_command": "Potwierdź polecenie", + "select_option": "Wybierz", + "cdap_status": "Status CDAP", + "cdap_enabled": "CDAP włączony", + "cdap_disabled": "CDAP wyłączony", + "cdap_devices": "Urządzenia CDAP", + "cdap_connections": "Aktywne połączenia", + "send_command": "Wyślij polecenie", + "command_sent": "Polecenie wysłane pomyślnie", + "command_failed": "Nie udało się wysłać polecenia" + }, + "desktop": { + "switch_mode": "Tryb pulpitu", + "console_mode": "Tryb konsoli", + "loading": "Ładowanie...", + "minimize": "Minimalizuj", + "maximize": "Maksymalizuj", + "restore": "Przywróć", + "close": "Zamknij" } } diff --git a/web-nodejs/lang/zh.json b/web-nodejs/lang/zh.json index dc266195..4006811d 100644 --- a/web-nodejs/lang/zh.json +++ b/web-nodejs/lang/zh.json @@ -122,6 +122,9 @@ "delete_warning": "您即将永久删除此设备:", "delete_permanent": "此操作无法撤销。与该设备关联的所有数据将从数据库中永久删除。", "delete_success": "设备删除成功", + "revoke_option": "吊销设备(断开连接 + 阻止重新注册)", + "revoke_hint": "设备将被立即断开连接,并永久阻止重新连接到此服务器。", + "revoke_success": "设备已吊销并加入黑名单", "details": "设备详情", "ban_title": "封禁设备", "ban_confirm": "确定要封禁设备 {id} 吗?", @@ -147,7 +150,14 @@ "invalid_id": "设备 ID 无效(需要 6-16 个字符)", "invalid_id_format": "ID 格式无效(仅允许字母、数字、连字符和下划线)", "id_exists": "设备 ID 已存在", - "no_selection": "未选择任何设备" + "no_selection": "未选择任何设备", + "device_type": "类型", + "filter_type_all": "所有类型", + "filter_type_rustdesk": "RustDesk", + "filter_type_desktop": "桌面", + "filter_type_scada": "SCADA", + "filter_type_iot": "物联网", + "filter_type_agent": "代理" }, "keys": { "title": "服务器密钥", @@ -1060,5 +1070,37 @@ "no_file": "未选择备份文件", "invalid_json": "无效的备份文件:不是有效的 JSON", "invalid_format": "无效的备份文件:不是 BetterDesk 备份" + }, + "cdap": { + "device_detail": "CDAP 设备", + "loading": "加载中...", + "loading_widgets": "正在加载设备控件...", + "load_error": "无法加载设备数据", + "connected": "已连接", + "disconnected": "已断开", + "device_offline_msg": "设备当前处于离线状态。控件值可能已过时。", + "no_widgets": "没有可用的控件", + "no_widgets_desc": "此设备尚未注册包含控件定义的 CDAP 清单。", + "command_log": "命令日志", + "clear_log": "清除日志", + "confirm_command": "确认命令", + "select_option": "选择", + "cdap_status": "CDAP 状态", + "cdap_enabled": "CDAP 已启用", + "cdap_disabled": "CDAP 已禁用", + "cdap_devices": "CDAP 设备", + "cdap_connections": "活跃连接", + "send_command": "发送命令", + "command_sent": "命令发送成功", + "command_failed": "命令发送失败" + }, + "desktop": { + "switch_mode": "桌面模式", + "console_mode": "控制台模式", + "loading": "加载中...", + "minimize": "最小化", + "maximize": "最大化", + "restore": "还原", + "close": "关闭" } } diff --git a/web-nodejs/middleware/security.js b/web-nodejs/middleware/security.js index 82c57989..4b167087 100644 --- a/web-nodejs/middleware/security.js +++ b/web-nodejs/middleware/security.js @@ -29,7 +29,7 @@ const helmetMiddleware = helmet({ imgSrc: ["'self'", "data:", "blob:"], mediaSrc: ["'self'", "blob:"], // blob: required by JMuxer MSE video decoding connectSrc: connectSources, - frameSrc: ["'none'"], + frameSrc: ["'self'"], objectSrc: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], diff --git a/web-nodejs/public/css/cdap.css b/web-nodejs/public/css/cdap.css new file mode 100644 index 00000000..39e93127 --- /dev/null +++ b/web-nodejs/public/css/cdap.css @@ -0,0 +1,682 @@ +/** + * BetterDesk Console - CDAP Device Page Styles + * Widget grid layout, individual widget types, and command log. + */ + +/* ── Page Layout ──────────────────────────────────────────────────── */ + +.cdap-device-page { + max-width: 1400px; + margin: 0 auto; +} + +/* ── Device Header ────────────────────────────────────────────────── */ + +.cdap-device-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md, 1rem); + margin-bottom: var(--space-lg, 1.5rem); + flex-wrap: wrap; +} + +.cdap-device-title { + display: flex; + align-items: center; + gap: var(--space-sm, 0.5rem); +} + +.cdap-back-btn { + color: var(--text-secondary, #8b949e); + transition: color 0.15s; +} + +.cdap-back-btn:hover { + color: var(--text-primary, #e6edf3); +} + +.cdap-device-identity h1 { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary, #e6edf3); + margin: 0; +} + +.cdap-device-meta { + display: flex; + align-items: center; + gap: var(--space-md, 1rem); + margin-top: 4px; +} + +.cdap-meta-item { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.8rem; + color: var(--text-secondary, #8b949e); +} + +.cdap-meta-item .material-icons { + font-size: 16px; +} + +/* Status indicator */ +.cdap-device-status { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 500; + background: var(--bg-tertiary, #21262d); + border: 1px solid var(--border-color, #30363d); +} + +.cdap-device-status.online { + background: rgba(63, 185, 80, 0.1); + border-color: rgba(63, 185, 80, 0.3); + color: #3fb950; +} + +.cdap-device-status.offline { + background: rgba(139, 148, 158, 0.1); + border-color: rgba(139, 148, 158, 0.3); + color: #8b949e; +} + +.cdap-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; +} + +.cdap-device-status.online .cdap-status-dot { + box-shadow: 0 0 6px rgba(63, 185, 80, 0.5); +} + +/* ── Offline Banner ───────────────────────────────────────────────── */ + +.cdap-offline-banner { + display: flex; + align-items: center; + gap: var(--space-sm, 0.5rem); + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + background: rgba(210, 153, 34, 0.1); + border: 1px solid rgba(210, 153, 34, 0.3); + border-radius: var(--radius-md, 8px); + color: #d29922; + font-size: 0.85rem; + margin-bottom: var(--space-lg, 1.5rem); +} + +.cdap-offline-banner .material-icons { + font-size: 20px; +} + +/* ── Widget Grid ──────────────────────────────────────────────────── */ + +.cdap-widget-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--space-md, 1rem); + margin-bottom: var(--space-lg, 1.5rem); +} + +.cdap-widget-category { + grid-column: 1 / -1; + margin-top: var(--space-md, 1rem); +} + +.cdap-widget-category:first-child { + margin-top: 0; +} + +.cdap-widget-category h3 { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary, #8b949e); + margin: 0; + padding-bottom: var(--space-xs, 0.25rem); + border-bottom: 1px solid var(--border-color, #30363d); +} + +/* Widget Card */ +.cdap-widget { + background: var(--bg-secondary, #161b22); + border: 1px solid var(--border-color, #30363d); + border-radius: var(--radius-md, 8px); + padding: var(--space-md, 1rem); + display: flex; + flex-direction: column; + gap: var(--space-sm, 0.5rem); + transition: border-color 0.15s; +} + +.cdap-widget:hover { + border-color: var(--border-hover, #484f58); +} + +.cdap-widget-readonly { + opacity: 0.85; +} + +/* Widget sizes */ +.cdap-widget-sm { + min-height: auto; +} + +.cdap-widget-lg { + grid-column: span 2; +} + +@media (max-width: 768px) { + .cdap-widget-lg { + grid-column: span 1; + } +} + +.cdap-widget-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.cdap-widget-label { + font-size: 0.8rem; + font-weight: 500; + color: var(--text-secondary, #8b949e); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.cdap-widget-unit { + font-size: 0.75rem; + color: var(--text-tertiary, #6e7681); +} + +.cdap-widget-body { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; +} + +/* ── Toggle Widget ────────────────────────────────────────────────── */ + +.cdap-toggle { + position: relative; + display: inline-block; + width: 48px; + height: 26px; +} + +.cdap-toggle-input { + opacity: 0; + width: 0; + height: 0; +} + +.cdap-toggle-slider { + position: absolute; + inset: 0; + background: var(--bg-tertiary, #21262d); + border: 1px solid var(--border-color, #30363d); + border-radius: 26px; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} + +.cdap-toggle-slider::before { + content: ''; + position: absolute; + left: 3px; + top: 3px; + width: 18px; + height: 18px; + background: var(--text-secondary, #8b949e); + border-radius: 50%; + transition: transform 0.2s, background 0.2s; +} + +.cdap-toggle-input:checked + .cdap-toggle-slider { + background: rgba(63, 185, 80, 0.2); + border-color: rgba(63, 185, 80, 0.4); +} + +.cdap-toggle-input:checked + .cdap-toggle-slider::before { + transform: translateX(22px); + background: #3fb950; +} + +.cdap-toggle-input:disabled + .cdap-toggle-slider { + cursor: not-allowed; + opacity: 0.5; +} + +.cdap-toggle-label { + margin-left: var(--space-sm, 0.5rem); + font-size: 0.85rem; + font-weight: 500; + color: var(--text-primary, #e6edf3); +} + +/* ── Gauge Widget ─────────────────────────────────────────────────── */ + +.cdap-gauge { + display: flex; + flex-direction: column; + gap: 6px; +} + +.cdap-gauge-bar { + height: 8px; + background: var(--bg-tertiary, #21262d); + border-radius: 4px; + overflow: hidden; +} + +.cdap-gauge-fill { + height: 100%; + background: var(--accent-color, #58a6ff); + border-radius: 4px; + transition: width 0.5s ease; +} + +.cdap-gauge-fill.cdap-gauge-warning { + background: #d29922; +} + +.cdap-gauge-fill.cdap-gauge-danger { + background: #f85149; +} + +.cdap-gauge-value { + display: flex; + align-items: baseline; + justify-content: space-between; +} + +.cdap-gauge-number { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary, #e6edf3); +} + +.cdap-gauge-range { + font-size: 0.75rem; + color: var(--text-tertiary, #6e7681); +} + +/* ── Button Widget ────────────────────────────────────────────────── */ + +.cdap-action-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: var(--bg-tertiary, #21262d); + border: 1px solid var(--border-color, #30363d); + border-radius: var(--radius-sm, 6px); + color: var(--text-primary, #e6edf3); + font-size: 0.85rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.cdap-action-btn:hover { + background: var(--bg-hover, #292e36); + border-color: var(--border-hover, #484f58); +} + +.cdap-action-btn:active { + background: var(--accent-color, #58a6ff); + color: #fff; +} + +.cdap-action-btn .material-icons { + font-size: 18px; +} + +/* ── LED Widget ───────────────────────────────────────────────────── */ + +.cdap-led { + display: flex; + align-items: center; + gap: var(--space-sm, 0.5rem); +} + +.cdap-led-light { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid var(--border-color, #30363d); + transition: background 0.3s, box-shadow 0.3s; +} + +.cdap-led-light.off { + background: var(--bg-tertiary, #21262d); +} + +.cdap-led-light.on { + background: #3fb950; + box-shadow: 0 0 8px rgba(63, 185, 80, 0.5); + border-color: rgba(63, 185, 80, 0.4); +} + +.cdap-led-label { + font-size: 0.85rem; + color: var(--text-primary, #e6edf3); +} + +/* ── Text Widget ──────────────────────────────────────────────────── */ + +.cdap-text-value { + font-size: 1.1rem; + font-weight: 500; + color: var(--text-primary, #e6edf3); + word-break: break-word; +} + +/* ── Slider Widget ────────────────────────────────────────────────── */ + +.cdap-slider-wrap { + display: flex; + flex-direction: column; + gap: 4px; +} + +.cdap-slider-input { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 6px; + background: var(--bg-tertiary, #21262d); + border-radius: 3px; + outline: none; +} + +.cdap-slider-input::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; + background: var(--accent-color, #58a6ff); + border-radius: 50%; + cursor: pointer; + border: 2px solid var(--bg-primary, #0d1117); + transition: transform 0.1s; +} + +.cdap-slider-input::-webkit-slider-thumb:hover { + transform: scale(1.15); +} + +.cdap-slider-input::-moz-range-thumb { + width: 18px; + height: 18px; + background: var(--accent-color, #58a6ff); + border-radius: 50%; + cursor: pointer; + border: 2px solid var(--bg-primary, #0d1117); +} + +.cdap-slider-input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.cdap-slider-labels { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: var(--text-tertiary, #6e7681); +} + +.cdap-slider-value { + font-weight: 600; + color: var(--text-primary, #e6edf3); +} + +/* ── Select Widget ────────────────────────────────────────────────── */ + +.cdap-select-input { + width: 100%; + padding: 6px 10px; + background: var(--bg-tertiary, #21262d); + border: 1px solid var(--border-color, #30363d); + border-radius: var(--radius-sm, 6px); + color: var(--text-primary, #e6edf3); + font-size: 0.85rem; +} + +.cdap-select-input:focus { + border-color: var(--accent-color, #58a6ff); + outline: none; +} + +/* ── Chart Widget (simple bars) ───────────────────────────────────── */ + +.cdap-chart-bars { + display: flex; + flex-direction: column; + gap: 8px; +} + +.cdap-chart-bar-wrap { + display: grid; + grid-template-columns: 80px 1fr 50px; + align-items: center; + gap: 8px; +} + +.cdap-chart-bar-label { + font-size: 0.75rem; + color: var(--text-secondary, #8b949e); + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cdap-chart-bar-track { + height: 8px; + background: var(--bg-tertiary, #21262d); + border-radius: 4px; + overflow: hidden; +} + +.cdap-chart-bar-fill { + height: 100%; + background: var(--accent-color, #58a6ff); + border-radius: 4px; + transition: width 0.5s ease; +} + +.cdap-chart-bar-value { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-primary, #e6edf3); + text-align: right; +} + +/* ── Loading & Empty States ───────────────────────────────────────── */ + +.cdap-loading { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + color: var(--text-secondary, #8b949e); +} + +.cdap-loading-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border-color, #30363d); + border-top-color: var(--accent-color, #58a6ff); + border-radius: 50%; + animation: cdap-spin 0.8s linear infinite; + margin-bottom: var(--space-md, 1rem); +} + +@keyframes cdap-spin { + to { transform: rotate(360deg); } +} + +.cdap-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 20px; + text-align: center; +} + +.cdap-empty-icon { + font-size: 64px; + color: var(--text-tertiary, #6e7681); + margin-bottom: var(--space-md, 1rem); +} + +.cdap-empty h3 { + color: var(--text-primary, #e6edf3); + margin: 0 0 8px; +} + +.cdap-empty p { + color: var(--text-secondary, #8b949e); + margin: 0; + max-width: 400px; +} + +.cdap-error { + color: #f85149; + text-align: center; +} + +/* ── Command Log ──────────────────────────────────────────────────── */ + +.cdap-command-log { + background: var(--bg-secondary, #161b22); + border: 1px solid var(--border-color, #30363d); + border-radius: var(--radius-md, 8px); + overflow: hidden; +} + +.cdap-command-log-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + border-bottom: 1px solid var(--border-color, #30363d); +} + +.cdap-command-log-header h3 { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + font-weight: 600; + color: var(--text-secondary, #8b949e); + margin: 0; +} + +.cdap-command-log-header h3 .material-icons { + font-size: 18px; +} + +.cdap-command-log-entries { + max-height: 240px; + overflow-y: auto; + padding: var(--space-xs, 0.25rem) 0; +} + +.cdap-log-entry { + display: flex; + align-items: center; + gap: 8px; + padding: 6px var(--space-md, 1rem); + font-size: 0.8rem; + border-bottom: 1px solid var(--border-subtle, rgba(48, 54, 61, 0.5)); +} + +.cdap-log-entry:last-child { + border-bottom: none; +} + +.cdap-log-icon { + font-size: 16px; +} + +.cdap-log-success .cdap-log-icon { + color: #3fb950; +} + +.cdap-log-error .cdap-log-icon { + color: #f85149; +} + +.cdap-log-time { + color: var(--text-tertiary, #6e7681); + min-width: 70px; + font-family: monospace; +} + +.cdap-log-detail { + flex: 1; + color: var(--text-primary, #e6edf3); +} + +.cdap-log-detail strong { + color: var(--accent-color, #58a6ff); +} + +.cdap-log-error-msg { + color: #f85149; + font-size: 0.75rem; +} + +.cdap-log-empty { + padding: var(--space-md, 1rem); + text-align: center; + color: var(--text-tertiary, #6e7681); + font-size: 0.8rem; +} + +/* ── Unsupported Widget ───────────────────────────────────────────── */ + +.cdap-widget-unsupported { + padding: var(--space-sm, 0.5rem); + text-align: center; + font-size: 0.8rem; + color: var(--text-tertiary, #6e7681); + font-style: italic; +} + +/* ── Responsive ───────────────────────────────────────────────────── */ + +@media (max-width: 640px) { + .cdap-widget-grid { + grid-template-columns: 1fr; + } + + .cdap-device-header { + flex-direction: column; + align-items: flex-start; + } + + .cdap-device-meta { + flex-wrap: wrap; + } + + .cdap-chart-bar-wrap { + grid-template-columns: 60px 1fr 40px; + } +} diff --git a/web-nodejs/public/css/desktop-mode.css b/web-nodejs/public/css/desktop-mode.css new file mode 100644 index 00000000..9c3b0e6c --- /dev/null +++ b/web-nodejs/public/css/desktop-mode.css @@ -0,0 +1,597 @@ +/** + * BetterDesk Console - Desktop Mode Styles + * Windows-like desktop environment with floating windows, taskbar, and animations. + * Only active on viewports >= 1200px. + */ + +/* ============ Desktop Toggle Button (navbar) ============ */ + +.desktop-toggle-btn { + display: none !important; +} + +@media (min-width: 1200px) { + .desktop-toggle-btn { + display: flex !important; + } +} + +/* ============ Desktop Shell ============ */ + +.desktop-shell { + display: none; + position: fixed; + inset: 0; + z-index: 9000; + flex-direction: column; + overflow: hidden; +} + +body.desktop-active .app-layout { + display: none !important; +} + +body.desktop-active .desktop-shell { + display: flex; +} + +/* ============ Wallpaper ============ */ + +.desktop-wallpaper { + position: absolute; + inset: 0; + background-image: url('/img/betterdesk_wallpaper.png'); + background-size: cover; + background-position: center; + background-repeat: no-repeat; + z-index: 0; +} + +.desktop-wallpaper::after { + content: ''; + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.15); + pointer-events: none; +} + +/* ============ Desktop Icons Grid ============ */ + +.desktop-icons { + position: absolute; + top: 16px; + left: 16px; + right: 16px; + bottom: 56px; + z-index: 1; + display: grid; + grid-template-columns: repeat(auto-fill, 96px); + grid-template-rows: repeat(auto-fill, 96px); + gap: 8px; + align-content: start; + padding: 8px; + pointer-events: none; +} + +.desktop-icon { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + width: 88px; + height: 88px; + border-radius: 8px; + cursor: pointer; + pointer-events: all; + user-select: none; + transition: background 0.15s ease, transform 0.15s ease; + text-decoration: none; + opacity: 0; + animation: iconAppear 0.4s ease forwards; +} + +.desktop-icon:hover { + background: rgba(255, 255, 255, 0.12); + transform: scale(1.05); +} + +.desktop-icon:active { + transform: scale(0.95); + background: rgba(255, 255, 255, 0.18); +} + +.desktop-icon-img { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + font-size: 0; +} + +.desktop-icon-img .material-icons { + font-size: 28px; + color: #fff; +} + +.desktop-icon-label { + font-size: 11px; + font-weight: 500; + color: #fff; + text-align: center; + line-height: 1.2; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7); +} + +/* Stagger animation for desktop icons */ +@keyframes iconAppear { + from { + opacity: 0; + transform: translateY(12px) scale(0.8); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* ============ Floating Windows ============ */ + +.desktop-windows { + position: absolute; + inset: 0; + bottom: 48px; + z-index: 2; + pointer-events: none; +} + +.desktop-window { + position: absolute; + display: flex; + flex-direction: column; + min-width: 420px; + min-height: 300px; + background: var(--bg-primary, #0d1117); + border: 1px solid var(--border-color, #30363d); + border-radius: 10px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.25); + pointer-events: all; + overflow: hidden; + animation: windowOpen 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +.desktop-window.closing { + animation: windowClose 0.2s ease-in forwards; +} + +.desktop-window.minimizing { + animation: windowMinimize 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +.desktop-window.maximized { + border-radius: 0; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.desktop-window.focused { + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.55), 0 4px 12px rgba(0, 0, 0, 0.3), + 0 0 0 1px var(--accent-color, #58a6ff); +} + +/* ============ Window Title Bar ============ */ + +.window-titlebar { + display: flex; + align-items: center; + height: 38px; + min-height: 38px; + padding: 0 8px 0 12px; + background: var(--bg-secondary, #161b22); + border-bottom: 1px solid var(--border-color, #30363d); + cursor: default; + user-select: none; + gap: 8px; +} + +.window-titlebar-icon { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + flex-shrink: 0; +} + +.window-titlebar-icon .material-icons { + font-size: 16px; + color: #fff; +} + +.window-titlebar-text { + flex: 1; + font-size: 12px; + font-weight: 500; + color: var(--text-primary, #e6edf3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.window-titlebar-controls { + display: flex; + align-items: center; + gap: 2px; + margin-left: auto; +} + +.window-ctrl-btn { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--text-secondary, #8b949e); + cursor: pointer; + border-radius: 6px; + transition: background 0.12s ease, color 0.12s ease; +} + +.window-ctrl-btn .material-icons { + font-size: 16px; +} + +.window-ctrl-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary, #e6edf3); +} + +.window-ctrl-btn.close-btn:hover { + background: #da3633; + color: #fff; +} + +/* ============ Window Content ============ */ + +.window-content { + flex: 1; + position: relative; + overflow: hidden; + background: var(--bg-primary, #0d1117); +} + +.window-content iframe { + width: 100%; + height: 100%; + border: none; + background: var(--bg-primary, #0d1117); +} + +.window-loading { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + background: var(--bg-primary, #0d1117); + z-index: 1; + transition: opacity 0.3s ease; +} + +.window-loading.hidden { + opacity: 0; + pointer-events: none; +} + +.window-loading-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border-color, #30363d); + border-top-color: var(--accent-color, #58a6ff); + border-radius: 50%; + animation: windowSpin 0.8s linear infinite; +} + +.window-loading-text { + font-size: 12px; + color: var(--text-secondary, #8b949e); +} + +@keyframes windowSpin { + to { transform: rotate(360deg); } +} + +/* ============ Window Resize Edges & Corners ============ */ + +.window-edge { + position: absolute; + z-index: 5; +} + +/* Edges */ +.edge-n { top: -3px; left: 8px; right: 8px; height: 6px; cursor: ns-resize; } +.edge-s { bottom: -3px; left: 8px; right: 8px; height: 6px; cursor: ns-resize; } +.edge-e { top: 8px; right: -3px; bottom: 8px; width: 6px; cursor: ew-resize; } +.edge-w { top: 8px; left: -3px; bottom: 8px; width: 6px; cursor: ew-resize; } + +/* Corners */ +.edge-ne { top: -3px; right: -3px; width: 14px; height: 14px; cursor: nesw-resize; } +.edge-nw { top: -3px; left: -3px; width: 14px; height: 14px; cursor: nwse-resize; } +.edge-se { bottom: -3px; right: -3px; width: 14px; height: 14px; cursor: nwse-resize; } +.edge-sw { bottom: -3px; left: -3px; width: 14px; height: 14px; cursor: nesw-resize; } + +/* Visual indicator for SE corner */ +.edge-se::after { + content: ''; + position: absolute; + bottom: 5px; + right: 5px; + width: 8px; + height: 8px; + border-right: 2px solid var(--text-secondary, #484f58); + border-bottom: 2px solid var(--text-secondary, #484f58); + opacity: 0.3; + transition: opacity 0.15s ease; +} +.edge-se:hover::after { opacity: 0.7; } + +.desktop-window.maximized .window-edge { display: none; } + +/* ============ Taskbar ============ */ + +.desktop-taskbar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 48px; + z-index: 100; + display: flex; + align-items: center; + background: rgba(13, 17, 23, 0.85); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border-top: 1px solid rgba(48, 54, 61, 0.6); + padding: 0 8px; + gap: 4px; + animation: taskbarSlideUp 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +.taskbar-start { + display: flex; + align-items: center; + padding-right: 4px; +} + +.taskbar-start-btn { + width: 40px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--text-primary, #e6edf3); + cursor: pointer; + border-radius: 6px; + transition: background 0.15s ease; +} + +.taskbar-start-btn:hover { + background: rgba(255, 255, 255, 0.08); +} + +.taskbar-start-btn .material-icons { + font-size: 22px; +} + +.taskbar-apps { + flex: 1; + display: flex; + align-items: center; + gap: 2px; + overflow-x: auto; + scrollbar-width: none; +} + +.taskbar-apps::-webkit-scrollbar { + display: none; +} + +.taskbar-app-btn { + display: flex; + align-items: center; + gap: 6px; + height: 36px; + padding: 0 12px; + border: none; + background: transparent; + color: var(--text-secondary, #8b949e); + cursor: pointer; + border-radius: 6px; + transition: background 0.15s ease, color 0.15s ease; + white-space: nowrap; + flex-shrink: 0; + font-size: 12px; + font-family: inherit; + position: relative; +} + +.taskbar-app-btn::after { + content: ''; + position: absolute; + bottom: 2px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 2px; + border-radius: 1px; + background: var(--accent-color, #58a6ff); + transition: width 0.2s ease; +} + +.taskbar-app-btn.active::after { + width: 20px; +} + +.taskbar-app-btn.focused { + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary, #e6edf3); +} + +.taskbar-app-btn.focused::after { + width: 20px; +} + +.taskbar-app-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary, #e6edf3); +} + +.taskbar-app-btn .material-icons { + font-size: 18px; +} + +.taskbar-right { + display: flex; + align-items: center; + gap: 4px; + padding-left: 8px; + margin-left: auto; +} + +.taskbar-btn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--text-secondary, #8b949e); + cursor: pointer; + border-radius: 6px; + transition: background 0.15s ease, color 0.15s ease; +} + +.taskbar-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary, #e6edf3); +} + +.taskbar-btn .material-icons { + font-size: 20px; +} + +.taskbar-clock { + font-size: 12px; + color: var(--text-secondary, #8b949e); + min-width: 50px; + text-align: center; + padding: 0 6px; + font-variant-numeric: tabular-nums; +} + +/* ============ Window Animations ============ */ + +@keyframes windowOpen { + from { + opacity: 0; + transform: scale(0.88); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes windowClose { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.88); + } +} + +@keyframes windowMinimize { + from { + opacity: 1; + transform: scale(1) translateY(0); + } + to { + opacity: 0; + transform: scale(0.5) translateY(40vh); + } +} + +@keyframes taskbarSlideUp { + from { + transform: translateY(100%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* ============ Desktop Activation Transition ============ */ + +body.desktop-entering .desktop-shell { + animation: desktopFadeIn 0.35s ease forwards; +} + +body.desktop-leaving .app-layout { + animation: desktopFadeIn 0.35s ease forwards; +} + +@keyframes desktopFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* ============ Embed Mode ============ */ + +body.embed-mode { + background: var(--bg-primary, #0d1117); + overflow: auto; +} + +body.embed-mode .main-wrapper { + margin-left: 0; + padding-top: 0; +} + +body.embed-mode .main-content { + padding: 16px; + max-width: 100%; +} + +/* ============ Responsive ============ */ + +@media (max-width: 1199px) { + .desktop-shell { + display: none !important; + } + + body.desktop-active .app-layout { + display: flex !important; + } +} diff --git a/web-nodejs/public/css/devices.css b/web-nodejs/public/css/devices.css index 89af39ea..4219be5d 100644 --- a/web-nodejs/public/css/devices.css +++ b/web-nodejs/public/css/devices.css @@ -1,231 +1,707 @@ /** - * BetterDesk Console - Devices Page Styles + * BetterDesk Console - Devices Page Styles v2 + * Responsive: PC (>=1024px) > Tablet (600-1023px) > Phone (<600px) */ -/* Button reset for folders sidebar */ -.folders-sidebar button { - -webkit-appearance: none; - appearance: none; - background: transparent; - border: none; - padding: 0; - margin: 0; - font: inherit; - color: inherit; - cursor: pointer; -} +/* ==================== Page Layout ==================== */ -/* Layout with Folders Sidebar */ -.devices-layout { - display: grid; - grid-template-columns: 280px 1fr; - gap: 0; - min-height: calc(100vh - var(--navbar-height, 60px)); - /* Stretch into parent padding so folders sidebar is flush */ - margin: calc(-1 * var(--space-lg, 1.5rem)) 0 calc(-1 * var(--space-lg, 1.5rem)) calc(-1 * var(--space-lg, 1.5rem)); -} - -/* Folders Sidebar */ -.folders-sidebar { - background: var(--bg-secondary, #161b22); - border-right: 1px solid var(--border-color, #30363d); - border-radius: 0; - padding: var(--space-md, 1rem); - position: sticky; - top: var(--navbar-height, 60px); - height: calc(100vh - var(--navbar-height, 60px)); +.devices-page { display: flex; flex-direction: column; - overflow-y: auto; - overflow-x: hidden; + gap: var(--space-sm, 0.5rem); } -/* Sidebar Toggle Button - hidden (folders always visible) */ -.folders-sidebar .sidebar-toggle { - display: none; -} +/* ==================== Header ==================== */ -.folders-header { +.devices-header { display: flex; align-items: center; justify-content: space-between; - padding-bottom: var(--space-md, 1rem); - border-bottom: 1px solid var(--border-color, #30363d); - margin-bottom: var(--space-sm, 0.5rem); - gap: var(--space-sm, 0.5rem); + gap: var(--space-md); } -.sidebar-title { - flex: 1; -} - -.folders-header h3 { - margin: 0; - font-size: var(--font-size-lg, 1.125rem); - font-weight: var(--font-weight-semibold, 600); - color: var(--text-primary, #e6edf3); -} - -/* Folder add button - high specificity to override defaults */ -#add-folder-btn, -.folders-header .btn-icon, -.folders-header button.btn-icon { +.devices-title { display: flex; align-items: center; - justify-content: center; - width: 36px; - height: 36px; - padding: 0; + gap: var(--space-sm); +} + +.devices-title h1 { + font-size: var(--font-size-xl, 1.25rem); + font-weight: var(--font-weight-bold); margin: 0; - background: transparent !important; - background-color: transparent !important; - border: 1px solid transparent; - border-radius: var(--radius-md, 8px); +} + +.devices-count { + background: var(--accent-blue-muted); + color: var(--accent-blue); + padding: 2px 8px; + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); +} + +.devices-actions .btn-sm { + padding: 6px 12px; + font-size: var(--font-size-sm); + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} + +.devices-actions .btn-sm .material-icons { + font-size: 18px; +} + +/* ==================== Folder Chips ==================== */ + +.folders-bar { + display: flex; + align-items: center; + gap: var(--space-xs, 0.25rem); + min-height: 34px; +} + +.folders-chips { + display: flex; + align-items: center; + gap: var(--space-xs, 0.25rem); + overflow-x: auto; + scrollbar-width: none; + -ms-overflow-style: none; + flex: 1; + min-width: 0; + padding: 2px 0; +} + +.folders-chips::-webkit-scrollbar { + display: none; +} + +.folder-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border: 1px solid var(--border-primary, #30363d); + border-radius: var(--radius-full, 9999px); + background: var(--bg-secondary, #161b22); color: var(--text-secondary, #8b949e); + font-size: var(--font-size-xs, 0.75rem); cursor: pointer; + white-space: nowrap; transition: all 0.15s ease; + flex-shrink: 0; -webkit-appearance: none; appearance: none; + position: relative; + font-family: inherit; } -#add-folder-btn:hover, -.folders-header .btn-icon:hover { - background: var(--bg-hover, rgba(255,255,255,0.05)) !important; +.folder-chip:hover { + background: var(--bg-hover); + color: var(--text-primary); + border-color: var(--border-hover, #484f58); +} + +.folder-chip.active { + background: var(--accent-blue-muted, rgba(88, 166, 255, 0.15)); + border-color: var(--accent-blue, #58a6ff); color: var(--accent-blue, #58a6ff); - border-color: var(--border-color, #30363d); } -.folders-header .btn-icon .material-icons, -#add-folder-btn .material-icons { - font-size: 20px; +.folder-chip.drag-over { + background: var(--accent-green-muted); + border-color: var(--accent-green); + color: var(--accent-green); } -.folders-list { - flex: 1; - overflow-y: auto; - padding-right: var(--space-xs, 0.25rem); +.chip-icon { + font-size: 14px !important; } -.folder-item { +.chip-count { + font-size: 10px; + background: var(--bg-tertiary, #21262d); + padding: 0 5px; + border-radius: var(--radius-full); + line-height: 16px; +} + +.folder-chip.active .chip-count { + background: rgba(88, 166, 255, 0.25); +} + +/* Chip actions (edit/delete) — shown on hover for custom folders */ +.chip-actions { + display: none; + align-items: center; + gap: 2px; + margin-left: 2px; +} + +.folder-chip:hover .chip-actions { + display: inline-flex; +} + +.folder-chip:hover .chip-count { + display: none; +} + +.chip-action { + font-size: 14px !important; + cursor: pointer; + padding: 1px; + border-radius: var(--radius-sm); + transition: color 0.1s; + color: var(--text-tertiary); +} + +.chip-action:hover { + color: var(--text-primary); +} + +.chip-action.folder-delete:hover { + color: var(--accent-red); +} + +/* Add folder chip */ +.chip-add { + padding: 4px 8px; + border-style: dashed; +} + +.chip-add:hover { + border-color: var(--accent-blue); + color: var(--accent-blue); +} + +.chip-add .material-icons { + font-size: 16px; +} + +/* ==================== Toolbar ==================== */ + +.devices-toolbar { display: flex; align-items: center; gap: var(--space-sm, 0.5rem); - padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); - border-radius: var(--radius-md, 8px); - cursor: pointer; - transition: background 0.15s ease; - margin-bottom: var(--space-xs, 0.25rem); - position: relative; - color: var(--text-primary, #e6edf3); + flex-wrap: wrap; } -.folder-item:hover { - background: var(--bg-hover, rgba(255,255,255,0.05)); -} - -.folder-item.active { - background: var(--accent-blue-muted, rgba(88, 166, 255, 0.15)); -} - -.folder-item.active .folder-name { - color: var(--accent-blue, #58a6ff); - font-weight: var(--font-weight-medium, 500); -} - -.folder-item.drag-over { - background: var(--accent-green-muted, rgba(46, 164, 79, 0.15)); - outline: 2px dashed var(--accent-green, #2ea44f); - outline-offset: -2px; -} - -.folder-icon { - font-size: 20px; - color: var(--text-secondary, #8b949e); -} - -.folder-name { +.toolbar-search { flex: 1; - font-size: var(--font-size-sm, 0.875rem); - overflow: hidden; - text-overflow: ellipsis; + min-width: 180px; +} + +.toolbar-search .form-input { + padding: 6px 12px 6px 32px; + font-size: var(--font-size-sm); + height: 32px; +} + +.toolbar-search .material-icons { + font-size: 18px; + left: 8px; +} + +.toolbar-filters { + display: flex; + gap: 2px; + background: var(--bg-tertiary, #21262d); + border-radius: var(--radius-md, 8px); + padding: 2px; +} + +.filter-btn { + padding: 4px 10px; + font-size: var(--font-size-xs, 0.75rem); + background: transparent; + border: none; + border-radius: var(--radius-sm, 6px); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s ease; white-space: nowrap; } -.folder-count { - font-size: var(--font-size-xs, 0.75rem); - color: var(--text-tertiary, #6e7681); - background: var(--bg-tertiary, #21262d); - padding: 2px 8px; - border-radius: var(--radius-full, 9999px); - min-width: 24px; - text-align: center; +.filter-btn:hover { + color: var(--text-primary); + background: var(--bg-hover); } -.folder-actions { - display: none; - gap: var(--space-xs, 0.25rem); +.filter-btn.active { + background: var(--accent-blue-muted); + color: var(--accent-blue); } -.folder-actions button { - background: transparent; - border: none; - color: var(--text-secondary, #8b949e); - cursor: pointer; - padding: 4px; - border-radius: var(--radius-sm, 4px); +.toolbar-icon { + width: 32px; + height: 32px; + padding: 0; display: flex; align-items: center; justify-content: center; + border-radius: var(--radius-md); + border: 1px solid var(--border-primary); + background: var(--bg-secondary); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s ease; } -.folder-actions button:hover { - color: var(--text-primary, #e6edf3); - background: var(--bg-hover, rgba(255,255,255,0.05)); +.toolbar-icon:hover { + color: var(--text-primary); + background: var(--bg-hover); } -.folder-actions button.delete-btn:hover { - color: var(--accent-red, #f85149); +/* Column visibility dropdown */ +.column-visibility-dropdown { + position: relative; + display: inline-flex; } -.folder-actions .material-icons { - font-size: 16px; -} - -.folder-item:hover .folder-actions { - display: flex; -} - -.folder-item:hover .folder-count { +.column-visibility-menu { display: none; + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + background: var(--bg-elevated, #2d333b); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + padding: var(--space-xs); + z-index: 100; + min-width: 160px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } -.folders-divider { - height: 1px; - background: var(--border-color, #30363d); - margin: var(--space-sm, 0.5rem) 0; +.column-visibility-menu.show { + display: flex; + flex-direction: column; } -.folders-hint { +.column-toggle { display: flex; align-items: center; - gap: var(--space-xs, 0.25rem); - padding: var(--space-md, 1rem) 0 0; - border-top: 1px solid var(--border-color, #30363d); - margin-top: auto; + gap: var(--space-xs); + padding: 6px 8px; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: var(--font-size-sm); + color: var(--text-secondary); + transition: background 0.15s; + user-select: none; + white-space: nowrap; +} + +.column-toggle:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.column-toggle input[type="checkbox"] { + width: 15px; + height: 15px; + accent-color: var(--accent-blue); + cursor: pointer; + flex-shrink: 0; +} + +.column-toggle span { + flex: 1; +} + +/* ==================== Table ==================== */ + +.devices-table-container { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg, 12px); + overflow: hidden; +} + +.devices-table { + width: 100%; + border-collapse: collapse; +} + +.devices-table th { + padding: 8px 12px; font-size: var(--font-size-xs, 0.75rem); - color: var(--text-tertiary, #6e7681); + font-weight: var(--font-weight-semibold); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + background: var(--bg-tertiary); + text-align: left; + white-space: nowrap; + border-bottom: 1px solid var(--border-primary); + user-select: none; } -.folders-hint .material-icons { +.devices-table th.sortable { + cursor: pointer; +} + +.devices-table th.sortable:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.devices-table td { + padding: 6px 12px; + font-size: var(--font-size-sm, 0.875rem); + color: var(--text-primary); + border-bottom: 1px solid var(--border-primary); + vertical-align: middle; +} + +.devices-table tbody tr { + transition: background 0.1s ease; + cursor: default; +} + +.devices-table tbody tr:hover { + background: var(--bg-hover, rgba(255, 255, 255, 0.03)); +} + +.devices-table tbody tr:last-child td { + border-bottom: none; +} + +/* Hidden columns */ +th.column-hidden, +td.column-hidden { + display: none !important; +} + +/* ==================== Table Cell: ID ==================== */ + +.device-id { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.device-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.device-status-dot.online { + background: var(--accent-green, #2ea44f); + box-shadow: 0 0 4px var(--accent-green); +} + +.device-status-dot.offline { + background: var(--text-tertiary, #6e7681); +} + +.device-status-dot.banned { + background: var(--accent-red, #f85149); +} + +.device-id-text { + font-family: var(--font-mono); + font-weight: var(--font-weight-medium); + font-size: var(--font-size-sm); +} + +.copy-btn { + padding: 2px; + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + border-radius: var(--radius-sm); + display: inline-flex; + align-items: center; + opacity: 0; + transition: all 0.1s; +} + +.copy-btn .material-icons { + font-size: 14px; +} + +.device-id:hover .copy-btn { + opacity: 1; +} + +.copy-btn:hover { + color: var(--accent-blue); + background: var(--accent-blue-muted); +} + +/* ==================== Table Cell: Platform / Type ==================== */ + +.platform-icon { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--text-secondary); + font-size: var(--font-size-sm); +} + +.platform-icon .material-icons { font-size: 16px; - color: var(--accent-blue, #58a6ff); } -/* Folder Form - Compact Modal Version */ +/* ==================== Table Cell: Last Seen ==================== */ + +.last-seen-text { + color: var(--text-secondary); + font-size: var(--font-size-sm); +} + +/* ==================== Table Cell: Status Badge ==================== */ + +.status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); +} + +.status-badge.online { + background: var(--accent-green-muted); + color: var(--accent-green); +} + +.status-badge.offline { + background: var(--bg-tertiary); + color: var(--text-tertiary); +} + +.status-badge.banned { + background: var(--accent-red-muted); + color: var(--accent-red); +} + +.status-badge .status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +/* ==================== Table Cell: Kebab Menu ==================== */ + +.col-actions { + width: 40px; + text-align: center; +} + +.kebab-wrapper { + position: relative; + display: inline-flex; +} + +.kebab-btn { + padding: 4px; + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.1s; +} + +.kebab-btn:hover { + color: var(--text-primary); + background: var(--bg-tertiary); +} + +.kebab-btn .material-icons { + font-size: 20px; +} + +.kebab-menu { + display: none; + position: absolute; + top: 100%; + right: 0; + z-index: 200; + min-width: 180px; + background: var(--bg-elevated, #2d333b); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md, 8px); + padding: 4px 0; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.kebab-menu.open { + display: block; +} + +.kebab-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: var(--font-size-sm); + color: var(--text-secondary); + cursor: pointer; + transition: background 0.1s; + border: none; + background: none; + width: 100%; + text-align: left; + font-family: inherit; +} + +.kebab-menu-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.kebab-menu-item .material-icons { + font-size: 18px; + flex-shrink: 0; +} + +.kebab-menu-item.connect { color: var(--accent-green); } +.kebab-menu-item.connect:hover { background: rgba(34, 197, 94, 0.1); } + +.kebab-menu-item.connect-desktop { color: var(--accent-purple, #a855f7); } +.kebab-menu-item.connect-desktop:hover { background: rgba(168, 85, 247, 0.1); } + +.kebab-menu-item.info { color: var(--accent-blue); } +.kebab-menu-item.info:hover { background: rgba(59, 130, 246, 0.1); } + +.kebab-menu-item.ban { color: var(--accent-orange); } +.kebab-menu-item.ban:hover { background: rgba(249, 115, 22, 0.1); } + +.kebab-menu-item.unban { color: var(--accent-green); } +.kebab-menu-item.unban:hover { background: rgba(34, 197, 94, 0.1); } + +.kebab-menu-item.danger { color: var(--accent-red); } +.kebab-menu-item.danger:hover { background: rgba(239, 68, 68, 0.1); } + +.kebab-divider { + height: 1px; + background: var(--border-primary); + margin: 4px 0; +} + +/* ==================== Banned Rows ==================== */ + +.banned-row { + background: rgba(239, 68, 68, 0.05) !important; +} + +.banned-row:hover { + background: rgba(239, 68, 68, 0.08) !important; +} + +.banned-row .device-id-text { + text-decoration: line-through; + opacity: 0.7; +} + +/* ==================== Draggable Rows ==================== */ + +.devices-table tbody tr[draggable="true"] { + cursor: grab; +} + +.devices-table tbody tr.dragging { + opacity: 0.4; +} + +/* ==================== Pagination ==================== */ + +.devices-pagination { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-top: 1px solid var(--border-primary); + background: var(--bg-tertiary); + font-size: var(--font-size-xs); +} + +.pagination-info { + color: var(--text-secondary); +} + +.pagination-controls { + display: flex; + align-items: center; + gap: 2px; +} + +.pagination-btn { + padding: 4px 8px; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + color: var(--text-primary); + cursor: pointer; + font-size: var(--font-size-xs); + transition: all 0.1s; + min-width: 28px; + text-align: center; +} + +.pagination-btn:hover:not(:disabled) { + background: var(--bg-elevated); +} + +.pagination-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.pagination-btn.active { + background: var(--accent-blue); + border-color: var(--accent-blue); + color: white; +} + +.pagination-btn .material-icons { + font-size: 16px; + vertical-align: middle; +} + +/* ==================== Empty State ==================== */ + +.devices-empty { + text-align: center; + padding: var(--space-3xl); +} + +.devices-empty-icon { + font-size: 56px; + color: var(--text-tertiary); + margin-bottom: var(--space-md); +} + +.devices-empty-title { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + margin-bottom: var(--space-xs); +} + +.devices-empty-text { + color: var(--text-secondary); +} + +/* ==================== Folder Form Modal ==================== */ + .folder-form { display: flex; flex-direction: column; - gap: var(--space-sm, 0.5rem); + gap: var(--space-sm); } .folder-form .form-group { @@ -233,19 +709,15 @@ } .folder-form .form-group label { - font-size: var(--font-size-sm, 0.875rem); - margin-bottom: var(--space-xs, 0.25rem); + font-size: var(--font-size-sm); + margin-bottom: var(--space-xs); display: block; - color: var(--text-secondary, #8b949e); -} - -.folder-form .form-input { - padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + color: var(--text-secondary); } .color-picker { display: flex; - gap: var(--space-xs, 0.25rem); + gap: var(--space-xs); flex-wrap: wrap; } @@ -255,8 +727,11 @@ border-radius: 50%; border: 2px solid transparent; cursor: pointer; - transition: transform 0.15s ease, border-color 0.15s ease; + transition: transform 0.15s, border-color 0.15s; flex-shrink: 0; + -webkit-appearance: none; + appearance: none; + padding: 0; } .color-option:hover { @@ -268,294 +743,8 @@ transform: scale(1.15); } -/* Drag Handle */ -.drag-handle-cell { - width: 30px; - padding: 0 var(--space-xs) !important; -} +/* ==================== Device Details Modal ==================== */ -.drag-handle { - cursor: grab; - color: var(--text-tertiary); - font-size: 18px; - opacity: 0.5; - transition: opacity 0.15s ease; -} - -tr:hover .drag-handle { - opacity: 1; -} - -tr.dragging { - opacity: 0.5; - background: var(--accent-blue-muted); -} - -/* Bulk Move Select */ -.bulk-move-select { - min-width: 150px; -} - -/* Devices Main - restore padding since layout ate parent padding */ -.devices-main { - padding: var(--space-lg, 1.5rem); - padding-left: var(--space-lg, 1.5rem); - min-width: 0; -} - -/* Responsive */ -@media (max-width: 1024px) { - .devices-layout { - grid-template-columns: 1fr; - margin: 0; - } - - .folders-sidebar { - position: static; - height: auto; - border-right: none; - border-bottom: 1px solid var(--border-color, #30363d); - } - - .devices-main { - padding: var(--space-md, 1rem); - } -} - -.devices-header { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: var(--space-md); - margin-bottom: var(--space-lg); -} - -.devices-title { - display: flex; - align-items: center; - gap: var(--space-sm); -} - -.devices-title h1 { - font-size: var(--font-size-2xl); - font-weight: var(--font-weight-bold); -} - -.devices-count { - background: var(--accent-blue-muted); - color: var(--accent-blue); - padding: var(--space-xs) var(--space-sm); - border-radius: var(--radius-full); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); -} - -.devices-actions { - display: flex; - align-items: center; - gap: var(--space-sm); -} - -/* Filters */ -.devices-filters { - display: flex; - align-items: center; - gap: var(--space-md); - flex-wrap: wrap; - margin-bottom: var(--space-md); -} - -.devices-search { - flex: 1; - min-width: 250px; -} - -.devices-filter-group { - display: flex; - align-items: center; - gap: var(--space-xs); -} - -.filter-btn { - padding: var(--space-xs) var(--space-sm); - font-size: var(--font-size-sm); - background: var(--bg-tertiary); - border: 1px solid var(--border-primary); - border-radius: var(--radius-md); - color: var(--text-secondary); - cursor: pointer; - transition: all var(--transition-fast); -} - -.filter-btn:hover { - background: var(--bg-elevated); - color: var(--text-primary); -} - -.filter-btn.active { - background: var(--accent-blue-muted); - border-color: var(--accent-blue); - color: var(--accent-blue); -} - -/* Device table */ -.devices-table-container { - background: var(--bg-secondary); - border: 1px solid var(--border-primary); - border-radius: var(--radius-lg); - overflow: hidden; -} - -.devices-table { - width: 100%; -} - -.devices-table th, -.devices-table td { - padding: var(--space-md); -} - -.devices-table th { - background: var(--bg-tertiary); - font-weight: var(--font-weight-semibold); -} - -.devices-table th:first-child { - width: 40px; -} - -/* Checkbox */ -.checkbox-cell { - text-align: center; -} - -.checkbox { - width: 18px; - height: 18px; - accent-color: var(--accent-blue); - cursor: pointer; -} - -/* Device ID cell */ -.device-id { - display: flex; - align-items: center; - gap: var(--space-sm); -} - -.device-id-text { - font-family: var(--font-mono); - font-weight: var(--font-weight-medium); -} - -.device-id-copy { - color: var(--text-tertiary); - cursor: pointer; - padding: var(--space-xs); - border-radius: var(--radius-sm); - transition: all var(--transition-fast); -} - -.device-id-copy:hover { - color: var(--accent-blue); - background: var(--accent-blue-muted); -} - -/* Platform icon */ -.platform-icon { - display: flex; - align-items: center; - gap: var(--space-xs); - color: var(--text-secondary); -} - -.platform-icon .material-icons { - font-size: 18px; -} - -/* Last seen */ -.last-seen { - font-size: var(--font-size-sm); -} - -.last-seen-time { - color: var(--text-secondary); -} - -.last-seen-ago { - color: var(--text-tertiary); - font-size: var(--font-size-xs); -} - -/* Actions */ -.device-actions { - display: flex; - align-items: center; - gap: var(--space-xs); -} - -.action-btn { - padding: var(--space-xs); - background: none; - border: none; - color: var(--text-secondary); - cursor: pointer; - border-radius: var(--radius-sm); - transition: all var(--transition-fast); - display: flex; - align-items: center; - justify-content: center; -} - -.action-btn:hover { - color: var(--text-primary); - background: var(--bg-tertiary); -} - -.action-btn.danger:hover { - color: var(--accent-red); - background: var(--accent-red-muted); -} - -/* Copy button */ -.btn-icon-sm, -.copy-btn { - padding: var(--space-2xs); - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - border-radius: var(--radius-sm); - transition: all var(--transition-fast); - display: inline-flex; - align-items: center; - justify-content: center; - opacity: 0; -} - -.btn-icon-sm .material-icons, -.copy-btn .material-icons { - font-size: 16px; -} - -.device-id:hover .copy-btn, -.device-id:hover .btn-icon-sm { - opacity: 1; -} - -.btn-icon-sm:hover, -.copy-btn:hover { - color: var(--accent-blue); - background: var(--bg-tertiary); -} - -.btn-icon-sm:active, -.copy-btn:active { - transform: scale(0.9); -} - -/* Device details modal */ .device-details { display: flex; flex-direction: column; @@ -585,111 +774,7 @@ tr.dragging { word-break: break-word; } -/* Bulk actions bar */ -.bulk-actions { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-md); - background: var(--accent-blue-muted); - border-radius: var(--radius-md); - margin-bottom: var(--space-md); -} - -.bulk-actions-info { - display: flex; - align-items: center; - gap: var(--space-md); - color: var(--accent-blue); - font-weight: var(--font-weight-medium); -} - -.bulk-actions-buttons { - display: flex; - gap: var(--space-sm); -} - -.bulk-actions.hidden { - display: none; -} - -/* Empty state */ -.devices-empty { - text-align: center; - padding: var(--space-3xl); -} - -.devices-empty-icon { - font-size: 64px; - color: var(--text-tertiary); - margin-bottom: var(--space-md); -} - -.devices-empty-title { - font-size: var(--font-size-lg); - font-weight: var(--font-weight-semibold); - margin-bottom: var(--space-sm); -} - -.devices-empty-text { - color: var(--text-secondary); - margin-bottom: var(--space-lg); -} - -/* Pagination */ -.devices-pagination { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-md); - border-top: 1px solid var(--border-primary); - background: var(--bg-tertiary); -} - -.pagination-info { - color: var(--text-secondary); - font-size: var(--font-size-sm); -} - -.pagination-controls { - display: flex; - align-items: center; - gap: var(--space-xs); -} - -.pagination-btn { - padding: var(--space-xs) var(--space-sm); - background: var(--bg-secondary); - border: 1px solid var(--border-primary); - border-radius: var(--radius-sm); - color: var(--text-primary); - cursor: pointer; - font-size: var(--font-size-sm); - transition: all var(--transition-fast); -} - -.pagination-btn:hover:not(:disabled) { - background: var(--bg-elevated); -} - -.pagination-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.pagination-btn.active { - background: var(--accent-blue); - border-color: var(--accent-blue); - color: white; -} - -/* Device edit modal */ -.device-edit-form .form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--space-md); -} - +/* Device edit info grid */ .device-info-grid { display: grid; gap: var(--space-md); @@ -713,7 +798,7 @@ tr.dragging { font-size: var(--font-size-sm); } -/* ID Change modal */ +/* ID change warning */ .id-change-warning { display: flex; align-items: flex-start; @@ -734,109 +819,252 @@ tr.dragging { font-size: var(--font-size-sm); } -/* Responsive */ -@media (max-width: 1024px) { - .devices-header { - flex-direction: column; - align-items: stretch; - } - - .devices-title { - justify-content: center; - } - - .devices-actions { - justify-content: center; - } - - .devices-filters { - flex-direction: column; - } - - .devices-search { - width: 100%; - } +/* ==================== Bulk Actions ==================== */ + +.bulk-actions { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--accent-blue-muted); + border-radius: var(--radius-md); + margin-bottom: var(--space-sm); } -@media (max-width: 768px) { - .devices-table th:nth-child(4), - .devices-table td:nth-child(4), - .devices-table th:nth-child(5), - .devices-table td:nth-child(5) { +.bulk-actions-info { + display: flex; + align-items: center; + gap: var(--space-md); + color: var(--accent-blue); + font-weight: var(--font-weight-medium); + font-size: var(--font-size-sm); +} + +.bulk-actions-buttons { + display: flex; + gap: var(--space-sm); +} + +.bulk-actions.hidden { + display: none; +} + +/* ==================== RESPONSIVE: Tablet (<=1024px) ==================== */ + +@media (max-width: 1024px) { + .devices-table th[data-column="device_type"], + .devices-table td[data-column="device_type"] { display: none; } - - .bulk-actions { +} + +/* ==================== RESPONSIVE: Small Tablet (<=768px) ==================== */ + +@media (max-width: 768px) { + .devices-header { + flex-wrap: wrap; + } + + .devices-toolbar { + flex-wrap: wrap; + } + + .toolbar-search { + min-width: 100%; + order: -1; + } + + .toolbar-filters { + flex-wrap: wrap; + } + + .devices-table th[data-column="platform"], + .devices-table td[data-column="platform"], + .devices-table th[data-column="last_online"], + .devices-table td[data-column="last_online"] { + display: none; + } + + .devices-actions .btn-label { + display: none; + } +} + +/* ==================== RESPONSIVE: Phone (<=600px) ==================== */ + +@media (max-width: 600px) { + .devices-page { + gap: var(--space-xs); + } + + .devices-title h1 { + font-size: var(--font-size-lg); + } + + /* Card-style rows on phone */ + .devices-table thead { + display: none; + } + + .devices-table tbody { + display: flex; flex-direction: column; - gap: var(--space-md); + gap: 6px; + padding: 6px; } - - .device-edit-form .form-row { - grid-template-columns: 1fr; + + .devices-table tbody tr { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + gap: 2px 8px; + padding: 10px 12px; + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + background: var(--bg-secondary); + } + + .devices-table tbody tr td { + padding: 0; + border-bottom: none; + } + + /* Row 1, Col 1: ID with status dot */ + .devices-table td[data-column="id"] { + grid-column: 1; + grid-row: 1; + font-weight: var(--font-weight-semibold); + } + + /* Row 1-2, Col 2: Kebab menu (spans both rows) */ + .devices-table td[data-column="actions"] { + grid-column: 2; + grid-row: 1 / 3; + display: flex !important; + align-items: center; + justify-content: center; + } + + /* Row 2, Col 1: Hostname */ + .devices-table td[data-column="hostname"] { + grid-column: 1; + grid-row: 2; + color: var(--text-secondary); + font-size: var(--font-size-xs); + } + + /* Hide non-essential columns on phone */ + .devices-table td[data-column="device_type"], + .devices-table td[data-column="platform"], + .devices-table td[data-column="last_online"], + .devices-table td[data-column="status"] { + display: none; + } + + /* Larger touch target for kebab */ + .kebab-btn { + padding: 8px; + } + + .kebab-btn .material-icons { + font-size: 24px; + } + + /* Mobile bottom sheet for kebab menu */ + .kebab-menu { + position: fixed; + top: auto; + right: 16px; + bottom: 16px; + left: 16px; + min-width: auto; + border-radius: var(--radius-lg); + max-height: 60vh; + overflow-y: auto; + } + + .kebab-menu-item { + padding: 12px 16px; + font-size: var(--font-size-md, 1rem); + } + + .kebab-menu-item .material-icons { + font-size: 22px; + } + + /* Overlay backdrop for mobile bottom sheet */ + .kebab-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 199; + } + + .kebab-overlay.open { + display: block; + } + + /* Compact pagination */ + .devices-pagination { + flex-wrap: wrap; + gap: 4px; + justify-content: center; + } + + .pagination-info { + width: 100%; + text-align: center; + } + + /* Banned card styling */ + .banned-row { + border-color: var(--accent-red) !important; + border-left: 3px solid var(--accent-red) !important; + } + + /* Folder chips scroll hint */ + .folders-bar { + position: relative; + } + + .folders-bar::after { + content: ''; + position: absolute; + right: 40px; + top: 0; + bottom: 0; + width: 24px; + background: linear-gradient(to right, transparent, var(--bg-primary, #0d1117)); + pointer-events: none; } } -/* Banned row styling */ -.banned-row { - background: rgba(239, 68, 68, 0.1) !important; - border-left: 3px solid var(--accent-red); +/* ==================== RESPONSIVE: Small Phone (<=400px) ==================== */ + +@media (max-width: 400px) { + /* Show only icons for folder chips (except All and Unassigned) */ + .folder-chip .chip-label { + display: none; + } + + .folder-chip[data-folder="all"] .chip-label, + .folder-chip[data-folder="unassigned"] .chip-label { + display: inline; + } + + .filter-btn { + padding: 4px 6px; + font-size: 10px; + } + + .devices-table tbody tr { + padding: 8px 10px; + } } -.banned-row:hover { - background: rgba(239, 68, 68, 0.15) !important; -} +/* ==================== Delete Confirmation Modal ==================== */ -.banned-row td { - color: var(--text-secondary); -} - -.banned-row .device-id-text { - text-decoration: line-through; - opacity: 0.7; -} - -/* Action button variants */ -.action-btn.connect { - color: var(--accent-green); -} - -.action-btn.connect:hover { - background: rgba(34, 197, 94, 0.15); -} - -.action-btn.connect-desktop { - color: var(--accent-purple, #a855f7); -} - -.action-btn.connect-desktop:hover { - background: rgba(168, 85, 247, 0.15); -} - -.action-btn.info { - color: var(--accent-blue); -} - -.action-btn.info:hover { - background: rgba(59, 130, 246, 0.15); -} - -.action-btn.ban { - color: var(--accent-orange); -} - -.action-btn.ban:hover { - background: rgba(249, 115, 22, 0.15); -} - -.action-btn.unban { - color: var(--accent-green); -} - -.action-btn.unban:hover { - background: rgba(34, 197, 94, 0.15); -} - -/* Delete confirmation modal */ .delete-confirm-modal { position: fixed; top: 0; @@ -925,91 +1153,7 @@ tr.dragging { font-weight: var(--font-weight-bold); } -/* Column Visibility Dropdown */ -.column-visibility-dropdown { - position: relative; - display: inline-flex; -} - -.column-visibility-dropdown .btn { - display: inline-flex; - align-items: center; - gap: var(--space-xs, 0.25rem); - padding: var(--space-xs, 0.25rem) var(--space-sm, 0.5rem); - font-size: var(--font-size-sm, 0.875rem); - background: var(--bg-tertiary, #21262d); - border: 1px solid var(--border-color, #30363d); - border-radius: var(--radius-md, 8px); - color: var(--text-secondary, #8b949e); - cursor: pointer; - white-space: nowrap; - height: 34px; -} - -.column-visibility-dropdown .btn:hover { - background: var(--bg-elevated, #30363d); - color: var(--text-primary, #e6edf3); -} - -.column-visibility-dropdown .btn .material-icons { - font-size: 18px; -} - -.column-visibility-menu { - display: none; - position: absolute; - top: 100%; - left: 0; - margin-top: var(--space-xs, 0.25rem); - background: var(--bg-secondary, #161b22); - border: 1px solid var(--border-color, #30363d); - border-radius: var(--radius-md, 8px); - padding: var(--space-xs, 0.25rem) 0; - min-width: 180px; - z-index: 200; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); -} - -.column-visibility-menu.show { - display: flex; - flex-direction: column; -} - -.column-toggle { - display: flex; - align-items: center; - gap: var(--space-sm, 0.5rem); - padding: 6px var(--space-md, 1rem); - cursor: pointer; - font-size: var(--font-size-sm, 0.875rem); - color: var(--text-secondary, #8b949e); - transition: background 0.1s ease; - user-select: none; - white-space: nowrap; -} - -.column-toggle:hover { - background: var(--bg-hover, rgba(255,255,255,0.05)); - color: var(--text-primary, #e6edf3); -} - -.column-toggle input[type="checkbox"] { - width: 15px; - height: 15px; - accent-color: var(--accent-blue, #58a6ff); - cursor: pointer; - flex-shrink: 0; -} - -.column-toggle span { - flex: 1; -} - -/* Hidden column rule */ -th.column-hidden, -td.column-hidden { - display: none !important; -} +/* ==================== Animations ==================== */ @keyframes fadeIn { from { opacity: 0; } @@ -1017,11 +1161,11 @@ td.column-hidden { } @keyframes slideUp { - from { + from { opacity: 0; transform: translateY(20px) scale(0.95); } - to { + to { opacity: 1; transform: translateY(0) scale(1); } diff --git a/web-nodejs/public/img/betterdesk_wallpaper.png b/web-nodejs/public/img/betterdesk_wallpaper.png new file mode 100644 index 00000000..d3363b55 Binary files /dev/null and b/web-nodejs/public/img/betterdesk_wallpaper.png differ diff --git a/web-nodejs/public/js/app.js b/web-nodejs/public/js/app.js index cb0dc3c7..a6965e28 100644 --- a/web-nodejs/public/js/app.js +++ b/web-nodejs/public/js/app.js @@ -15,6 +15,7 @@ initUserMenu(); initRefreshButton(); initRegistrationBadge(); + initDesktopMode(); } /** @@ -176,4 +177,13 @@ setInterval(updateBadge, 30000); // every 30s } + /** + * Desktop mode initialization + */ + function initDesktopMode() { + if (window.DesktopMode && typeof window.DesktopMode.init === 'function') { + window.DesktopMode.init(); + } + } + })(); diff --git a/web-nodejs/public/js/cdap-commands.js b/web-nodejs/public/js/cdap-commands.js new file mode 100644 index 00000000..47452704 --- /dev/null +++ b/web-nodejs/public/js/cdap-commands.js @@ -0,0 +1,187 @@ +/** + * BetterDesk Console - CDAP Command Sender + * Handles sending commands to CDAP devices with confirmation dialogs, + * cooldown management, and command log tracking. + */ + +(function () { + 'use strict'; + + const COOLDOWN_MS = 1000; + const MAX_LOG_ENTRIES = 50; + + const lastCommandTime = {}; + const commandLog = []; + + // ── Command Sending ────────────────────────────────────────────────── + + async function send(deviceId, widgetId, action, value, reason) { + // Cooldown check per widget + const key = `${deviceId}:${widgetId}`; + const now = Date.now(); + if (lastCommandTime[key] && (now - lastCommandTime[key]) < COOLDOWN_MS) { + return; + } + lastCommandTime[key] = now; + + const csrfToken = window.BetterDesk?.csrfToken || ''; + + try { + const res = await fetch(`/api/cdap/devices/${encodeURIComponent(deviceId)}/command`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + widget_id: widgetId, + action: action, + value: value, + reason: reason || undefined + }) + }); + + const data = await res.json(); + const success = res.ok && data.success; + + addLogEntry({ + time: new Date(), + widgetId, + action, + value, + success, + error: success ? null : (data.error || 'Unknown error') + }); + + if (!success) { + showToast(data.error || 'Command failed', 'error'); + } + + return data; + } catch (err) { + addLogEntry({ + time: new Date(), + widgetId, + action, + value, + success: false, + error: err.message + }); + showToast('Failed to send command', 'error'); + return null; + } + } + + function sendWithConfirm(deviceId, widgetId, action, value, confirmMsg) { + const __ = window.BetterDesk?.translations || {}; + const title = __?.cdap?.confirm_command || 'Confirm Command'; + + if (!window.BetterDeskModal) { + if (confirm(confirmMsg || title)) { + return send(deviceId, widgetId, action, value); + } + return Promise.resolve(null); + } + + return new Promise((resolve) => { + window.BetterDeskModal.confirm({ + title: title, + message: confirmMsg || `${action} → ${widgetId}?`, + confirmText: __?.common?.confirm || 'Confirm', + cancelText: __?.common?.cancel || 'Cancel', + type: 'warning', + onConfirm: async () => { + const result = await send(deviceId, widgetId, action, value); + resolve(result); + }, + onCancel: () => resolve(null) + }); + }); + } + + // ── Command Log ────────────────────────────────────────────────────── + + function addLogEntry(entry) { + commandLog.unshift(entry); + if (commandLog.length > MAX_LOG_ENTRIES) commandLog.pop(); + renderLog(); + } + + function renderLog() { + const container = document.getElementById('cdap-log-entries'); + if (!container) return; + + let html = ''; + for (const entry of commandLog) { + const time = entry.time.toLocaleTimeString(); + const icon = entry.success ? 'check_circle' : 'error'; + const cls = entry.success ? 'cdap-log-success' : 'cdap-log-error'; + const valueStr = entry.value !== null && entry.value !== undefined + ? ` = ${escapeHtml(String(entry.value))}` + : ''; + const errorStr = entry.error + ? `${escapeHtml(entry.error)}` + : ''; + + html += ` +
+ ${icon} + ${time} + + ${escapeHtml(entry.widgetId)} + → ${escapeHtml(entry.action)}${valueStr} + + ${errorStr} +
+ `; + } + + container.innerHTML = html || '
No commands sent yet
'; + } + + function clearLog() { + commandLog.length = 0; + renderLog(); + } + + // ── Init ───────────────────────────────────────────────────────────── + + function init() { + const clearBtn = document.getElementById('cdap-clear-log'); + if (clearBtn) { + clearBtn.addEventListener('click', clearLog); + } + renderLog(); + } + + // ── Utilities ──────────────────────────────────────────────────────── + + function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = String(str); + return div.innerHTML; + } + + function showToast(message, type) { + if (window.BetterDeskNotifications?.show) { + window.BetterDeskNotifications.show(message, type); + } + } + + // ── Public API ─────────────────────────────────────────────────────── + + window.CDAPCommands = { + send, + sendWithConfirm, + clearLog, + getLog: () => [...commandLog] + }; + + // Auto-init + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/web-nodejs/public/js/cdap-widgets.js b/web-nodejs/public/js/cdap-widgets.js new file mode 100644 index 00000000..88b27a65 --- /dev/null +++ b/web-nodejs/public/js/cdap-widgets.js @@ -0,0 +1,629 @@ +/** + * BetterDesk Console - CDAP Widget Renderer + * Renders device widgets based on CDAP manifest and polls state updates. + * Supports Phase 2 widget types: toggle, gauge, button, led, text, slider, select, chart. + */ + +(function () { + 'use strict'; + + const __ = window.BetterDesk?.translations || {}; + const t = (key) => { + const parts = key.split('.'); + let val = __; + for (const p of parts) { + val = val?.[p]; + } + return val || key; + }; + + const STATE_POLL_INTERVAL = 3000; + const INFO_POLL_INTERVAL = 10000; + + let deviceId = ''; + let manifest = null; + let widgetState = {}; + let statePollTimer = null; + let infoPollTimer = null; + let isConnected = false; + + // ── Initialization ─────────────────────────────────────────────────── + + function init() { + const page = document.querySelector('.cdap-device-page'); + if (!page) return; + + deviceId = page.dataset.deviceId; + if (!deviceId) return; + + loadDeviceInfo(); + loadManifestAndState(); + } + + async function loadDeviceInfo() { + try { + const res = await fetch(`/api/cdap/devices/${encodeURIComponent(deviceId)}`, { + headers: { 'X-CSRF-Token': window.BetterDesk?.csrfToken || '' } + }); + if (!res.ok) throw new Error(res.statusText); + const data = await res.json(); + + if (data.success && data.data) { + updateDeviceHeader(data.data); + } + } catch (err) { + console.error('CDAP device info error:', err); + } + + // Schedule periodic info refresh + if (!infoPollTimer) { + infoPollTimer = setInterval(loadDeviceInfo, INFO_POLL_INTERVAL); + } + } + + function updateDeviceHeader(info) { + isConnected = !!info.connected; + + // Device name (prefer manifest name or hostname) + const nameEl = document.getElementById('cdap-device-name'); + if (nameEl) { + nameEl.textContent = info.manifest?.device?.name || info.hostname || deviceId; + } + + // Device type + const typeEl = document.getElementById('cdap-device-type'); + if (typeEl && info.manifest?.device?.type) { + const iconMap = { + scada: 'factory', + iot: 'sensors', + os_agent: 'computer', + network: 'router', + camera: 'videocam', + desktop: 'desktop_windows', + custom: 'memory' + }; + const icon = iconMap[info.manifest.device.type] || 'memory'; + typeEl.innerHTML = `${icon}${info.manifest.device.type}`; + } + + // Version + const verEl = document.getElementById('cdap-device-version'); + if (verEl && info.manifest?.device?.firmware_version) { + verEl.innerHTML = `info_outlinev${escapeHtml(info.manifest.device.firmware_version)}`; + } + + // Uptime + const uptimeEl = document.getElementById('cdap-device-uptime'); + if (uptimeEl && info.connected_at) { + const uptime = formatDuration(Date.now() - new Date(info.connected_at).getTime()); + uptimeEl.innerHTML = `schedule${uptime}`; + } + + // Status indicator + const statusEl = document.getElementById('cdap-device-status'); + if (statusEl) { + statusEl.className = `cdap-device-status ${isConnected ? 'online' : 'offline'}`; + statusEl.innerHTML = ` + + ${isConnected ? t('cdap.connected') : t('cdap.disconnected')} + `; + } + + // Offline banner + const banner = document.getElementById('cdap-offline-banner'); + if (banner) { + banner.classList.toggle('hidden', isConnected); + } + } + + async function loadManifestAndState() { + const loading = document.getElementById('cdap-loading'); + const grid = document.getElementById('cdap-widget-grid'); + const empty = document.getElementById('cdap-empty'); + + try { + // Fetch manifest and state in parallel + const [manifestRes, stateRes] = await Promise.all([ + fetch(`/api/cdap/devices/${encodeURIComponent(deviceId)}/manifest`, { + headers: { 'X-CSRF-Token': window.BetterDesk?.csrfToken || '' } + }), + fetch(`/api/cdap/devices/${encodeURIComponent(deviceId)}/state`, { + headers: { 'X-CSRF-Token': window.BetterDesk?.csrfToken || '' } + }) + ]); + + if (manifestRes.ok) { + const mData = await manifestRes.json(); + if (mData.success) manifest = mData.data; + } + + if (stateRes.ok) { + const sData = await stateRes.json(); + if (sData.success) widgetState = sData.data || {}; + } + + if (loading) loading.classList.add('hidden'); + + if (!manifest || !manifest.widgets || manifest.widgets.length === 0) { + if (empty) empty.classList.remove('hidden'); + return; + } + + renderWidgets(); + startStatePolling(); + + } catch (err) { + console.error('CDAP manifest/state error:', err); + if (loading) { + loading.innerHTML = `

${t('cdap.load_error')}

`; + } + } + } + + // ── Widget Rendering ───────────────────────────────────────────────── + + function renderWidgets() { + const grid = document.getElementById('cdap-widget-grid'); + if (!grid || !manifest?.widgets) return; + + // Remove loading state + const loading = document.getElementById('cdap-loading'); + if (loading) loading.remove(); + + // Group widgets by category if categories exist + const widgets = manifest.widgets; + const grouped = groupByCategory(widgets); + + let html = ''; + for (const [category, catWidgets] of Object.entries(grouped)) { + if (category !== '_default') { + html += `

${escapeHtml(category)}

`; + } + for (const widget of catWidgets) { + html += renderWidget(widget); + } + } + + grid.innerHTML = html; + + // Apply initial state values + applyState(widgetState); + + // Show command log if any interactive widgets + const hasInteractive = widgets.some(w => + ['toggle', 'button', 'slider', 'select'].includes(w.type) + ); + if (hasInteractive) { + const log = document.getElementById('cdap-command-log'); + if (log) log.classList.remove('hidden'); + } + + // Bind widget event handlers + bindWidgetEvents(); + } + + function groupByCategory(widgets) { + const groups = {}; + for (const w of widgets) { + const cat = w.category || '_default'; + if (!groups[cat]) groups[cat] = []; + groups[cat].push(w); + } + return groups; + } + + function renderWidget(widget) { + const { id, type, label, unit, read_only } = widget; + const safeId = escapeHtml(id); + const safeLabel = escapeHtml(label || id); + const readOnlyClass = read_only ? ' cdap-widget-readonly' : ''; + + const sizeClass = getWidgetSizeClass(type, widget); + + let inner = ''; + switch (type) { + case 'toggle': + inner = renderToggle(widget); + break; + case 'gauge': + inner = renderGauge(widget); + break; + case 'button': + inner = renderButton(widget); + break; + case 'led': + inner = renderLed(widget); + break; + case 'text': + inner = renderText(widget); + break; + case 'slider': + inner = renderSlider(widget); + break; + case 'select': + inner = renderSelect(widget); + break; + case 'chart': + inner = renderChart(widget); + break; + default: + inner = `
${escapeHtml(type)}
`; + } + + return ` +
+
+ ${safeLabel} + ${unit ? `${escapeHtml(unit)}` : ''} +
+
+ ${inner} +
+
+ `; + } + + function getWidgetSizeClass(type, widget) { + if (widget.size === 'large') return 'cdap-widget-lg'; + if (widget.size === 'small') return 'cdap-widget-sm'; + // Default sizes by type + switch (type) { + case 'chart': return 'cdap-widget-lg'; + case 'text': return 'cdap-widget-sm'; + case 'led': return 'cdap-widget-sm'; + default: return ''; + } + } + + // ── Individual Widget Renderers ────────────────────────────────────── + + function renderToggle(widget) { + const disabled = widget.read_only ? 'disabled' : ''; + return ` + + + `; + } + + function renderGauge(widget) { + const min = widget.min ?? 0; + const max = widget.max ?? 100; + return ` +
+
+
+
+
+ + ${min} – ${max} +
+
+ `; + } + + function renderButton(widget) { + const icon = widget.icon || 'play_arrow'; + const confirmText = widget.confirm ? `data-confirm="${escapeHtml(widget.confirm)}"` : ''; + return ` + + `; + } + + function renderLed(widget) { + return ` +
+
+ +
+ `; + } + + function renderText(widget) { + return ` +
+ `; + } + + function renderSlider(widget) { + const min = widget.min ?? 0; + const max = widget.max ?? 100; + const step = widget.step ?? 1; + const disabled = widget.read_only ? 'disabled' : ''; + return ` +
+ +
+ ${min} + ${min} + ${max} +
+
+ `; + } + + function renderSelect(widget) { + const options = widget.options || []; + const disabled = widget.read_only ? 'disabled' : ''; + let optHtml = ``; + for (const opt of options) { + const val = typeof opt === 'object' ? opt.value : opt; + const label = typeof opt === 'object' ? (opt.label || opt.value) : opt; + optHtml += ``; + } + return ` + + `; + } + + function renderChart(widget) { + // Phase 2: simple bar-style multi-value chart + const series = widget.series || []; + let barsHtml = ''; + for (const s of series) { + barsHtml += ` +
+
${escapeHtml(s.label || s.key || '')}
+
+
+
+
+
+ `; + } + return `
${barsHtml}
`; + } + + // ── State Polling & Application ────────────────────────────────────── + + function startStatePolling() { + if (statePollTimer) clearInterval(statePollTimer); + statePollTimer = setInterval(pollState, STATE_POLL_INTERVAL); + } + + async function pollState() { + try { + const res = await fetch(`/api/cdap/devices/${encodeURIComponent(deviceId)}/state`, { + headers: { 'X-CSRF-Token': window.BetterDesk?.csrfToken || '' } + }); + if (!res.ok) return; + const data = await res.json(); + if (data.success && data.data) { + widgetState = data.data; + applyState(widgetState); + } + } catch (err) { + // Silent fail — device may be offline + } + } + + function applyState(state) { + if (!state || !manifest?.widgets) return; + + for (const widget of manifest.widgets) { + const val = state[widget.id]; + if (val === undefined) continue; + + const el = document.querySelector(`[data-widget-id="${CSS.escape(widget.id)}"]`); + if (!el) continue; + + switch (widget.type) { + case 'toggle': + applyToggleState(el, widget, val); + break; + case 'gauge': + applyGaugeState(el, widget, val); + break; + case 'led': + applyLedState(el, widget, val); + break; + case 'text': + applyTextState(el, widget, val); + break; + case 'slider': + applySliderState(el, widget, val); + break; + case 'select': + applySelectState(el, widget, val); + break; + case 'chart': + applyChartState(el, widget, val); + break; + } + } + } + + function applyToggleState(el, widget, val) { + const input = el.querySelector('.cdap-toggle-input'); + const label = document.getElementById(`wval-${widget.id}`); + const checked = val === true || val === 1 || val === 'on' || val === 'true'; + if (input && !input._userInteracting) input.checked = checked; + if (label) label.textContent = checked ? 'ON' : 'OFF'; + } + + function applyGaugeState(el, widget, val) { + const num = parseFloat(val); + if (isNaN(num)) return; + const min = widget.min ?? 0; + const max = widget.max ?? 100; + const pct = Math.min(100, Math.max(0, ((num - min) / (max - min)) * 100)); + + const bar = document.getElementById(`wbar-${widget.id}`); + const valEl = document.getElementById(`wval-${widget.id}`); + if (bar) { + bar.style.width = pct + '%'; + // Color based on thresholds + if (pct > 90) bar.className = 'cdap-gauge-fill cdap-gauge-danger'; + else if (pct > 70) bar.className = 'cdap-gauge-fill cdap-gauge-warning'; + else bar.className = 'cdap-gauge-fill'; + } + if (valEl) valEl.textContent = num.toFixed(widget.decimals ?? 1); + } + + function applyLedState(el, widget, val) { + const light = el.querySelector('.cdap-led-light'); + const label = document.getElementById(`wval-${widget.id}`); + const on = val === true || val === 1 || val === 'on' || val === 'true'; + if (light) { + light.className = `cdap-led-light ${on ? 'on' : 'off'}`; + if (typeof val === 'string' && val.startsWith('#')) { + light.style.backgroundColor = val; + light.className = 'cdap-led-light on'; + } + } + if (label) label.textContent = typeof val === 'string' ? val : (on ? 'ON' : 'OFF'); + } + + function applyTextState(el, widget, val) { + const valEl = document.getElementById(`wval-${widget.id}`); + if (valEl) valEl.textContent = String(val); + } + + function applySliderState(el, widget, val) { + const input = el.querySelector('.cdap-slider-input'); + const valEl = document.getElementById(`wval-${widget.id}`); + const num = parseFloat(val); + if (isNaN(num)) return; + if (input && !input._userInteracting) input.value = num; + if (valEl) valEl.textContent = num.toFixed(widget.decimals ?? 0); + } + + function applySelectState(el, widget, val) { + const select = el.querySelector('.cdap-select-input'); + if (select && !select._userInteracting) select.value = String(val); + } + + function applyChartState(el, widget, val) { + if (typeof val !== 'object') return; + const series = widget.series || []; + for (const s of series) { + const key = s.key || s.label || ''; + const seriesVal = val[key]; + if (seriesVal === undefined) continue; + const num = parseFloat(seriesVal); + if (isNaN(num)) continue; + const min = s.min ?? 0; + const max = s.max ?? 100; + const pct = Math.min(100, Math.max(0, ((num - min) / (max - min)) * 100)); + const bar = document.getElementById(`wbar-${widget.id}-${key}`); + const valEl = document.getElementById(`wval-${widget.id}-${key}`); + if (bar) bar.style.width = pct + '%'; + if (valEl) valEl.textContent = num.toFixed(1); + } + } + + // ── Event Binding ──────────────────────────────────────────────────── + + function bindWidgetEvents() { + // Toggle switches + document.querySelectorAll('.cdap-toggle-input').forEach(input => { + input.addEventListener('change', (e) => { + const widgetEl = e.target.closest('.cdap-widget'); + if (!widgetEl) return; + const wid = widgetEl.dataset.widgetId; + window.CDAPCommands?.send(deviceId, wid, 'set', e.target.checked); + }); + // Prevent state polling from overriding user interaction + input.addEventListener('mousedown', () => { input._userInteracting = true; }); + input.addEventListener('change', () => { setTimeout(() => { input._userInteracting = false; }, 2000); }); + }); + + // Action buttons + document.querySelectorAll('.cdap-action-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const widgetEl = e.target.closest('.cdap-widget'); + if (!widgetEl) return; + const wid = widgetEl.dataset.widgetId; + const confirm = btn.dataset.confirm; + if (confirm) { + window.CDAPCommands?.sendWithConfirm(deviceId, wid, 'trigger', null, confirm); + } else { + window.CDAPCommands?.send(deviceId, wid, 'trigger', null); + } + }); + }); + + // Sliders (debounced) + document.querySelectorAll('.cdap-slider-input').forEach(input => { + let debounce = null; + input.addEventListener('input', (e) => { + const widgetEl = e.target.closest('.cdap-widget'); + if (!widgetEl) return; + const wid = widgetEl.dataset.widgetId; + const valEl = document.getElementById(`wval-${wid}`); + if (valEl) valEl.textContent = e.target.value; + input._userInteracting = true; + clearTimeout(debounce); + debounce = setTimeout(() => { + window.CDAPCommands?.send(deviceId, wid, 'set', parseFloat(e.target.value)); + setTimeout(() => { input._userInteracting = false; }, 2000); + }, 300); + }); + }); + + // Selects + document.querySelectorAll('.cdap-select-input').forEach(select => { + select.addEventListener('change', (e) => { + const widgetEl = e.target.closest('.cdap-widget'); + if (!widgetEl) return; + const wid = widgetEl.dataset.widgetId; + select._userInteracting = true; + window.CDAPCommands?.send(deviceId, wid, 'set', e.target.value); + setTimeout(() => { select._userInteracting = false; }, 2000); + }); + }); + } + + // ── Utilities ──────────────────────────────────────────────────────── + + function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = String(str); + return div.innerHTML; + } + + function formatDuration(ms) { + if (ms < 0) ms = 0; + const s = Math.floor(ms / 1000); + const m = Math.floor(s / 60); + const h = Math.floor(m / 60); + const d = Math.floor(h / 24); + if (d > 0) return `${d}d ${h % 24}h`; + if (h > 0) return `${h}h ${m % 60}m`; + if (m > 0) return `${m}m`; + return `${s}s`; + } + + // ── Public API ─────────────────────────────────────────────────────── + + window.CDAPWidgets = { + init, + refresh: loadManifestAndState, + getState: () => widgetState, + getManifest: () => manifest, + isDeviceConnected: () => isConnected + }; + + // Auto-init on DOM ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + // Cleanup on page unload + window.addEventListener('beforeunload', () => { + if (statePollTimer) clearInterval(statePollTimer); + if (infoPollTimer) clearInterval(infoPollTimer); + }); +})(); diff --git a/web-nodejs/public/js/desktop-mode.js b/web-nodejs/public/js/desktop-mode.js new file mode 100644 index 00000000..a873c89a --- /dev/null +++ b/web-nodejs/public/js/desktop-mode.js @@ -0,0 +1,805 @@ +/** + * BetterDesk Console - Desktop Mode + * Windows-like desktop environment with floating windows, taskbar, and app icons. + * Available on viewports >= 1200px. + */ + +(function() { + 'use strict'; + + // ============ Constants ============ + + const MIN_WIDTH = 420; + const MIN_HEIGHT = 300; + const TASKBAR_HEIGHT = 48; + const BREAKPOINT = 1200; + const STORAGE_KEY = 'betterdesk_desktop_mode'; + const STORAGE_WINS_KEY = 'betterdesk_desktop_wins'; + const CASCADE_OFFSET = 32; + + // ============ State ============ + + let active = false; + let windows = new Map(); + let zCounter = 100; + let focusedWindowId = null; + let cascadeIndex = 0; + let dragState = null; + let resizeState = null; + + // ============ Apps Definition ============ + + function getApps() { + var t = typeof _ === 'function' ? _ : function(k) { return k; }; + var isAdmin = window.BetterDesk && window.BetterDesk.user && + window.BetterDesk.user.role === 'admin'; + var apps = [ + { id: 'dashboard', icon: 'dashboard', route: '/', color: '#58a6ff', name: t('nav.dashboard') }, + { id: 'devices', icon: 'devices', route: '/devices', color: '#3fb950', name: t('nav.devices') }, + { id: 'registrations', icon: 'how_to_reg', route: '/registrations', color: '#79c0ff', name: t('nav.registrations') }, + { id: 'keys', icon: 'vpn_key', route: '/keys', color: '#d29922', name: t('nav.keys') }, + { id: 'generator', icon: 'build', route: '/generator', color: '#bc8cff', name: t('nav.generator') }, + { id: 'settings', icon: 'settings', route: '/settings', color: '#8b949e', name: t('nav.settings') } + ]; + + if (isAdmin) { + apps.splice(5, 0, { + id: 'users', icon: 'group', route: '/users', color: '#f778ba', name: t('nav.users') + }); + } + + return apps; + } + + // ============ Initialization ============ + + function init() { + if (window.BetterDesk && window.BetterDesk.embed) return; + if (window.innerWidth < BREAKPOINT) return; + + setupGlobalListeners(); + + if (localStorage.getItem(STORAGE_KEY) === 'true' && window.innerWidth >= BREAKPOINT) { + activate(true); + } + } + + function setupGlobalListeners() { + // Navbar toggle button + var btn = document.getElementById('desktop-toggle-btn'); + if (btn) { + btn.addEventListener('click', function() { toggle(); }); + } + + // Taskbar console button + var consoleBtn = document.getElementById('taskbar-console-btn'); + if (consoleBtn) { + consoleBtn.addEventListener('click', function() { deactivate(); }); + } + + // Taskbar start button + var startBtn = document.getElementById('taskbar-start-btn'); + if (startBtn) { + startBtn.addEventListener('click', function() { openStartMenu(); }); + } + + // Global mouse events for drag/resize + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + // Responsive: deactivate if viewport shrinks below breakpoint + window.addEventListener('resize', Utils.debounce(function() { + if (active && window.innerWidth < BREAKPOINT) { + deactivate(true); + } + }, 200)); + } + + // ============ Activate / Deactivate ============ + + function activate(skipAnimation) { + if (active) return; + active = true; + localStorage.setItem(STORAGE_KEY, 'true'); + + document.body.classList.add('desktop-active'); + if (!skipAnimation) { + document.body.classList.add('desktop-entering'); + setTimeout(function() { + document.body.classList.remove('desktop-entering'); + }, 350); + } + + renderDesktopIcons(); + startClock(); + } + + function deactivate(silent) { + if (!active) return; + active = false; + localStorage.setItem(STORAGE_KEY, 'false'); + + // Close all windows + windows.forEach(function(win) { + removeWindowDOM(win.id, true); + }); + windows.clear(); + focusedWindowId = null; + cascadeIndex = 0; + + document.body.classList.remove('desktop-active', 'desktop-entering'); + stopClock(); + clearDesktopIcons(); + clearTaskbar(); + + if (!silent) { + // Reload to restore console view properly + window.location.reload(); + } + } + + function toggle() { + if (active) { + deactivate(); + } else { + activate(); + } + } + + // ============ Desktop Icons ============ + + function renderDesktopIcons() { + var container = document.getElementById('desktop-icons'); + if (!container) return; + container.innerHTML = ''; + + var apps = getApps(); + apps.forEach(function(app, index) { + var el = document.createElement('div'); + el.className = 'desktop-icon'; + el.setAttribute('data-app', app.id); + el.style.animationDelay = (index * 0.05) + 's'; + + el.innerHTML = + '
' + + '' + app.icon + '' + + '
' + + '' + escapeHtml(app.name) + ''; + + el.addEventListener('dblclick', function() { + openApp(app); + }); + + container.appendChild(el); + }); + } + + function clearDesktopIcons() { + var container = document.getElementById('desktop-icons'); + if (container) container.innerHTML = ''; + } + + // ============ Start Menu ============ + + function openStartMenu() { + // Simple: open a small overlay with app list near taskbar + var existing = document.getElementById('desktop-start-menu'); + if (existing) { + existing.remove(); + return; + } + + var apps = getApps(); + var menu = document.createElement('div'); + menu.id = 'desktop-start-menu'; + menu.style.cssText = + 'position:fixed;bottom:52px;left:8px;z-index:9999;' + + 'background:rgba(13,17,23,0.92);backdrop-filter:blur(20px);' + + 'border:1px solid rgba(48,54,61,0.6);border-radius:10px;' + + 'padding:8px;min-width:220px;' + + 'animation:windowOpen 0.2s cubic-bezier(0.34,1.56,0.64,1) forwards;'; + + apps.forEach(function(app) { + var item = document.createElement('div'); + item.style.cssText = + 'display:flex;align-items:center;gap:10px;padding:8px 12px;' + + 'border-radius:6px;cursor:pointer;color:var(--text-primary,#e6edf3);' + + 'font-size:13px;transition:background 0.12s ease;'; + + item.innerHTML = + '' + + app.icon + + '' + + '' + escapeHtml(app.name) + ''; + + item.addEventListener('mouseenter', function() { + item.style.background = 'rgba(255,255,255,0.08)'; + }); + item.addEventListener('mouseleave', function() { + item.style.background = 'transparent'; + }); + item.addEventListener('click', function() { + menu.remove(); + openApp(app); + }); + + menu.appendChild(item); + }); + + // Close button + var closeItem = document.createElement('div'); + closeItem.style.cssText = + 'display:flex;align-items:center;gap:10px;padding:8px 12px;' + + 'border-radius:6px;cursor:pointer;color:var(--accent-red,#da3633);' + + 'font-size:13px;transition:background 0.12s ease;margin-top:4px;' + + 'border-top:1px solid rgba(48,54,61,0.4);padding-top:12px;'; + var t = typeof _ === 'function' ? _ : function(k) { return k; }; + closeItem.innerHTML = + 'view_sidebar' + + '' + escapeHtml(t('desktop.console_mode')) + ''; + closeItem.addEventListener('mouseenter', function() { + closeItem.style.background = 'rgba(255,255,255,0.08)'; + }); + closeItem.addEventListener('mouseleave', function() { + closeItem.style.background = 'transparent'; + }); + closeItem.addEventListener('click', function() { + menu.remove(); + deactivate(); + }); + menu.appendChild(closeItem); + + document.body.appendChild(menu); + + // Close on outside click + setTimeout(function() { + function closeMenu(e) { + if (!menu.contains(e.target) && e.target.id !== 'taskbar-start-btn' && + !e.target.closest('#taskbar-start-btn')) { + menu.remove(); + document.removeEventListener('click', closeMenu); + } + } + document.addEventListener('click', closeMenu); + }, 10); + } + + // ============ Window Management ============ + + function openApp(app) { + // Check if window already open for this app + var existingId = null; + windows.forEach(function(win, id) { + if (win.appId === app.id) existingId = id; + }); + + if (existingId) { + var win = windows.get(existingId); + if (win.minimized) { + restoreWindow(existingId); + } + focusWindow(existingId); + return; + } + + createWindow(app); + } + + function createWindow(app) { + var id = 'win-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5); + + // Calculate position (cascading) + var area = getDesktopArea(); + var width = Math.min(960, area.width - 80); + var height = Math.min(640, area.height - 80); + var x = area.x + 60 + (cascadeIndex * CASCADE_OFFSET) % (area.width - width - 60); + var y = area.y + 40 + (cascadeIndex * CASCADE_OFFSET) % (area.height - height - 40); + cascadeIndex++; + + var win = { + id: id, + appId: app.id, + app: app, + x: x, + y: y, + width: width, + height: height, + minimized: false, + maximized: false, + prevBounds: null, + zIndex: ++zCounter + }; + + windows.set(id, win); + renderWindow(win); + focusWindow(id); + updateTaskbar(); + } + + function renderWindow(win) { + var container = document.getElementById('desktop-windows'); + if (!container) return; + + var el = document.createElement('div'); + el.className = 'desktop-window focused'; + el.id = win.id; + el.style.left = win.x + 'px'; + el.style.top = win.y + 'px'; + el.style.width = win.width + 'px'; + el.style.height = win.height + 'px'; + el.style.zIndex = win.zIndex; + + var t = typeof _ === 'function' ? _ : function(k) { return k; }; + + el.innerHTML = + '
' + + '
' + + '' + win.app.icon + '' + + '
' + + '
' + escapeHtml(win.app.name) + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + escapeHtml(t('desktop.loading')) + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + + // Event: focus on click + el.addEventListener('mousedown', function(e) { + if (!e.target.closest('.window-ctrl-btn')) { + focusWindow(win.id); + } + }); + + // Event: title bar controls + el.querySelectorAll('.window-ctrl-btn').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + var action = btn.getAttribute('data-action'); + if (action === 'minimize') minimizeWindow(win.id); + else if (action === 'maximize') toggleMaximize(win.id); + else if (action === 'close') closeWindow(win.id); + }); + }); + + // Event: drag via title bar + var titlebar = el.querySelector('.window-titlebar'); + titlebar.addEventListener('mousedown', function(e) { + if (e.target.closest('.window-ctrl-btn')) return; + startDrag(win.id, e); + }); + + // Event: double-click title bar to maximize + titlebar.addEventListener('dblclick', function(e) { + if (e.target.closest('.window-ctrl-btn')) return; + toggleMaximize(win.id); + }); + + // Event: resize edges/corners + el.querySelectorAll('.window-edge').forEach(function(edge) { + edge.addEventListener('mousedown', function(e) { + e.stopPropagation(); + startResize(win.id, e, edge.getAttribute('data-dir')); + }); + }); + + // Event: iframe loaded + var iframe = el.querySelector('iframe'); + var loadingOverlay = el.querySelector('.window-loading'); + iframe.addEventListener('load', function() { + loadingOverlay.classList.add('hidden'); + }); + + container.appendChild(el); + } + + function closeWindow(id) { + var el = document.getElementById(id); + if (!el) { + windows.delete(id); + updateTaskbar(); + return; + } + + el.classList.add('closing'); + el.addEventListener('animationend', function() { + removeWindowDOM(id, false); + }, { once: true }); + } + + function removeWindowDOM(id, immediate) { + var el = document.getElementById(id); + if (el) { + // Destroy iframe to free memory + var iframe = el.querySelector('iframe'); + if (iframe) iframe.src = 'about:blank'; + el.remove(); + } + windows.delete(id); + + if (focusedWindowId === id) { + focusedWindowId = null; + // Focus next topmost window + var topWin = null; + windows.forEach(function(w) { + if (!w.minimized && (!topWin || w.zIndex > topWin.zIndex)) { + topWin = w; + } + }); + if (topWin) focusWindow(topWin.id); + } + + updateTaskbar(); + } + + function minimizeWindow(id) { + var win = windows.get(id); + if (!win) return; + win.minimized = true; + + var el = document.getElementById(id); + if (el) { + el.classList.add('minimizing'); + el.addEventListener('animationend', function() { + el.style.display = 'none'; + el.classList.remove('minimizing'); + }, { once: true }); + } + + if (focusedWindowId === id) { + focusedWindowId = null; + // Focus next topmost visible window + var topWin = null; + windows.forEach(function(w) { + if (!w.minimized && w.id !== id && (!topWin || w.zIndex > topWin.zIndex)) { + topWin = w; + } + }); + if (topWin) focusWindow(topWin.id); + } + + updateTaskbar(); + } + + function restoreWindow(id) { + var win = windows.get(id); + if (!win) return; + win.minimized = false; + + var el = document.getElementById(id); + if (el) { + el.style.display = ''; + el.style.animation = 'none'; + // Force reflow + el.offsetHeight; + el.style.animation = ''; + el.classList.remove('minimizing'); + // Re-trigger open animation + el.style.animation = 'windowOpen 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards'; + } + + focusWindow(id); + updateTaskbar(); + } + + function toggleMaximize(id) { + var win = windows.get(id); + if (!win) return; + + var el = document.getElementById(id); + if (!el) return; + + if (win.maximized) { + // Restore + win.maximized = false; + el.classList.remove('maximized'); + if (win.prevBounds) { + el.style.left = win.prevBounds.x + 'px'; + el.style.top = win.prevBounds.y + 'px'; + el.style.width = win.prevBounds.width + 'px'; + el.style.height = win.prevBounds.height + 'px'; + win.x = win.prevBounds.x; + win.y = win.prevBounds.y; + win.width = win.prevBounds.width; + win.height = win.prevBounds.height; + } + } else { + // Maximize + win.prevBounds = { x: win.x, y: win.y, width: win.width, height: win.height }; + win.maximized = true; + var area = getDesktopArea(); + el.classList.add('maximized'); + el.style.left = area.x + 'px'; + el.style.top = area.y + 'px'; + el.style.width = area.width + 'px'; + el.style.height = area.height + 'px'; + win.x = area.x; + win.y = area.y; + win.width = area.width; + win.height = area.height; + } + + // Update maximize button icon + var maxBtn = el.querySelector('.maximize-btn .material-icons'); + if (maxBtn) { + maxBtn.textContent = win.maximized ? 'filter_none' : 'crop_square'; + } + } + + function focusWindow(id) { + if (focusedWindowId === id) return; + + // Unfocus previous + if (focusedWindowId) { + var prevEl = document.getElementById(focusedWindowId); + if (prevEl) prevEl.classList.remove('focused'); + } + + focusedWindowId = id; + var win = windows.get(id); + if (!win) return; + + win.zIndex = ++zCounter; + var el = document.getElementById(id); + if (el) { + el.style.zIndex = win.zIndex; + el.classList.add('focused'); + // Disable pointer events on iframe when not focused for drag/resize + } + + updateTaskbar(); + } + + // ============ Drag ============ + + function startDrag(winId, e) { + var win = windows.get(winId); + if (!win || win.maximized) return; + + e.preventDefault(); + focusWindow(winId); + + dragState = { + winId: winId, + startX: e.clientX, + startY: e.clientY, + origX: win.x, + origY: win.y + }; + + disableIframePointerEvents(); + document.body.style.cursor = 'move'; + } + + function handleMouseMove(e) { + if (dragState) { + var dx = e.clientX - dragState.startX; + var dy = e.clientY - dragState.startY; + var win = windows.get(dragState.winId); + if (!win) return; + + win.x = dragState.origX + dx; + win.y = Math.max(0, dragState.origY + dy); // don't drag above viewport + + var el = document.getElementById(dragState.winId); + if (el) { + el.style.left = win.x + 'px'; + el.style.top = win.y + 'px'; + } + } + + if (resizeState) { + var dx = e.clientX - resizeState.startX; + var dy = e.clientY - resizeState.startY; + var win = windows.get(resizeState.winId); + if (!win) return; + + var dir = resizeState.dir; + var newX = win.x, newY = win.y; + var newW = win.width, newH = win.height; + + if (dir.indexOf('e') !== -1) { + newW = Math.max(MIN_WIDTH, resizeState.origW + dx); + } + if (dir.indexOf('w') !== -1) { + var dw = resizeState.origW - dx; + if (dw >= MIN_WIDTH) { + newW = dw; + newX = resizeState.origX + dx; + } + } + if (dir.indexOf('s') !== -1) { + newH = Math.max(MIN_HEIGHT, resizeState.origH + dy); + } + if (dir === 'n' || dir === 'ne' || dir === 'nw') { + var dh = resizeState.origH - dy; + if (dh >= MIN_HEIGHT) { + newH = dh; + newY = resizeState.origY + dy; + } + } + + win.x = newX; + win.y = newY; + win.width = newW; + win.height = newH; + + var el = document.getElementById(resizeState.winId); + if (el) { + el.style.left = newX + 'px'; + el.style.top = newY + 'px'; + el.style.width = newW + 'px'; + el.style.height = newH + 'px'; + } + } + } + + function handleMouseUp() { + if (dragState || resizeState) { + enableIframePointerEvents(); + document.body.style.cursor = ''; + } + dragState = null; + resizeState = null; + } + + // ============ Resize ============ + + var cursorMap = { n:'ns-resize', s:'ns-resize', e:'ew-resize', w:'ew-resize', + ne:'nesw-resize', sw:'nesw-resize', nw:'nwse-resize', se:'nwse-resize' }; + + function startResize(winId, e, dir) { + var win = windows.get(winId); + if (!win || win.maximized) return; + + e.preventDefault(); + focusWindow(winId); + + resizeState = { + winId: winId, + dir: dir || 'se', + startX: e.clientX, + startY: e.clientY, + origW: win.width, + origH: win.height, + origX: win.x, + origY: win.y + }; + + disableIframePointerEvents(); + document.body.style.cursor = cursorMap[dir] || 'se-resize'; + } + + // ============ Iframe Pointer Control ============ + + function disableIframePointerEvents() { + document.querySelectorAll('.desktop-window iframe').forEach(function(iframe) { + iframe.style.pointerEvents = 'none'; + }); + } + + function enableIframePointerEvents() { + document.querySelectorAll('.desktop-window iframe').forEach(function(iframe) { + iframe.style.pointerEvents = ''; + }); + } + + // ============ Taskbar ============ + + function updateTaskbar() { + var container = document.getElementById('taskbar-apps'); + if (!container) return; + container.innerHTML = ''; + + windows.forEach(function(win) { + var btn = document.createElement('button'); + btn.className = 'taskbar-app-btn'; + if (!win.minimized) btn.classList.add('active'); + if (win.id === focusedWindowId) btn.classList.add('focused'); + + btn.innerHTML = + '' + + win.app.icon + + '' + + '' + escapeHtml(win.app.name) + ''; + + btn.addEventListener('click', function() { + if (win.minimized) { + restoreWindow(win.id); + } else if (win.id === focusedWindowId) { + minimizeWindow(win.id); + } else { + focusWindow(win.id); + } + }); + + container.appendChild(btn); + }); + } + + function clearTaskbar() { + var container = document.getElementById('taskbar-apps'); + if (container) container.innerHTML = ''; + } + + // ============ Clock ============ + + var clockInterval = null; + + function startClock() { + updateClock(); + clockInterval = setInterval(updateClock, 1000); + } + + function stopClock() { + if (clockInterval) { + clearInterval(clockInterval); + clockInterval = null; + } + } + + function updateClock() { + var el = document.getElementById('taskbar-clock'); + if (!el) return; + var now = new Date(); + var h = String(now.getHours()).padStart(2, '0'); + var m = String(now.getMinutes()).padStart(2, '0'); + el.textContent = h + ':' + m; + } + + // ============ Helpers ============ + + function getDesktopArea() { + return { + x: 0, + y: 0, + width: window.innerWidth, + height: window.innerHeight - TASKBAR_HEIGHT + }; + } + + function escapeHtml(str) { + var div = document.createElement('div'); + div.textContent = str || ''; + return div.innerHTML; + } + + function escapeAttr(str) { + return (str || '').replace(/&/g, '&').replace(/"/g, '"') + .replace(/'/g, ''').replace(//g, '>'); + } + + // ============ Public API ============ + + window.DesktopMode = { + init: init, + toggle: toggle, + isActive: function() { return active; }, + activate: activate, + deactivate: deactivate + }; + +})(); diff --git a/web-nodejs/public/js/devices.js b/web-nodejs/public/js/devices.js index 9ff794e6..b6cc0b88 100644 --- a/web-nodejs/public/js/devices.js +++ b/web-nodejs/public/js/devices.js @@ -11,6 +11,19 @@ if (/^#[0-9A-Fa-f]{3,6}$/.test(c)) return c; return '#808080'; } + + // Map device_type to Material Icons + function getDeviceTypeIcon(type) { + switch ((type || '').toLowerCase()) { + case 'desktop': return 'desktop_windows'; + case 'scada': return 'precision_manufacturing'; + case 'iot': return 'sensors'; + case 'os_agent': return 'terminal'; + case 'mobile': return 'phone_android'; + case 'rustdesk': return 'connected_tv'; + default: return 'connected_tv'; + } + } document.addEventListener('DOMContentLoaded', init); @@ -49,8 +62,9 @@ initSync(); initFolders(); initDragDrop(); - attachFolderDropEvents(); // For static folders + attachFolderDropEvents(); // For static folder chips initColumnVisibility(); // Column show/hide toggle + initKebabGlobalClose(); // Close kebab menus on outside click // Refresh handler window.addEventListener('app:refresh', () => { @@ -64,6 +78,28 @@ loadDevices(); }); } + + /** + * Close all open kebab menus when clicking outside + */ + function initKebabGlobalClose() { + document.addEventListener('click', (e) => { + if (!e.target.closest('.kebab-wrapper')) { + closeAllKebabMenus(); + } + }); + + // Close on Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeAllKebabMenus(); + }); + } + + function closeAllKebabMenus() { + document.querySelectorAll('.kebab-menu.open').forEach(m => m.classList.remove('open')); + const overlay = document.getElementById('kebab-overlay'); + if (overlay) overlay.classList.remove('open'); + } /** * Load devices from API @@ -110,7 +146,8 @@ device.id?.toLowerCase().includes(q) || device.hostname?.toLowerCase().includes(q) || device.username?.toLowerCase().includes(q) || - device.platform?.toLowerCase().includes(q); + device.platform?.toLowerCase().includes(q) || + (device.device_type || 'rustdesk').toLowerCase().includes(q); if (!match) return false; } @@ -173,20 +210,30 @@ return; } - tableBody.innerHTML = pageDevices.map(device => ` - - - drag_indicator - + const statusClass = (d) => d.banned ? 'banned' : d.online ? 'online' : 'offline'; + const statusLabel = (d) => d.banned ? _('status.banned') : d.online ? _('status.online') : _('status.offline'); + + tableBody.innerHTML = pageDevices.map(device => { + const eid = Utils.escapeHtml(device.id); + const sc = statusClass(device); + return ` +
- ${Utils.escapeHtml(device.id)} -
${Utils.escapeHtml(device.hostname || device.note || '-')} + +
+ ${getDeviceTypeIcon(device.device_type)} + ${Utils.escapeHtml(device.device_type || 'rustdesk')} +
+
${Utils.getPlatformIcon(device.platform || device.os)} @@ -194,41 +241,44 @@
-
-
${Utils.formatDate(device.last_online)}
-
${Utils.formatRelativeTime(device.last_online)}
-
+ ${Utils.formatRelativeTime(device.last_online)} - ${device.banned - ? `${_('status.banned')}` - : device.online - ? `${_('status.online')}` - : `${_('status.offline')}` - } + ${statusLabel(device)} -
- - - - - +
+ + +
+ + +
+ +
- - `).join(''); + `; + }).join(''); // Re-apply column visibility to newly rendered rows applyColumnVisibility(); @@ -252,30 +302,36 @@ Notifications.success(_('common.copied')); }); }); - - // Checkboxes - tableBody.querySelectorAll('.device-checkbox').forEach(cb => { - cb.addEventListener('change', () => { - const id = cb.dataset.id; - if (cb.checked) { - selectedIds.add(id); - } else { - selectedIds.delete(id); + + // Kebab menu toggle + tableBody.querySelectorAll('.kebab-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const menu = btn.nextElementSibling; + const wasOpen = menu.classList.contains('open'); + closeAllKebabMenus(); + if (!wasOpen) { + menu.classList.add('open'); + const overlay = document.getElementById('kebab-overlay'); + if (overlay) overlay.classList.add('open'); } - updateSelectionUI(); }); }); - - // Action buttons - tableBody.querySelectorAll('.action-btn').forEach(btn => { - btn.addEventListener('click', () => handleAction(btn.dataset.action, btn.dataset.id, btn.dataset)); + + // Kebab menu item actions + tableBody.querySelectorAll('.kebab-menu-item').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + closeAllKebabMenus(); + handleAction(btn.dataset.action, btn.dataset.id, btn.dataset); + }); }); // Double-click row to open device detail panel tableBody.querySelectorAll('tr[data-id]').forEach(row => { row.addEventListener('dblclick', (e) => { - // Ignore double-click on action buttons and drag handle - if (e.target.closest('.action-btn') || e.target.closest('.drag-handle') || e.target.closest('.copy-btn')) return; + // Ignore double-click on kebab menu and copy button + if (e.target.closest('.kebab-wrapper') || e.target.closest('.copy-btn')) return; const deviceId = row.dataset.id; if (deviceId && typeof DeviceDetail !== 'undefined') { DeviceDetail.open(deviceId); @@ -469,6 +525,16 @@

${_('devices.delete_warning')}

${Utils.escapeHtml(deviceId)}

${_('devices.delete_permanent')}

+
+ +

+ ${_('devices.revoke_hint')} +

+
- +