Add peer metrics, PATCH updates & relay fixes

Introduce peer metrics persistence and partial peer updates, plus relay UUID recovery and soft-delete protections.

- DB: add PeerMetric type and new Database methods (SavePeerMetric, GetPeerMetrics, GetLatestPeerMetric, CleanupOldMetrics, UpdatePeerFields, IsPeerSoftDeleted). Add peer_metrics table and indexes in SQLite and PostgreSQL migrations; implement all methods for both backends.
- API: handleClientHeartbeat now parses cpu/memory/disk and saves metrics; new endpoints PATCH /api/peers/{id} to update note/user/tags and GET /api/peers/{id}/metrics for historical metrics. handleSetPeerTags now accepts either string or array JSON payloads. Added audit.ActionPeerUpdated.
- Signal server: add pendingRelayUUIDs store (with TTL cleanup, store/get helpers) to recover missing UUIDs from old clients when RelayResponse contains empty uuid; store pending UUIDs when forwarding RequestRelay/PunchHole. Add IsPeerSoftDeleted checks to reject re-registration of soft-deleted devices.
- Node.js panel: betterdeskApi.setPeerTags now sends tags as array and exposes updatePeer() for PATCH; serverBackend.updateDevice routes note/user updates through Go PATCH endpoint and preserves local auth.db writes as fallback; devices.routes deletes now call cleanupDeletedPeerData.
- dbAdapter: add cleanupDeletedPeerData implementations for SQLite and Postgres to remove related auth.db rows when a peer is deleted.

These changes fix zombie device re-registration, ensure metrics are stored & retrievable, centralize peer metadata updates through the Go API, and recover relay pairing failures with legacy clients.

Co-Authored-By: boruto79 <176351662+boruto79@users.noreply.github.com>
Co-Authored-By: БлагоЯр <3672314+blagoyar@users.noreply.github.com>
This commit is contained in:
UNITRONIX
2026-03-18 22:09:48 +01:00
parent c461a1aa13
commit 199a321fe5
13 changed files with 535 additions and 22 deletions
+26 -1
View File
@@ -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*
+12 -2
View File
@@ -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{
+108 -6
View File
@@ -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.
+1
View File
@@ -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"
+20
View File
@@ -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
}
+108
View File
@@ -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.
+119
View File
@@ -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()
}
+36 -9
View File
@@ -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])
}
+49
View File
@@ -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.
+5
View File
@@ -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);
+18 -1
View File
@@ -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
+15
View File
@@ -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
+18 -3
View File
@@ -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) {