mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
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:
@@ -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*
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user