diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ed6dd977..b55c17ba 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -504,6 +504,27 @@ sudo apt-get install -y build-essential libsqlite3-dev pkg-config libssl-dev git 129. [x] **Dynamic codec negotiation**: `buildLoginRequest()` in `protocol.js` now detects `VideoDecoder` (WebCodecs) and `JMuxer` availability. HTTPS: reports VP9+H264+AV1+VP8 with Auto preference. HTTP: reports H264-only with H264 preference. Gives peer more encoding options on HTTPS. 130. [x] **FPS option after login**: `_startSession()` in `client.js` sends `customFps` option as Misc message after login. Default reduced from 60 to 30 fps for stability. Helps peer establish target framerate without relying solely on `video_received` ack timing. +#### Go Server & Node.js — Device Management Fix (Phase 23) ✅ COMPLETED 2026-03-18 +131. [x] **IsPeerSoftDeleted interface + impl**: Added `IsPeerSoftDeleted(id string) (bool, error)` to `db/database.go` interface. Implemented in both `sqlite.go` and `postgres.go` — queries `soft_deleted` column for deleted device detection. +132. [x] **Zombie device prevention (Issues #65, #64, #38)**: Signal handler now checks `IsPeerSoftDeleted()` after `IsPeerBanned()` in both `handleRegisterPeer()` and `processRegisterPk()`. Deleted devices cannot re-register, preventing "zombie" devices from reappearing after admin deletion. +133. [x] **UpdatePeerFields method**: Added `UpdatePeerFields(id string, fields map[string]string) error` to Database interface + implementations. Supports dynamic partial updates for `note`, `user`, `tags` fields with SQL-safe allowed-key validation. +134. [x] **PATCH /api/peers/{id} endpoint**: New REST endpoint in `api/server.go` for partial peer updates. Accepts JSON body `{"note": "...", "user": "...", "tags": "..."}`. Used by Node.js panel instead of direct SQLite writes. +135. [x] **Tags type mismatch fix (Issues #65, #38)**: `handleSetPeerTags` in `api/server.go` now accepts both JSON string (`"tag1,tag2"`) and array (`["tag1","tag2"]`) using `json.RawMessage`. Fixes 400 errors when panel sends array format. +136. [x] **Notes routed through Go API**: `serverBackend.js` `updateDevice()` now calls Go server's `PATCH /api/peers/{id}` endpoint instead of writing directly to Node.js SQLite. Ensures notes/user/tags stored in Go server's `db_v2.sqlite3`. +137. [x] **Tag serialization fix**: `betterdeskApi.js` `setPeerTags()` now sends tags as array in request body. Added `updatePeer()` method for PATCH requests. +138. [x] **auth.db cleanup on delete**: `devices.routes.js` delete handler now calls `db.cleanupDeletedPeerData(id)` to remove user linkages from auth.db when device is deleted. Implemented `cleanupDeletedPeerData()` in `dbAdapter.js` for both SQLite and PostgreSQL. +139. [x] **Relay UUID tracking (Issues #65, #64)**: Old RustDesk clients respond with empty UUID in `RelayResponse`. Added `pendingRelayUUIDs sync.Map` to track UUIDs sent to targets in `RequestRelay`/`PunchHole`. When target responds with empty UUID, `handleRelayResponseForward` recovers original UUID from store. Fixes relay pairing failures. +140. [x] **ActionPeerUpdated audit**: Added `ActionPeerUpdated` constant to `audit/logger.go` for tracking peer field updates. +141. [x] **getPendingUUID retry support**: Changed `getPendingUUID()` from `LoadAndDelete` to `Load` — UUID now remains available for multiple retry attempts from target device. Cleanup handled by existing ticker goroutine (2-min TTL). + +#### Go Server — Peer Metrics Persistence (Phase 24) ✅ COMPLETED 2026-03-19 +142. [x] **PeerMetric struct**: Added `PeerMetric` struct to `db/database.go` (ID, PeerID, CPU, Memory, Disk, CreatedAt) for heartbeat metrics storage. +143. [x] **Database interface methods**: Added `SavePeerMetric()`, `GetPeerMetrics()`, `GetLatestPeerMetric()`, `CleanupOldMetrics()` to Database interface. +144. [x] **peer_metrics table (SQLite)**: Added `peer_metrics` table to `sqlite.go` Migrate() with indexes on peer_id and created_at. Implemented all 4 metric methods. +145. [x] **peer_metrics table (PostgreSQL)**: Added `peer_metrics` table to `postgres.go` Migrate() with BIGSERIAL PK and TIMESTAMPTZ. Implemented all 4 metric methods. +146. [x] **handleClientHeartbeat extended**: Now parses `cpu`, `memory`, `disk` float64 fields from request body and calls `SavePeerMetric()` when any value > 0. +147. [x] **GET /api/peers/{id}/metrics endpoint**: New API endpoint returns historical metrics for a peer with configurable limit (default 100, max 1000). Enables Node.js console to fetch metrics from Go server. + --- ## 🔄 System Statusu v3.0 @@ -680,6 +701,10 @@ Pełna dokumentacja budowania: [BUILD_GUIDE.md](../docs/BUILD_GUIDE.md) 31. ~~**Password modal plaintext (Issue #60)**~~ ✅ ROZWIĄZANE - `modal.js` prompt checked `options.type` but `users.js` passed `inputType`. Fixed to check both — Phase 18 32. ~~**Empty UUID in relay causes all WAN connections to fail (Issues #58, #63, #64)**~~ ✅ ROZWIĄZANE - `PunchHoleResponse` has no `uuid` field, so when hole-punch fails, client sends `RequestRelay{uuid=""}`. Signal server now generates `uuid.New().String()` when empty in both `handleRequestRelay()` (UDP) and `handleRequestRelayTCP()` (TCP). Relay address validation rejects `host < 2 chars` (prevents `relay=a:21117`) — Phase 19 33. ~~**Docker DNS failures during build (Issue #62)**~~ ✅ ROZWIĄZANE - Added retry logic to all `apk add --no-cache` commands in Dockerfile, Dockerfile.server, Dockerfile.console — Phase 19 +34. ~~**Target device sends empty UUID in RelayResponse (Issues #64, #65)**~~ ✅ ROZWIĄZANE - Old RustDesk clients don't echo UUID back in `RelayResponse`. Added `pendingRelayUUIDs sync.Map` to track UUIDs sent to targets in `RequestRelay`/`PunchHole`. When target responds with empty UUID, `handleRelayResponseForward` recovers original UUID from store. Fixes relay pairing failures where initiator and target used mismatched UUIDs — Phase 23 +35. ~~**Notes/tags written to wrong database**~~ ✅ ROZWIĄZANE - Node.js panel was writing notes/user/tags directly to local SQLite instead of Go server's database. Now routes through `PATCH /api/peers/{id}` endpoint on Go server — Phase 23 +36. ~~**Deleted devices reappear as zombies**~~ ✅ ROZWIĄZANE - Added `IsPeerSoftDeleted()` check in signal handlers. Soft-deleted devices cannot re-register, preventing "zombie" devices from reappearing after admin deletion — Phase 23 +37. ~~**Metrics not visible in device detail (Issue #65)**~~ ✅ ROZWIĄZANE - Added `peer_metrics` table to Go server database (SQLite + PostgreSQL), extended `handleClientHeartbeat` to parse and save CPU/memory/disk metrics, added `GET /api/peers/{id}/metrics` endpoint for Node.js console to fetch metrics from Go server — Phase 24 --- @@ -769,4 +794,4 @@ All code changes MUST include a security review as part of the implementation pr --- -*Ostatnia aktualizacja: 2026-03-18 (Go Server & Installers — API TLS Separation Fix — Phase 21) przez GitHub Copilot* +*Ostatnia aktualizacja: 2026-03-19 (Go Server — Peer Metrics Persistence — Phase 24) przez GitHub Copilot* diff --git a/betterdesk-server/api/client_api_handlers.go b/betterdesk-server/api/client_api_handlers.go index 3ce800bc..0d893824 100644 --- a/betterdesk-server/api/client_api_handlers.go +++ b/betterdesk-server/api/client_api_handlers.go @@ -441,8 +441,11 @@ func (s *Server) handleClientAddressBookTags(w http.ResponseWriter, r *http.Requ // { "modified_at": "2026-..." } (normal ACK) func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) { var body struct { - ID string `json:"id"` - UUID string `json:"uuid"` + ID string `json:"id"` + UUID string `json:"uuid"` + CPU float64 `json:"cpu"` + Memory float64 `json:"memory"` + Disk float64 `json:"disk"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeJSON(w, http.StatusOK, map[string]string{"modified_at": time.Now().UTC().Format(time.RFC3339)}) @@ -474,6 +477,13 @@ func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) { clientIP := s.remoteIP(r) _ = s.db.UpdatePeerStatus(deviceID, "ONLINE", clientIP) + // Save metrics if any values provided (values > 0) + if body.CPU > 0 || body.Memory > 0 || body.Disk > 0 { + if err := s.db.SavePeerMetric(deviceID, body.CPU, body.Memory, body.Disk); err != nil { + log.Printf("[api] Failed to save peer metrics for %s: %v", deviceID, err) + } + } + // Request sysinfo if hostname is empty (never received) if peer.Hostname == "" { writeJSON(w, http.StatusOK, map[string]any{ diff --git a/betterdesk-server/api/server.go b/betterdesk-server/api/server.go index 9562ce9c..eaf021d1 100644 --- a/betterdesk-server/api/server.go +++ b/betterdesk-server/api/server.go @@ -12,6 +12,7 @@ import ( "net/http" "regexp" "runtime" + "strconv" "strings" "sync" "time" @@ -120,6 +121,7 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("GET /api/peers", s.handleListPeers) mux.HandleFunc("GET /api/peers/{id}", s.handleGetPeer) mux.HandleFunc("DELETE /api/peers/{id}", s.requireRole(auth.RoleAdmin, s.handleDeletePeer)) + mux.HandleFunc("PATCH /api/peers/{id}", s.handleUpdatePeerFields) mux.HandleFunc("POST /api/peers/{id}/ban", s.requireRole(auth.RoleAdmin, s.handleBanPeer)) mux.HandleFunc("POST /api/peers/{id}/unban", s.requireRole(auth.RoleAdmin, s.handleUnbanPeer)) mux.HandleFunc("POST /api/peers/{id}/change-id", s.requireRole(auth.RoleAdmin, s.handleChangePeerID)) @@ -128,6 +130,7 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("GET /api/peers/status/summary", s.handleStatusSummary) mux.HandleFunc("GET /api/peers/online", s.handleOnlinePeers) mux.HandleFunc("GET /api/peers/{id}/status", s.handlePeerStatus) + mux.HandleFunc("GET /api/peers/{id}/metrics", s.handlePeerMetrics) // Blocklist management mux.HandleFunc("GET /api/blocklist", s.handleListBlocklist) @@ -406,6 +409,49 @@ func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) { }) } +// handleUpdatePeerFields partially updates a peer's editable fields (note, user, tags). +// PATCH /api/peers/{id} +func (s *Server) handleUpdatePeerFields(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + + var body struct { + Note *string `json:"note"` + User *string `json:"user"` + Tags *string `json:"tags"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON"}) + return + } + + fields := make(map[string]string) + if body.Note != nil { + fields["note"] = *body.Note + } + if body.User != nil { + fields["user"] = *body.User + } + if body.Tags != nil { + fields["tags"] = *body.Tags + } + + if len(fields) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "No fields to update"}) + return + } + + if err := s.db.UpdatePeerFields(id, fields); err != nil { + writeInternalError(w, err, "UpdatePeerFields") + return + } + + if s.auditLog != nil { + s.auditLog.Log(audit.ActionPeerUpdated, s.remoteIP(r), id, nil) + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "updated", "id": id}) +} + func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") hard := r.URL.Query().Get("hard") == "true" @@ -627,6 +673,36 @@ func (s *Server) handlePeerStatus(w http.ResponseWriter, r *http.Request) { }) } +// handlePeerMetrics returns historical metrics (CPU, memory, disk) for a peer. +// GET /api/peers/{id}/metrics?limit=100 +func (s *Server) handlePeerMetrics(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" || !peerIDRegexp.MatchString(id) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid peer ID"}) + return + } + + // Parse optional limit param (default 100, max 1000) + limit := 100 + if l := r.URL.Query().Get("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 1000 { + limit = parsed + } + } + + metrics, err := s.db.GetPeerMetrics(id, limit) + if err != nil { + writeInternalError(w, err, "GetPeerMetrics") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "peer_id": id, + "count": len(metrics), + "metrics": metrics, + }) +} + // handleListBlocklist returns all blocklist entries. // GET /api/blocklist func (s *Server) handleListBlocklist(w http.ResponseWriter, r *http.Request) { @@ -758,27 +834,53 @@ func (s *Server) remoteIP(r *http.Request) string { // handleSetPeerTags updates tags for a peer. // PUT /api/peers/{id}/tags +// Accepts either { "tags": "tag1,tag2" } (string) or { "tags": ["tag1","tag2"] } (array). func (s *Server) handleSetPeerTags(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - var body struct { - Tags string `json:"tags"` // comma-separated tags + var raw json.RawMessage + var wrapper struct { + Tags json.RawMessage `json:"tags"` } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + if err := json.NewDecoder(r.Body).Decode(&wrapper); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON"}) return } + raw = wrapper.Tags - if err := s.db.UpdatePeerTags(id, body.Tags); err != nil { + // Determine if tags is a string or an array + var tagsStr string + if len(raw) > 0 && raw[0] == '"' { + // JSON string + if err := json.Unmarshal(raw, &tagsStr); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid tags value"}) + return + } + } else if len(raw) > 0 && raw[0] == '[' { + // JSON array + var arr []string + if err := json.Unmarshal(raw, &arr); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid tags array"}) + return + } + tagsStr = strings.Join(arr, ",") + } else if len(raw) == 0 || string(raw) == "null" { + tagsStr = "" + } else { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Tags must be a string or array"}) + return + } + + if err := s.db.UpdatePeerTags(id, tagsStr); err != nil { writeInternalError(w, err, "UpdatePeerTags") return } if s.auditLog != nil { - s.auditLog.Log(audit.ActionPeerTagsUpdated, s.remoteIP(r), id, map[string]string{"tags": body.Tags}) + s.auditLog.Log(audit.ActionPeerTagsUpdated, s.remoteIP(r), id, map[string]string{"tags": tagsStr}) } - writeJSON(w, http.StatusOK, map[string]string{"status": "updated", "id": id, "tags": body.Tags}) + writeJSON(w, http.StatusOK, map[string]string{"status": "updated", "id": id, "tags": tagsStr}) } // handlePeersByTag returns peers matching a tag. diff --git a/betterdesk-server/audit/logger.go b/betterdesk-server/audit/logger.go index 44d4f2d7..a400ee3f 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" + ActionPeerUpdated Action = "peer_updated" ActionPeerIDChanged Action = "peer_id_changed" ActionPeerTagsUpdated Action = "peer_tags_updated" ActionBlocklistAdd Action = "blocklist_add" diff --git a/betterdesk-server/db/database.go b/betterdesk-server/db/database.go index 2ccdd586..69be000a 100644 --- a/betterdesk-server/db/database.go +++ b/betterdesk-server/db/database.go @@ -67,6 +67,16 @@ type IDChangeHistory struct { Reason string `json:"reason,omitempty"` } +// PeerMetric represents a single heartbeat metric data point. +type PeerMetric struct { + ID int64 `json:"id"` + PeerID string `json:"peer_id"` + CPU float64 `json:"cpu_usage"` + Memory float64 `json:"memory_usage"` + Disk float64 `json:"disk_usage"` + CreatedAt time.Time `json:"created_at"` +} + // DeviceToken represents a unique enrollment token for device registration. // Dual Key System: supports both global server key (backward compatible) and // per-device tokens for enhanced security. @@ -116,10 +126,14 @@ type Database interface { UpdatePeerSysinfo(id, hostname, os, version string) error SetAllOffline() error + // Peer field updates + UpdatePeerFields(id string, fields map[string]string) error + // Ban system BanPeer(id string, reason string) error UnbanPeer(id string) error IsPeerBanned(id string) (bool, error) + IsPeerSoftDeleted(id string) (bool, error) // ID change ChangePeerID(oldID, newID string) error @@ -167,4 +181,10 @@ type Database interface { // Address Book GetAddressBook(username, abType string) (string, error) // Returns JSON data string; abType: "legacy" or "personal" SaveAddressBook(username, abType, data string) error + + // Peer Metrics (heartbeat CPU/memory/disk) + SavePeerMetric(peerID string, cpu, memory, disk float64) error + GetPeerMetrics(peerID string, limit int) ([]*PeerMetric, error) + GetLatestPeerMetric(peerID string) (*PeerMetric, error) + CleanupOldMetrics(maxAge time.Duration) (int64, error) // Delete metrics older than maxAge } diff --git a/betterdesk-server/db/postgres.go b/betterdesk-server/db/postgres.go index 84bbf47c..38376861 100644 --- a/betterdesk-server/db/postgres.go +++ b/betterdesk-server/db/postgres.go @@ -160,6 +160,18 @@ func (pg *PostgresDB) Migrate() error { updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (username, ab_type) )`, + + // Peer metrics table (heartbeat CPU/memory/disk history) + `CREATE TABLE IF NOT EXISTS peer_metrics ( + id BIGSERIAL PRIMARY KEY, + peer_id TEXT NOT NULL, + cpu_usage REAL NOT NULL DEFAULT 0, + memory_usage REAL NOT NULL DEFAULT 0, + disk_usage REAL NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_peer_metrics_peer_id ON peer_metrics(peer_id)`, + `CREATE INDEX IF NOT EXISTS idx_peer_metrics_created_at ON peer_metrics(created_at)`, } for _, stmt := range statements { @@ -395,6 +407,44 @@ func (pg *PostgresDB) IsPeerBanned(id string) (bool, error) { return banned, err } +// IsPeerSoftDeleted checks if a peer is soft-deleted. +func (pg *PostgresDB) IsPeerSoftDeleted(id string) (bool, error) { + var deleted bool + err := pg.pool.QueryRow(pg.ctx, + `SELECT soft_deleted FROM peers WHERE id = $1`, id).Scan(&deleted) + if err == pgx.ErrNoRows { + return false, nil + } + return deleted, err +} + +// UpdatePeerFields updates specific peer fields (note, user, tags). +// Only provided keys are updated; others are left unchanged. +// Allowed keys: "note", "user", "tags". +func (pg *PostgresDB) UpdatePeerFields(id string, fields map[string]string) error { + allowed := map[string]string{"note": "note", "user": `"user"`, "tags": "tags"} + setClauses := []string{} + args := []interface{}{} + idx := 1 + for k, v := range fields { + col, ok := allowed[k] + if !ok { + continue + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, idx)) + args = append(args, v) + idx++ + } + if len(setClauses) == 0 { + return nil + } + args = append(args, id) + query := fmt.Sprintf("UPDATE peers SET %s WHERE id = $%d AND soft_deleted = FALSE", + strings.Join(setClauses, ", "), idx) + _, err := pg.pool.Exec(pg.ctx, query, args...) + return err +} + // ── ID Change ───────────────────────────────────────────────────────── // ChangePeerID changes a peer's ID and records it in history. @@ -936,6 +986,64 @@ func (pg *PostgresDB) SaveAddressBook(username, abType, data string) error { return err } +// ── Peer Metrics ────────────────────────────────────────────────────── + +// SavePeerMetric inserts a new metric record for a peer. +func (pg *PostgresDB) SavePeerMetric(peerID string, cpu, memory, disk float64) error { + _, err := pg.pool.Exec(pg.ctx, + `INSERT INTO peer_metrics (peer_id, cpu_usage, memory_usage, disk_usage) VALUES ($1, $2, $3, $4)`, + peerID, cpu, memory, disk) + return err +} + +// GetPeerMetrics retrieves the most recent N metric records for a peer. +func (pg *PostgresDB) GetPeerMetrics(peerID string, limit int) ([]*PeerMetric, error) { + if limit <= 0 { + limit = 100 + } + rows, err := pg.pool.Query(pg.ctx, + `SELECT id, peer_id, cpu_usage, memory_usage, disk_usage, created_at + FROM peer_metrics WHERE peer_id = $1 ORDER BY created_at DESC LIMIT $2`, + peerID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var metrics []*PeerMetric + for rows.Next() { + m := &PeerMetric{} + if err := rows.Scan(&m.ID, &m.PeerID, &m.CPU, &m.Memory, &m.Disk, &m.CreatedAt); err != nil { + return nil, err + } + metrics = append(metrics, m) + } + return metrics, rows.Err() +} + +// GetLatestPeerMetric returns the single most recent metric for a peer. +func (pg *PostgresDB) GetLatestPeerMetric(peerID string) (*PeerMetric, error) { + metrics, err := pg.GetPeerMetrics(peerID, 1) + if err != nil { + return nil, err + } + if len(metrics) == 0 { + return nil, nil + } + return metrics[0], nil +} + +// CleanupOldMetrics deletes metrics older than maxAge. Returns deleted count. +func (pg *PostgresDB) CleanupOldMetrics(maxAge time.Duration) (int64, error) { + cutoff := time.Now().Add(-maxAge) + result, err := pg.pool.Exec(pg.ctx, + `DELETE FROM peer_metrics WHERE created_at < $1`, cutoff) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + // ── LISTEN/NOTIFY ───────────────────────────────────────────────────── // OnNotify registers a callback for PostgreSQL LISTEN/NOTIFY events. diff --git a/betterdesk-server/db/sqlite.go b/betterdesk-server/db/sqlite.go index 2858e1b3..6a6a898b 100644 --- a/betterdesk-server/db/sqlite.go +++ b/betterdesk-server/db/sqlite.go @@ -142,6 +142,18 @@ func (s *SQLiteDB) Migrate() error { updated_at TEXT DEFAULT (datetime('now')), PRIMARY KEY (username, ab_type) )`, + + // Peer metrics table (heartbeat CPU/memory/disk data) + `CREATE TABLE IF NOT EXISTS peer_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + peer_id TEXT NOT NULL, + cpu_usage REAL DEFAULT 0, + memory_usage REAL DEFAULT 0, + disk_usage REAL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + )`, + `CREATE INDEX IF NOT EXISTS idx_peer_metrics_peer ON peer_metrics(peer_id)`, + `CREATE INDEX IF NOT EXISTS idx_peer_metrics_created ON peer_metrics(created_at)`, } for _, stmt := range statements { @@ -468,6 +480,45 @@ func (s *SQLiteDB) IsPeerBanned(id string) (bool, error) { return banned, err } +// IsPeerSoftDeleted checks if a peer is soft-deleted. +func (s *SQLiteDB) IsPeerSoftDeleted(id string) (bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var deleted bool + err := s.db.QueryRow(`SELECT soft_deleted FROM peers WHERE id = ?`, id).Scan(&deleted) + if err == sql.ErrNoRows { + return false, nil + } + return deleted, err +} + +// UpdatePeerFields updates specific peer fields (note, user, tags). +// Only provided keys are updated; others are left unchanged. +// Allowed keys: "note", "user", "tags". +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} + setClauses := []string{} + args := []interface{}{} + for k, v := range fields { + if !allowed[k] { + continue + } + setClauses = append(setClauses, k+" = ?") + args = append(args, v) + } + if len(setClauses) == 0 { + return nil + } + args = append(args, id) + query := "UPDATE peers SET " + strings.Join(setClauses, ", ") + " WHERE id = ? AND soft_deleted = 0" + _, err := s.db.Exec(query, args...) + return err +} + // ChangePeerID changes a peer's ID and records it in history. func (s *SQLiteDB) ChangePeerID(oldID, newID string) error { s.mu.Lock() @@ -1109,3 +1160,71 @@ func (s *SQLiteDB) SaveAddressBook(username, abType, data string) error { username, abType, data) return err } + +// ── Peer Metrics ────────────────────────────────────────────────────── + +// SavePeerMetric inserts a new heartbeat metric data point for a peer. +func (s *SQLiteDB) SavePeerMetric(peerID string, cpu, memory, disk float64) error { + s.mu.Lock() + defer s.mu.Unlock() + + _, err := s.db.Exec( + `INSERT INTO peer_metrics (peer_id, cpu_usage, memory_usage, disk_usage, created_at) + VALUES (?, ?, ?, ?, datetime('now'))`, + peerID, cpu, memory, disk) + return err +} + +// GetPeerMetrics returns the most recent metric data points for a peer. +func (s *SQLiteDB) GetPeerMetrics(peerID string, limit int) ([]*PeerMetric, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + rows, err := s.db.Query( + `SELECT id, peer_id, cpu_usage, memory_usage, disk_usage, created_at + FROM peer_metrics WHERE peer_id = ? ORDER BY created_at DESC LIMIT ?`, + peerID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var metrics []*PeerMetric + for rows.Next() { + var m PeerMetric + var createdAt string + if err := rows.Scan(&m.ID, &m.PeerID, &m.CPU, &m.Memory, &m.Disk, &createdAt); err != nil { + return nil, err + } + m.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + metrics = append(metrics, &m) + } + return metrics, nil +} + +// GetLatestPeerMetric returns the most recent metric for a peer. +func (s *SQLiteDB) GetLatestPeerMetric(peerID string) (*PeerMetric, error) { + metrics, err := s.GetPeerMetrics(peerID, 1) + if err != nil { + return nil, err + } + if len(metrics) == 0 { + return nil, nil + } + return metrics[0], nil +} + +// CleanupOldMetrics deletes metric records older than maxAge. +// Returns the number of deleted rows. +func (s *SQLiteDB) CleanupOldMetrics(maxAge time.Duration) (int64, error) { + s.mu.Lock() + defer s.mu.Unlock() + + cutoff := time.Now().Add(-maxAge).UTC().Format("2006-01-02 15:04:05") + result, err := s.db.Exec( + `DELETE FROM peer_metrics WHERE created_at < ?`, cutoff) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/betterdesk-server/signal/handler.go b/betterdesk-server/signal/handler.go index 565d2164..3452d650 100644 --- a/betterdesk-server/signal/handler.go +++ b/betterdesk-server/signal/handler.go @@ -185,6 +185,12 @@ func (s *Server) handleRegisterPeer(msg *pb.RegisterPeer, raddr *net.UDPAddr) { return } + // Check if this peer was soft-deleted — do not allow re-registration + if deleted, _ := s.db.IsPeerSoftDeleted(id); deleted { + log.Printf("[signal] Rejected soft-deleted peer registration: %s from %s", id, raddr.IP) + return + } + // New peer — add to memory map // Try to load existing PK from database first (peer may have registered PK before server restart) now := time.Now() @@ -286,6 +292,12 @@ func (s *Server) processRegisterPk(msg *pb.RegisterPk, addrStr string) *pb.Rende return registerPkResponse(pb.RegisterPkResponse_NOT_SUPPORT) } + // Check soft-deleted status — do not allow re-registration + if deleted, _ := s.db.IsPeerSoftDeleted(id); deleted { + log.Printf("[signal] Rejected soft-deleted peer PK registration: %s", id) + return registerPkResponse(pb.RegisterPkResponse_NOT_SUPPORT) + } + // Get or create peer entry in memory entry := s.peers.Get(id) if entry == nil { @@ -594,6 +606,8 @@ func (s *Server) handlePunchHoleRequestTCP(msg *pb.PunchHoleRequest, raddr *net. }, } if target.UDPAddr != nil { + // Store the UUID so we can recover it if target responds with empty UUID. + s.storePendingUUID(targetID, relayUUID) s.sendUDP(reqRelay, target.UDPAddr) log.Printf("[signal] PunchHole (TCP): forwarded RequestRelay to target %s (uuid=%s)", targetID, relayUUID[:8]) } @@ -860,6 +874,8 @@ func (s *Server) handleRequestRelay(msg *pb.RequestRelay, raddr *net.UDPAddr) { } if target.UDPAddr != nil { + // Store the UUID so we can recover it if target responds with empty UUID. + s.storePendingUUID(targetID, relayUUID) s.sendUDP(relayResp, target.UDPAddr) } @@ -962,6 +978,8 @@ func (s *Server) handleRequestRelayTCP(msg *pb.RequestRelay, raddr *net.UDPAddr) }, }, } + // Store the UUID so we can recover it if target responds with empty UUID. + s.storePendingUUID(targetID, relayUUID) s.sendUDP(reqRelay, target.UDPAddr) log.Printf("[signal] RequestRelay (TCP): forwarded to %s secure=%v connType=%v", targetID, msg.Secure, msg.ConnType) } @@ -1012,15 +1030,6 @@ func (s *Server) handleRelayResponseForward(msg *pb.RendezvousMessage, senderAdd return } - // If the target sent a RelayResponse with an empty UUID, both peers will fail - // to connect through relay. Generate a UUID as a last resort — the target may - // have already connected to relay with "" which won't pair, but at least this - // gives useful diagnostics and prevents silent failures. - if rr.Uuid == "" { - rr.Uuid = uuid.New().String() - log.Printf("[signal] WARNING: RelayResponse from %s has empty UUID — generated %s (target may have connected with empty UUID, relay pairing may fail)", senderAddr, rr.Uuid[:8]) - } - initiatorAddr, err := crypto.DecodeAddr(rr.SocketAddr) if err != nil { log.Printf("[signal] RelayResponse forward: cannot decode socket_addr: %v", err) @@ -1041,6 +1050,22 @@ func (s *Server) handleRelayResponseForward(msg *pb.RendezvousMessage, senderAdd } } + // If the target sent a RelayResponse with an empty UUID, try to recover the + // original UUID that we sent to the target in RequestRelay/PunchHole. This + // is critical for relay pairing — the target may have connected to relay with + // that UUID, but the old RustDesk client doesn't echo it back. + if rr.Uuid == "" { + if storedUUID := s.getPendingUUID(targetID); storedUUID != "" { + rr.Uuid = storedUUID + log.Printf("[signal] RelayResponse from %s has empty UUID — recovered original %s from pending store", senderAddr, storedUUID[:8]) + } else { + // Last resort: generate a new UUID. This will likely fail relay pairing + // because target already connected with different (empty?) UUID. + rr.Uuid = uuid.New().String() + log.Printf("[signal] WARNING: RelayResponse from %s has empty UUID and no pending UUID found — generated %s (relay pairing may fail)", senderAddr, rr.Uuid[:8]) + } + } + var signedPk []byte if targetID != "" { if target := s.peers.Get(targetID); target != nil && len(target.PK) > 0 { @@ -1253,6 +1278,8 @@ func (s *Server) sendRelayResponse(target *peer.Entry, raddr *net.UDPAddr, msg * }, } if target.UDPAddr != nil { + // Store the UUID so we can recover it if target responds with empty UUID. + s.storePendingUUID(target.ID, relayUUID) s.sendUDP(reqRelay, target.UDPAddr) log.Printf("[signal] sendRelayResponse: forwarded RequestRelay to target %s at %s (uuid=%s)", target.ID, target.UDPAddr, relayUUID[:8]) } diff --git a/betterdesk-server/signal/server.go b/betterdesk-server/signal/server.go index 0f086d37..7e2b3811 100644 --- a/betterdesk-server/signal/server.go +++ b/betterdesk-server/signal/server.go @@ -38,6 +38,15 @@ type tcpPunchConn struct { createdAt time.Time // M2: track creation time for TTL eviction } +// pendingUUID tracks a relay UUID that was sent to a target device. +// Some RustDesk clients don't echo the UUID back in RelayResponse, causing +// relay pairing to fail. We store the UUID so we can recover it when the +// target responds with an empty UUID. +type pendingUUID struct { + uuid string + createdAt time.Time +} + // writeProto sends a protobuf message, using encryption if the connection is secure. func (pc *tcpPunchConn) writeProto(msg *pb.RendezvousMessage) error { pc.writeMu.Lock() @@ -71,6 +80,12 @@ type Server struct { // initiator's addr key and forward the message over their TCP connection. tcpPunchConns sync.Map // map[string]*tcpPunchConn + // pendingRelayUUIDs tracks the UUID we send to each target when forwarding + // RequestRelay or PunchHole (force-relay). Some RustDesk clients respond with + // an empty UUID in RelayResponse — this map lets us recover the original UUID + // so relay pairing succeeds. Key=targetID, Value=*pendingUUID. + pendingRelayUUIDs sync.Map // map[string]*pendingUUID + // localIP is the server's detected public IP address (via external service). // Used to build the relay server address when -relay-servers is not set. localIP atomic.Value // stores string @@ -755,6 +770,20 @@ func (s *Server) cleanupTCPPunchConns() { if evicted > 0 { log.Printf("[signal] TCP punch conns cleanup: evicted %d stale entries (remaining ~%d)", evicted, count-evicted) } + + // Also cleanup stale pendingRelayUUIDs (same TTL as punch conns) + uuidEvicted := 0 + s.pendingRelayUUIDs.Range(func(key, value any) bool { + pu := value.(*pendingUUID) + if now.Sub(pu.createdAt) > maxTTL { + s.pendingRelayUUIDs.Delete(key) + uuidEvicted++ + } + return true + }) + if uuidEvicted > 0 { + log.Printf("[signal] Pending relay UUIDs cleanup: evicted %d stale entries", uuidEvicted) + } } } } @@ -771,6 +800,26 @@ func (s *Server) sendUDP(msg *pb.RendezvousMessage, addr *net.UDPAddr) { } } +// storePendingUUID stores a relay UUID that we sent/are sending to a target. +// When the target responds with RelayResponse containing empty UUID, we can +// look up this stored UUID to maintain relay pairing. +func (s *Server) storePendingUUID(targetID, uuid string) { + s.pendingRelayUUIDs.Store(targetID, &pendingUUID{ + uuid: uuid, + createdAt: time.Now(), + }) +} + +// getPendingUUID retrieves the pending UUID for a target device (without removing it). +// The UUID remains available for subsequent retry attempts; cleanup happens via ticker. +// Returns empty string if no pending UUID exists for this target. +func (s *Server) getPendingUUID(targetID string) string { + if val, ok := s.pendingRelayUUIDs.Load(targetID); ok { + return val.(*pendingUUID).uuid + } + return "" +} + // isNormalClose returns true if the error represents a normal connection close // (EOF, timeout, or connection reset by peer). These are expected during // TCP connection lifecycle and should not be logged as errors. diff --git a/web-nodejs/routes/devices.routes.js b/web-nodejs/routes/devices.routes.js index 32324bb5..fd655ac4 100644 --- a/web-nodejs/routes/devices.routes.js +++ b/web-nodejs/routes/devices.routes.js @@ -195,6 +195,11 @@ router.delete('/api/devices/:id', requireAuth, requireRole('operator'), async (r }); } + // Clean up local auth.db data for this peer + try { + await db.cleanupDeletedPeerData(id); + } catch { /* non-critical: auth.db cleanup is secondary */ } + // Log action await db.logAction(req.session.userId, 'device_deleted', `Device ${id} deleted`, req.ip); diff --git a/web-nodejs/services/betterdeskApi.js b/web-nodejs/services/betterdeskApi.js index 3ce8a981..f80d8b5f 100644 --- a/web-nodejs/services/betterdeskApi.js +++ b/web-nodejs/services/betterdeskApi.js @@ -252,7 +252,22 @@ async function removeBlocklistEntry(entry) { */ async function setPeerTags(id, tags) { try { - const { data } = await apiClient.put(`/peers/${encodeURIComponent(id)}/tags`, { tags }); + // Ensure tags is sent as an array (Go server now accepts both string and array) + const payload = Array.isArray(tags) ? tags : (typeof tags === 'string' ? tags.split(',').map(t => t.trim()).filter(Boolean) : []); + const { data } = await apiClient.put(`/peers/${encodeURIComponent(id)}/tags`, { tags: payload }); + return wrap(data); + } catch (err) { + if (err.response?.data) return wrap(err.response.data); + return { success: false, error: err.message }; + } +} + +/** + * PATCH /api/peers/:id - Update peer fields (note, user, tags) + */ +async function updatePeer(id, fields) { + try { + const { data } = await apiClient.patch(`/peers/${encodeURIComponent(id)}`, fields); return wrap(data); } catch (err) { if (err.response?.data) return wrap(err.response.data); @@ -423,6 +438,8 @@ module.exports = { // Tags setPeerTags, getPeersByTag, + // Peer update + updatePeer, // Audit getAuditEvents, // Config diff --git a/web-nodejs/services/dbAdapter.js b/web-nodejs/services/dbAdapter.js index 5344d218..1c7b813e 100644 --- a/web-nodejs/services/dbAdapter.js +++ b/web-nodejs/services/dbAdapter.js @@ -963,6 +963,14 @@ function createSqliteAdapter(config) { openMain().prepare('UPDATE peer SET is_deleted = 1 WHERE id = ?').run(id); }, + async cleanupDeletedPeerData(id) { + const authDb = openAuth(); + authDb.prepare('DELETE FROM peer_sysinfo WHERE peer_id = ?').run(id); + authDb.prepare('DELETE FROM peer_metrics WHERE peer_id = ?').run(id); + authDb.prepare('DELETE FROM device_folder_assignments WHERE peer_id = ?').run(id); + authDb.prepare('DELETE FROM device_group_peers WHERE peer_id = ?').run(id); + }, + async setBanStatus(id, banned, reason = '') { openMain().prepare(` UPDATE peer SET is_banned = ?, banned_at = CASE WHEN ? THEN datetime('now') ELSE NULL END, banned_reason = ? @@ -3214,6 +3222,13 @@ function createPostgresAdapter() { await q('UPDATE peer SET is_deleted = TRUE WHERE id = $1', [id]); }, + async cleanupDeletedPeerData(id) { + await q('DELETE FROM peer_sysinfo WHERE peer_id = $1', [id]); + await q('DELETE FROM peer_metrics WHERE peer_id = $1', [id]); + await q('DELETE FROM device_folder_assignments WHERE peer_id = $1', [id]); + await q('DELETE FROM device_group_peers WHERE peer_id = $1', [id]); + }, + async setBanStatus(id, banned, reason = '') { await q(` UPDATE peer SET is_banned = $1, banned_at = CASE WHEN $1 THEN NOW() ELSE NULL END, banned_reason = $2 diff --git a/web-nodejs/services/serverBackend.js b/web-nodejs/services/serverBackend.js index c27f34a5..8391e19c 100644 --- a/web-nodejs/services/serverBackend.js +++ b/web-nodejs/services/serverBackend.js @@ -164,9 +164,24 @@ async function setBanStatus(id, banned, reason = '') { } async function updateDevice(id, data) { - // BetterDesk Go server does not expose a peer-update endpoint for user/note, - // so we keep writing to the local SQLite in both modes for now. - return await db.updateDevice(id, data); + // Route through Go API PATCH /api/peers/:id for note/user fields + const fields = {}; + if (data.note !== undefined) fields.note = String(data.note); + if (data.user !== undefined) fields.user = String(data.user); + + if (Object.keys(fields).length > 0) { + const result = await betterdeskApi.updatePeer(id, fields); + if (!result || !result.success) { + return { changes: 0, error: result?.error || 'Failed to update peer' }; + } + } + + // Also update local auth.db as fallback for overlaid fields + try { + await db.updateDevice(id, data); + } catch { /* non-critical: auth.db is secondary storage */ } + + return { changes: 1 }; } async function changePeerId(oldId, newId) {