From ea88c58a9ea76d21eb46dfd6a960e6cb33732c65 Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Sat, 30 May 2026 18:52:42 +0200 Subject: [PATCH] feat(server): add RustDesk-client API endpoints to Go server (Phase A) Consolidate the RustDesk-client API surface onto the Go server (port 21114) as an additive, fully backward-compatible first step. DB layer: add AuditConnection/AuditFile/AuditAlarm, UserGroup, DeviceGroup and Strategy models with 30 Database interface methods. Both SQLite and PostgreSQL gain the 6 new tables via additive CREATE TABLE IF NOT EXISTS migrations (no existing schema is altered). 7 round-trip + backward-compat migration tests pass. API layer: new client_audit_handlers.go (audit conn/file/alarm POST public + GET behind audit.view, server-key, server-key/fingerprint, peer-key/{id}) and client_group_handlers.go (user-groups, device-group, strategies GET/POST). Routes registered in server.go; authMiddleware allowlist exempts the public client-reporting POSTs and server-key reads in a method-aware way so audit GETs stay protected. 4 handler tests pass. No existing routes, schemas or the Node.js port 21121 surface are changed; existing instances are unaffected. This commit was made possible thanks to Insolve. --- betterdesk-server/api/auth_handlers.go | 4 + .../api/client_audit_handlers.go | 374 +++++++++++++ .../api/client_audit_handlers_test.go | 180 +++++++ .../api/client_group_handlers.go | 170 ++++++ betterdesk-server/api/server.go | 28 +- betterdesk-server/db/audit_groups_postgres.go | 430 +++++++++++++++ betterdesk-server/db/audit_groups_sqlite.go | 494 ++++++++++++++++++ betterdesk-server/db/audit_groups_test.go | 243 +++++++++ betterdesk-server/db/database.go | 146 +++++- betterdesk-server/db/postgres.go | 76 +++ betterdesk-server/db/sqlite.go | 76 +++ 11 files changed, 2209 insertions(+), 12 deletions(-) create mode 100644 betterdesk-server/api/client_audit_handlers.go create mode 100644 betterdesk-server/api/client_audit_handlers_test.go create mode 100644 betterdesk-server/api/client_group_handlers.go create mode 100644 betterdesk-server/db/audit_groups_postgres.go create mode 100644 betterdesk-server/db/audit_groups_sqlite.go create mode 100644 betterdesk-server/db/audit_groups_test.go diff --git a/betterdesk-server/api/auth_handlers.go b/betterdesk-server/api/auth_handlers.go index ca918922..e5239e60 100644 --- a/betterdesk-server/api/auth_handlers.go +++ b/betterdesk-server/api/auth_handlers.go @@ -1172,6 +1172,10 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { path == "/api/server/pubkey" || path == "/api/server/stats" || path == "/api/login" || path == "/api/login-options" || path == "/api/logout" || path == "/api/heartbeat" || path == "/api/sysinfo" || path == "/api/sysinfo_ver" || + path == "/api/audit/conn" && r.Method == http.MethodPost || + path == "/api/audit/file" && r.Method == http.MethodPost || + path == "/api/audit/alarm" && r.Method == http.MethodPost || + path == "/api/server-key" || path == "/api/server-key/fingerprint" || path == "/api/branding" || path == "/api/org/login" || path == "/api/auth/oidc/status" || path == "/api/auth/oidc/authorize" || path == "/api/auth/oidc/callback" || diff --git a/betterdesk-server/api/client_audit_handlers.go b/betterdesk-server/api/client_audit_handlers.go new file mode 100644 index 00000000..f2a27cc2 --- /dev/null +++ b/betterdesk-server/api/client_audit_handlers.go @@ -0,0 +1,374 @@ +package api + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/unitronix/betterdesk-server/db" +) + +// Validation limits and value sets mirror the Node.js RustDesk client API +// (web-nodejs/routes/rustdesk-api.routes.js) for behavioural parity. +const ( + maxIDLen = 32 + maxHostnameLen = 256 + maxFilesPerOp = 100 +) + +var ( + connTypes = map[int]bool{0: true, 1: true, 2: true, 3: true, 4: true} + alarmTypes = map[int]bool{0: true, 1: true, 2: true, 3: true, 4: true, 5: true, 6: true} +) + +// truncStr trims a string to a maximum length (rune-safe enough for ASCII IDs). +func truncStr(s string, max int) string { + if len(s) > max { + return s[:max] + } + return s +} + +// coerceStr converts arbitrary JSON scalars (string or number) to a string, +// matching the Node.js `String(value)` coercion used for host_id/peer_id. +func coerceStr(v any) string { + switch t := v.(type) { + case nil: + return "" + case string: + return t + case float64: + // JSON numbers decode to float64; render without trailing ".0" for ints. + if t == float64(int64(t)) { + return strconv.FormatInt(int64(t), 10) + } + return strconv.FormatFloat(t, 'f', -1, 64) + case bool: + return strconv.FormatBool(t) + default: + return fmt.Sprintf("%v", t) + } +} + +// queryLimitOffset parses limit/offset query params with sane defaults. +func queryLimitOffset(r *http.Request) (int, int) { + limit := 100 + offset := 0 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + limit = n + } + } + if v := r.URL.Query().Get("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + offset = n + } + } + return limit, offset +} + +// ── Audit: Connections ──────────────────────────────────────────────── + +// handleAuditConnPost records a connection event reported by a RustDesk client. +// Public endpoint (no auth) — matches the Node.js behaviour. +func (s *Server) handleAuditConnPost(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + hostID := truncStr(coerceStr(body["host_id"]), maxIDLen) + if hostID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "host_id is required"}) + return + } + connType := 0 + if v, ok := body["conn_type"].(float64); ok { + connType = int(v) + } + if !connTypes[connType] { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid conn_type"}) + return + } + ip := truncStr(coerceStr(body["ip"]), 64) + if ip == "" { + ip = s.remoteIP(r) + } + rec := &db.AuditConnection{ + HostID: hostID, + HostUUID: truncStr(coerceStr(body["host_uuid"]), maxIDLen), + PeerID: truncStr(coerceStr(body["peer_id"]), maxIDLen), + PeerName: truncStr(coerceStr(body["peer_name"]), maxHostnameLen), + Action: truncStr(defaultStr(coerceStr(body["action"]), "connect"), 32), + ConnType: connType, + SessionID: truncStr(coerceStr(body["session_id"]), 64), + IP: ip, + } + if err := s.db.InsertAuditConnection(rec); err != nil { + writeInternalError(w, err, "InsertAuditConnection") + return + } + writeJSON(w, http.StatusOK, map[string]any{}) +} + +// handleAuditConnGet returns connection audit records. Auth enforced by route wrapper. +func (s *Server) handleAuditConnGet(w http.ResponseWriter, r *http.Request) { + limit, offset := queryLimitOffset(r) + f := db.AuditFilter{ + HostID: r.URL.Query().Get("host_id"), + PeerID: r.URL.Query().Get("peer_id"), + Action: r.URL.Query().Get("action"), + Limit: limit, + Offset: offset, + } + rows, err := s.db.ListAuditConnections(f) + if err != nil { + writeInternalError(w, err, "ListAuditConnections") + return + } + total, err := s.db.CountAuditConnections(f) + if err != nil { + writeInternalError(w, err, "CountAuditConnections") + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": emptyIfNilConn(rows), "total": total}) +} + +// ── Audit: File Transfers ───────────────────────────────────────────── + +// handleAuditFilePost records a file-transfer event. Public endpoint. +func (s *Server) handleAuditFilePost(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + hostID := truncStr(coerceStr(body["host_id"]), maxIDLen) + if hostID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "host_id is required"}) + return + } + direction := 0 + if v, ok := body["direction"].(float64); ok { + direction = int(v) + } + if direction != 0 && direction != 1 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid direction"}) + return + } + isFile := 1 + if v, ok := body["is_file"].(float64); ok { + isFile = int(v) + } + numFiles := 0 + if v, ok := body["num_files"].(float64); ok { + numFiles = int(v) + } + if numFiles < 0 || numFiles > 10000 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid num_files"}) + return + } + filesJSON := "[]" + if arr, ok := body["files"].([]any); ok { + if len(arr) > maxFilesPerOp { + arr = arr[:maxFilesPerOp] + } + if b, err := json.Marshal(arr); err == nil { + filesJSON = string(b) + } + } + ip := truncStr(coerceStr(body["ip"]), 64) + if ip == "" { + ip = s.remoteIP(r) + } + rec := &db.AuditFile{ + HostID: hostID, + HostUUID: truncStr(coerceStr(body["host_uuid"]), maxIDLen), + PeerID: truncStr(coerceStr(body["peer_id"]), maxIDLen), + Direction: direction, + Path: truncStr(coerceStr(body["path"]), 1024), + IsFile: isFile, + NumFiles: numFiles, + FilesJSON: filesJSON, + IP: ip, + PeerName: truncStr(coerceStr(body["peer_name"]), maxHostnameLen), + } + if err := s.db.InsertAuditFile(rec); err != nil { + writeInternalError(w, err, "InsertAuditFile") + return + } + writeJSON(w, http.StatusOK, map[string]any{}) +} + +// handleAuditFileGet returns file-transfer audit records. Auth enforced by route wrapper. +func (s *Server) handleAuditFileGet(w http.ResponseWriter, r *http.Request) { + limit, offset := queryLimitOffset(r) + f := db.AuditFilter{ + HostID: r.URL.Query().Get("host_id"), + PeerID: r.URL.Query().Get("peer_id"), + Limit: limit, + Offset: offset, + } + rows, err := s.db.ListAuditFiles(f) + if err != nil { + writeInternalError(w, err, "ListAuditFiles") + return + } + total, err := s.db.CountAuditFiles(f) + if err != nil { + writeInternalError(w, err, "CountAuditFiles") + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": emptyIfNilFile(rows), "total": total}) +} + +// ── Audit: Security Alarms ──────────────────────────────────────────── + +// handleAuditAlarmPost records a security alarm. Public endpoint. +func (s *Server) handleAuditAlarmPost(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + alarmType := 0 + if v, ok := body["alarm_type"].(float64); ok { + alarmType = int(v) + } + if !alarmTypes[alarmType] { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid alarm_type"}) + return + } + details := "{}" + if raw, ok := body["details"]; ok && raw != nil { + switch t := raw.(type) { + case string: + if t != "" { + details = t + } + default: + if b, err := json.Marshal(t); err == nil { + details = string(b) + } + } + } + ip := truncStr(coerceStr(body["ip"]), 64) + if ip == "" { + ip = s.remoteIP(r) + } + rec := &db.AuditAlarm{ + AlarmType: alarmType, + AlarmName: truncStr(coerceStr(body["alarm_name"]), 64), + HostID: truncStr(coerceStr(body["host_id"]), maxIDLen), + PeerID: truncStr(coerceStr(body["peer_id"]), maxIDLen), + IP: ip, + Details: truncStr(details, 4096), + } + if err := s.db.InsertAuditAlarm(rec); err != nil { + writeInternalError(w, err, "InsertAuditAlarm") + return + } + writeJSON(w, http.StatusOK, map[string]any{}) +} + +// handleAuditAlarmGet returns alarm audit records. Auth enforced by route wrapper. +func (s *Server) handleAuditAlarmGet(w http.ResponseWriter, r *http.Request) { + limit, offset := queryLimitOffset(r) + f := db.AuditFilter{ + HostID: r.URL.Query().Get("host_id"), + Limit: limit, + Offset: offset, + } + if v := r.URL.Query().Get("alarm_type"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + f.AlarmType = &n + } + } + rows, err := s.db.ListAuditAlarms(f) + if err != nil { + writeInternalError(w, err, "ListAuditAlarms") + return + } + total, err := s.db.CountAuditAlarms(f) + if err != nil { + writeInternalError(w, err, "CountAuditAlarms") + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": emptyIfNilAlarm(rows), "total": total}) +} + +// ── Server / Peer Keys ──────────────────────────────────────────────── + +// handleServerKey returns the Rendezvous Server Ed25519 public key (base64). +// Public — the key is safe to expose and clients use it to verify peer identity. +func (s *Server) handleServerKey(w http.ResponseWriter, r *http.Request) { + key := "" + if s.keyPair != nil && len(s.keyPair.PublicKey) == 32 { + key = s.keyPair.PublicKeyBase64() + } + writeJSON(w, http.StatusOK, map[string]string{"key": key}) +} + +// handleServerKeyFingerprint returns the SHA-256 fingerprint of the RS public key. +func (s *Server) handleServerKeyFingerprint(w http.ResponseWriter, r *http.Request) { + resp := map[string]string{"fingerprint": "", "algorithm": "SHA-256"} + if s.keyPair != nil && len(s.keyPair.PublicKey) == 32 { + sum := sha256.Sum256(s.keyPair.PublicKey) + parts := make([]string, len(sum)) + for i, b := range sum { + parts[i] = fmt.Sprintf("%02X", b) + } + resp["fingerprint"] = strings.Join(parts, ":") + } + writeJSON(w, http.StatusOK, resp) +} + +// handlePeerKey returns a peer's public key (base64). Auth enforced by route wrapper. +func (s *Server) handlePeerKey(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + resp := map[string]string{"id": id, "pk": ""} + peer, err := s.db.GetPeer(id) + if err != nil { + writeInternalError(w, err, "GetPeer") + return + } + if peer != nil && len(peer.PK) > 0 { + resp["pk"] = base64.StdEncoding.EncodeToString(peer.PK) + } + writeJSON(w, http.StatusOK, resp) +} + +// ── Helpers for empty-slice JSON ([] instead of null) ───────────────── + +func emptyIfNilConn(v []*db.AuditConnection) []*db.AuditConnection { + if v == nil { + return []*db.AuditConnection{} + } + return v +} + +func emptyIfNilFile(v []*db.AuditFile) []*db.AuditFile { + if v == nil { + return []*db.AuditFile{} + } + return v +} + +func emptyIfNilAlarm(v []*db.AuditAlarm) []*db.AuditAlarm { + if v == nil { + return []*db.AuditAlarm{} + } + return v +} + +// defaultStr returns def when s is empty. +func defaultStr(s, def string) string { + if s == "" { + return def + } + return s +} diff --git a/betterdesk-server/api/client_audit_handlers_test.go b/betterdesk-server/api/client_audit_handlers_test.go new file mode 100644 index 00000000..82109157 --- /dev/null +++ b/betterdesk-server/api/client_audit_handlers_test.go @@ -0,0 +1,180 @@ +package api + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/unitronix/betterdesk-server/config" + cryptopkg "github.com/unitronix/betterdesk-server/crypto" + "github.com/unitronix/betterdesk-server/peer" +) + +// startTestServer spins up a Server on the given port with a generated keypair. +func startTestServer(t *testing.T, port int) (*config.Config, func()) { + t.Helper() + cfg := config.DefaultConfig() + cfg.APIPort = port + database := testSetupDB(t) + kp, err := cryptopkg.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + srv := New(cfg, database, peer.NewMap(), nil, "1.0.0-test") + srv.SetKeyPair(kp) + if err := srv.Start(t.Context()); err != nil { + t.Fatal(err) + } + time.Sleep(100 * time.Millisecond) + return cfg, func() { + srv.Stop() + database.Close() + } +} + +func TestAuditConnEndpoint(t *testing.T) { + cfg, cleanup := startTestServer(t, 19901) + defer cleanup() + base := fmt.Sprintf("http://127.0.0.1:%d", cfg.APIPort) + + // POST is public (no auth) — client reports a connection event. + payload := map[string]any{ + "host_id": 1340238749, // numeric host_id (RustDesk client behaviour) + "peer_id": "PEER01", + "peer_name": "workstation", + "action": "connect", + "conn_type": 0, + } + body, _ := json.Marshal(payload) + resp, err := http.Post(base+"/api/audit/conn", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("POST /api/audit/conn: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("POST status: %d", resp.StatusCode) + } + resp.Body.Close() + + // GET requires auth and returns {data, total}. + resp, err = testAuthGet(base + "/api/audit/conn") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("GET status: %d", resp.StatusCode) + } + var out struct { + Data []map[string]any `json:"data"` + Total int `json:"total"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + if out.Total != 1 || len(out.Data) != 1 { + t.Fatalf("expected 1 record, got total=%d len=%d", out.Total, len(out.Data)) + } + if out.Data[0]["host_id"] != "1340238749" { + t.Errorf("host_id coercion: %v", out.Data[0]["host_id"]) + } +} + +func TestAuditConnRequiresHostID(t *testing.T) { + cfg, cleanup := startTestServer(t, 19902) + defer cleanup() + base := fmt.Sprintf("http://127.0.0.1:%d", cfg.APIPort) + + body, _ := json.Marshal(map[string]any{"peer_id": "P1"}) + resp, err := http.Post(base+"/api/audit/conn", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 400 { + t.Errorf("expected 400 for missing host_id, got %d", resp.StatusCode) + } +} + +func TestServerKeyEndpoints(t *testing.T) { + cfg, cleanup := startTestServer(t, 19903) + defer cleanup() + base := fmt.Sprintf("http://127.0.0.1:%d", cfg.APIPort) + + // server-key is public. + resp, err := http.Get(base + "/api/server-key") + if err != nil { + t.Fatal(err) + } + var kr struct { + Key string `json:"key"` + } + json.NewDecoder(resp.Body).Decode(&kr) + resp.Body.Close() + decoded, err := base64.StdEncoding.DecodeString(kr.Key) + if err != nil || len(decoded) != 32 { + t.Fatalf("server-key not a 32-byte base64 key: %q (err=%v)", kr.Key, err) + } + + // fingerprint format: uppercase colon-separated hex. + resp, err = http.Get(base + "/api/server-key/fingerprint") + if err != nil { + t.Fatal(err) + } + var fr struct { + Fingerprint string `json:"fingerprint"` + Algorithm string `json:"algorithm"` + } + json.NewDecoder(resp.Body).Decode(&fr) + resp.Body.Close() + if fr.Algorithm != "SHA-256" { + t.Errorf("algorithm: %s", fr.Algorithm) + } + if !strings.Contains(fr.Fingerprint, ":") || fr.Fingerprint != strings.ToUpper(fr.Fingerprint) { + t.Errorf("fingerprint format: %s", fr.Fingerprint) + } +} + +func TestUserGroupsEndpoint(t *testing.T) { + cfg, cleanup := startTestServer(t, 19904) + defer cleanup() + base := fmt.Sprintf("http://127.0.0.1:%d", cfg.APIPort) + + // Create a user group (auth required). + body, _ := json.Marshal(map[string]any{"name": "Support Team"}) + req, _ := http.NewRequest("POST", base+"/api/user-groups", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(testAuthReq(req)) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 200 { + t.Fatalf("POST status: %d", resp.StatusCode) + } + resp.Body.Close() + + // List user groups. + resp, err = testAuthGet(base + "/api/user-groups") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out struct { + Data []map[string]any `json:"data"` + Total int `json:"total"` + } + json.NewDecoder(resp.Body).Decode(&out) + if out.Total != 1 || len(out.Data) != 1 { + t.Fatalf("expected 1 group, got total=%d len=%d", out.Total, len(out.Data)) + } + if out.Data[0]["name"] != "Support Team" { + t.Errorf("name: %v", out.Data[0]["name"]) + } + if out.Data[0]["guid"] == "" || out.Data[0]["guid"] == nil { + t.Error("guid should be auto-generated") + } +} diff --git a/betterdesk-server/api/client_group_handlers.go b/betterdesk-server/api/client_group_handlers.go new file mode 100644 index 00000000..79208e18 --- /dev/null +++ b/betterdesk-server/api/client_group_handlers.go @@ -0,0 +1,170 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/unitronix/betterdesk-server/db" +) + +// ── User Groups ─────────────────────────────────────────────────────── + +// handleUserGroupsGet lists user groups. Auth enforced by route wrapper. +// Role "pro" sees an empty list (matches the Node.js console behaviour). +func (s *Server) handleUserGroupsGet(w http.ResponseWriter, r *http.Request) { + if getRoleFromCtx(r) == "pro" { + writeJSON(w, http.StatusOK, map[string]any{"data": []*db.UserGroup{}, "total": 0}) + return + } + rows, err := s.db.ListUserGroups() + if err != nil { + writeInternalError(w, err, "ListUserGroups") + return + } + if rows == nil { + rows = []*db.UserGroup{} + } + writeJSON(w, http.StatusOK, map[string]any{"data": rows, "total": len(rows)}) +} + +// handleUserGroupsPost creates a user group. Auth enforced by route wrapper. +func (s *Server) handleUserGroupsPost(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + Note string `json:"note"` + TeamID string `json:"team_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + body.Name = truncStr(body.Name, maxHostnameLen) + if body.Name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + g := &db.UserGroup{Name: body.Name, Note: truncStr(body.Note, 1024), TeamID: truncStr(body.TeamID, maxIDLen)} + if err := s.db.CreateUserGroup(g); err != nil { + writeInternalError(w, err, "CreateUserGroup") + return + } + writeJSON(w, http.StatusOK, g) +} + +// ── Device Groups ───────────────────────────────────────────────────── + +// handleDeviceGroupsGet lists device groups. Auth enforced by route wrapper. +func (s *Server) handleDeviceGroupsGet(w http.ResponseWriter, r *http.Request) { + rows, err := s.db.ListDeviceGroups() + if err != nil { + writeInternalError(w, err, "ListDeviceGroups") + return + } + if rows == nil { + rows = []*db.DeviceGroup{} + } + writeJSON(w, http.StatusOK, map[string]any{"data": rows, "total": len(rows)}) +} + +// handleDeviceGroupsPost creates a device group. Auth enforced by route wrapper. +func (s *Server) handleDeviceGroupsPost(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + Note string `json:"note"` + TeamID string `json:"team_id"` + SourceType string `json:"source_type"` + TagFilter string `json:"tag_filter"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + body.Name = truncStr(body.Name, maxHostnameLen) + if body.Name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + g := &db.DeviceGroup{ + Name: body.Name, + Note: truncStr(body.Note, 1024), + TeamID: truncStr(body.TeamID, maxIDLen), + SourceType: body.SourceType, + TagFilter: truncStr(body.TagFilter, 256), + } + if err := s.db.CreateDeviceGroup(g); err != nil { + writeInternalError(w, err, "CreateDeviceGroup") + return + } + writeJSON(w, http.StatusOK, g) +} + +// ── Strategies ──────────────────────────────────────────────────────── + +// handleStrategiesGet lists access-control strategies. Auth enforced by route wrapper. +// permissions is emitted as a parsed JSON object for consumer convenience. +func (s *Server) handleStrategiesGet(w http.ResponseWriter, r *http.Request) { + rows, err := s.db.ListStrategies() + if err != nil { + writeInternalError(w, err, "ListStrategies") + return + } + data := make([]map[string]any, 0, len(rows)) + for _, st := range rows { + var perms any + if st.Permissions == "" || json.Unmarshal([]byte(st.Permissions), &perms) != nil { + perms = map[string]any{} + } + data = append(data, map[string]any{ + "id": st.ID, + "guid": st.GUID, + "name": st.Name, + "user_group_guid": st.UserGroupGUID, + "device_group_guid": st.DeviceGroupGUID, + "enabled": st.Enabled, + "permissions": perms, + "created_at": st.CreatedAt, + "updated_at": st.UpdatedAt, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"data": data, "total": len(data)}) +} + +// handleStrategiesPost creates a strategy. Auth enforced by route wrapper. +func (s *Server) handleStrategiesPost(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + UserGroupGUID string `json:"user_group_guid"` + DeviceGroupGUID string `json:"device_group_guid"` + Enabled *bool `json:"enabled"` + Permissions json.RawMessage `json:"permissions"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + body.Name = truncStr(body.Name, maxHostnameLen) + if body.Name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + enabled := true + if body.Enabled != nil { + enabled = *body.Enabled + } + perms := "{}" + if len(body.Permissions) > 0 && string(body.Permissions) != "null" { + perms = string(body.Permissions) + } + st := &db.Strategy{ + Name: body.Name, + UserGroupGUID: truncStr(body.UserGroupGUID, maxIDLen), + DeviceGroupGUID: truncStr(body.DeviceGroupGUID, maxIDLen), + Enabled: enabled, + Permissions: perms, + } + if err := s.db.CreateStrategy(st); err != nil { + writeInternalError(w, err, "CreateStrategy") + return + } + writeJSON(w, http.StatusOK, st) +} diff --git a/betterdesk-server/api/server.go b/betterdesk-server/api/server.go index 8ae64c73..257d0d76 100644 --- a/betterdesk-server/api/server.go +++ b/betterdesk-server/api/server.go @@ -60,8 +60,8 @@ type Server struct { // branding endpoints to deter device-ID enumeration and config probing. enrollmentLimiter *ratelimit.IPLimiter brandingLimiter *ratelimit.IPLimiter - keyPair *crypto.KeyPair // Ed25519 keypair for signing - cdapGw *cdap.Gateway // CDAP gateway (nil if CDAP disabled) + keyPair *crypto.KeyPair // Ed25519 keypair for signing + cdapGw *cdap.Gateway // CDAP gateway (nil if CDAP disabled) ldapProvider *auth.LDAPProvider // LDAP auth provider (nil if not configured) oidcProvider *auth.OIDCProvider // OIDC/OAuth2 auth provider (nil if not configured) clientTFASessions *tfaSessionStore @@ -331,6 +331,30 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("POST /api/sysinfo", s.handleClientSysinfo) mux.HandleFunc("POST /api/sysinfo_ver", s.handleClientSysinfoVer) + // RustDesk Client API — audit reporting (Phase A consolidation). + // POST endpoints are public: the RustDesk client reports events and may be + // unauthenticated. GET endpoints require the audit.view permission (panel). + mux.HandleFunc("POST /api/audit/conn", s.handleAuditConnPost) + mux.HandleFunc("GET /api/audit/conn", s.requirePermission(auth.PermAuditView, s.handleAuditConnGet)) + mux.HandleFunc("POST /api/audit/file", s.handleAuditFilePost) + mux.HandleFunc("GET /api/audit/file", s.requirePermission(auth.PermAuditView, s.handleAuditFileGet)) + mux.HandleFunc("POST /api/audit/alarm", s.handleAuditAlarmPost) + mux.HandleFunc("GET /api/audit/alarm", s.requirePermission(auth.PermAuditView, s.handleAuditAlarmGet)) + + // RustDesk Client API — server / peer public keys. + // server-key endpoints are public (key is safe to expose). peer-key requires auth. + mux.HandleFunc("GET /api/server-key", s.handleServerKey) + mux.HandleFunc("GET /api/server-key/fingerprint", s.handleServerKeyFingerprint) + mux.HandleFunc("GET /api/peer-key/{id}", s.requirePermission(auth.PermDeviceView, s.handlePeerKey)) + + // RustDesk Client API — user groups, device groups, strategies (panel-facing). + mux.HandleFunc("GET /api/user-groups", s.requirePermission(auth.PermUserView, s.handleUserGroupsGet)) + mux.HandleFunc("POST /api/user-groups", s.requirePermission(auth.PermUserCreate, s.handleUserGroupsPost)) + mux.HandleFunc("GET /api/device-group", s.requirePermission(auth.PermDeviceView, s.handleDeviceGroupsGet)) + mux.HandleFunc("POST /api/device-group", s.requirePermission(auth.PermUserCreate, s.handleDeviceGroupsPost)) + mux.HandleFunc("GET /api/strategies", s.requirePermission(auth.PermUserView, s.handleStrategiesGet)) + mux.HandleFunc("POST /api/strategies", s.requirePermission(auth.PermUserCreate, s.handleStrategiesPost)) + // User management (permission-based) // Issue #138: RustDesk client calls GET /api/users?accessible&pageSize=100 // with operator tokens. The _getUsers() result gates the entire group pull — diff --git a/betterdesk-server/db/audit_groups_postgres.go b/betterdesk-server/db/audit_groups_postgres.go new file mode 100644 index 00000000..40ba91c0 --- /dev/null +++ b/betterdesk-server/db/audit_groups_postgres.go @@ -0,0 +1,430 @@ +package db + +import ( + "strconv" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// itoa renders a positional placeholder index for PostgreSQL queries. +func itoa(i int) string { return strconv.Itoa(i) } + +// ── Audit: Connections ──────────────────────────────────────────────── + +// InsertAuditConnection records a remote-control session event. +func (pg *PostgresDB) InsertAuditConnection(a *AuditConnection) error { + _, err := pg.pool.Exec(pg.ctx, + `INSERT INTO audit_connections (host_id, host_uuid, peer_id, peer_name, action, conn_type, session_id, ip) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + a.HostID, a.HostUUID, a.PeerID, a.PeerName, a.Action, a.ConnType, a.SessionID, a.IP) + return err +} + +// ListAuditConnections returns connection audit records matching the filter. +func (pg *PostgresDB) ListAuditConnections(f AuditFilter) ([]*AuditConnection, error) { + q := `SELECT id, host_id, host_uuid, peer_id, peer_name, action, conn_type, session_id, ip, created_at::text + FROM audit_connections WHERE 1=1` + var args []any + i := 1 + if f.HostID != "" { + q += " AND host_id = $" + itoa(i) + args = append(args, f.HostID) + i++ + } + if f.PeerID != "" { + q += " AND peer_id = $" + itoa(i) + args = append(args, f.PeerID) + i++ + } + if f.Action != "" { + q += " AND action = $" + itoa(i) + args = append(args, f.Action) + i++ + } + q += " ORDER BY created_at DESC LIMIT $" + itoa(i) + " OFFSET $" + itoa(i+1) + args = append(args, auditLimit(f.Limit), auditOffset(f.Offset)) + rows, err := pg.pool.Query(pg.ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*AuditConnection + for rows.Next() { + a := &AuditConnection{} + if err := rows.Scan(&a.ID, &a.HostID, &a.HostUUID, &a.PeerID, &a.PeerName, &a.Action, + &a.ConnType, &a.SessionID, &a.IP, &a.CreatedAt); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// CountAuditConnections returns the total number of connection records matching the filter. +func (pg *PostgresDB) CountAuditConnections(f AuditFilter) (int, error) { + q := `SELECT COUNT(*) FROM audit_connections WHERE 1=1` + var args []any + i := 1 + if f.HostID != "" { + q += " AND host_id = $" + itoa(i) + args = append(args, f.HostID) + i++ + } + if f.PeerID != "" { + q += " AND peer_id = $" + itoa(i) + args = append(args, f.PeerID) + i++ + } + if f.Action != "" { + q += " AND action = $" + itoa(i) + args = append(args, f.Action) + i++ + } + var n int + err := pg.pool.QueryRow(pg.ctx, q, args...).Scan(&n) + return n, err +} + +// ── Audit: File Transfers ───────────────────────────────────────────── + +// InsertAuditFile records a file-transfer event. +func (pg *PostgresDB) InsertAuditFile(a *AuditFile) error { + filesJSON := a.FilesJSON + if filesJSON == "" { + filesJSON = "[]" + } + _, err := pg.pool.Exec(pg.ctx, + `INSERT INTO audit_files (host_id, host_uuid, peer_id, direction, path, is_file, num_files, files_json, ip, peer_name) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + a.HostID, a.HostUUID, a.PeerID, a.Direction, a.Path, a.IsFile, a.NumFiles, filesJSON, a.IP, a.PeerName) + return err +} + +// ListAuditFiles returns file-transfer audit records matching the filter. +func (pg *PostgresDB) ListAuditFiles(f AuditFilter) ([]*AuditFile, error) { + q := `SELECT id, host_id, host_uuid, peer_id, direction, path, is_file, num_files, files_json, ip, peer_name, created_at::text + FROM audit_files WHERE 1=1` + var args []any + i := 1 + if f.HostID != "" { + q += " AND host_id = $" + itoa(i) + args = append(args, f.HostID) + i++ + } + if f.PeerID != "" { + q += " AND peer_id = $" + itoa(i) + args = append(args, f.PeerID) + i++ + } + q += " ORDER BY created_at DESC LIMIT $" + itoa(i) + " OFFSET $" + itoa(i+1) + args = append(args, auditLimit(f.Limit), auditOffset(f.Offset)) + rows, err := pg.pool.Query(pg.ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*AuditFile + for rows.Next() { + a := &AuditFile{} + if err := rows.Scan(&a.ID, &a.HostID, &a.HostUUID, &a.PeerID, &a.Direction, &a.Path, + &a.IsFile, &a.NumFiles, &a.FilesJSON, &a.IP, &a.PeerName, &a.CreatedAt); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// CountAuditFiles returns the total number of file records matching the filter. +func (pg *PostgresDB) CountAuditFiles(f AuditFilter) (int, error) { + q := `SELECT COUNT(*) FROM audit_files WHERE 1=1` + var args []any + i := 1 + if f.HostID != "" { + q += " AND host_id = $" + itoa(i) + args = append(args, f.HostID) + i++ + } + if f.PeerID != "" { + q += " AND peer_id = $" + itoa(i) + args = append(args, f.PeerID) + i++ + } + var n int + err := pg.pool.QueryRow(pg.ctx, q, args...).Scan(&n) + return n, err +} + +// ── Audit: Security Alarms ──────────────────────────────────────────── + +// InsertAuditAlarm records a security alarm event. +func (pg *PostgresDB) InsertAuditAlarm(a *AuditAlarm) error { + details := a.Details + if details == "" { + details = "{}" + } + _, err := pg.pool.Exec(pg.ctx, + `INSERT INTO audit_alarms (alarm_type, alarm_name, host_id, peer_id, ip, details) + VALUES ($1, $2, $3, $4, $5, $6)`, + a.AlarmType, a.AlarmName, a.HostID, a.PeerID, a.IP, details) + return err +} + +// ListAuditAlarms returns alarm audit records matching the filter. +func (pg *PostgresDB) ListAuditAlarms(f AuditFilter) ([]*AuditAlarm, error) { + q := `SELECT id, alarm_type, alarm_name, host_id, peer_id, ip, details, created_at::text + FROM audit_alarms WHERE 1=1` + var args []any + i := 1 + if f.AlarmType != nil { + q += " AND alarm_type = $" + itoa(i) + args = append(args, *f.AlarmType) + i++ + } + if f.HostID != "" { + q += " AND host_id = $" + itoa(i) + args = append(args, f.HostID) + i++ + } + q += " ORDER BY created_at DESC LIMIT $" + itoa(i) + " OFFSET $" + itoa(i+1) + args = append(args, auditLimit(f.Limit), auditOffset(f.Offset)) + rows, err := pg.pool.Query(pg.ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*AuditAlarm + for rows.Next() { + a := &AuditAlarm{} + if err := rows.Scan(&a.ID, &a.AlarmType, &a.AlarmName, &a.HostID, &a.PeerID, + &a.IP, &a.Details, &a.CreatedAt); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// CountAuditAlarms returns the total number of alarm records matching the filter. +func (pg *PostgresDB) CountAuditAlarms(f AuditFilter) (int, error) { + q := `SELECT COUNT(*) FROM audit_alarms WHERE 1=1` + var args []any + i := 1 + if f.AlarmType != nil { + q += " AND alarm_type = $" + itoa(i) + args = append(args, *f.AlarmType) + i++ + } + if f.HostID != "" { + q += " AND host_id = $" + itoa(i) + args = append(args, f.HostID) + i++ + } + var n int + err := pg.pool.QueryRow(pg.ctx, q, args...).Scan(&n) + return n, err +} + +// ── User Groups ─────────────────────────────────────────────────────── + +// ListUserGroups returns all user groups. +func (pg *PostgresDB) ListUserGroups() ([]*UserGroup, error) { + rows, err := pg.pool.Query(pg.ctx, + `SELECT id, guid, name, note, team_id, created_at::text FROM user_groups ORDER BY name ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*UserGroup + for rows.Next() { + g := &UserGroup{} + if err := rows.Scan(&g.ID, &g.GUID, &g.Name, &g.Note, &g.TeamID, &g.CreatedAt); err != nil { + return nil, err + } + out = append(out, g) + } + return out, rows.Err() +} + +// GetUserGroup returns a single user group by GUID, or nil if not found. +func (pg *PostgresDB) GetUserGroup(guid string) (*UserGroup, error) { + g := &UserGroup{} + err := pg.pool.QueryRow(pg.ctx, + `SELECT id, guid, name, note, team_id, created_at::text FROM user_groups WHERE guid = $1`, guid). + Scan(&g.ID, &g.GUID, &g.Name, &g.Note, &g.TeamID, &g.CreatedAt) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return g, nil +} + +// CreateUserGroup inserts a new user group. Generates a GUID if empty. +func (pg *PostgresDB) CreateUserGroup(g *UserGroup) error { + if g.GUID == "" { + g.GUID = uuid.New().String() + } + _, err := pg.pool.Exec(pg.ctx, + `INSERT INTO user_groups (guid, name, note, team_id) VALUES ($1, $2, $3, $4)`, + g.GUID, g.Name, g.Note, g.TeamID) + return err +} + +// UpdateUserGroup updates name/note/team_id on an existing user group. +func (pg *PostgresDB) UpdateUserGroup(guid string, g *UserGroup) error { + _, err := pg.pool.Exec(pg.ctx, + `UPDATE user_groups SET name = $1, note = $2, team_id = $3 WHERE guid = $4`, + g.Name, g.Note, g.TeamID, guid) + return err +} + +// DeleteUserGroup removes a user group by GUID. +func (pg *PostgresDB) DeleteUserGroup(guid string) error { + _, err := pg.pool.Exec(pg.ctx, `DELETE FROM user_groups WHERE guid = $1`, guid) + return err +} + +// ── Device Groups ───────────────────────────────────────────────────── + +// ListDeviceGroups returns all device groups. +func (pg *PostgresDB) ListDeviceGroups() ([]*DeviceGroup, error) { + rows, err := pg.pool.Query(pg.ctx, + `SELECT id, guid, name, note, team_id, source_type, tag_filter, created_at::text FROM device_groups ORDER BY name ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*DeviceGroup + for rows.Next() { + g := &DeviceGroup{} + if err := rows.Scan(&g.ID, &g.GUID, &g.Name, &g.Note, &g.TeamID, &g.SourceType, &g.TagFilter, &g.CreatedAt); err != nil { + return nil, err + } + out = append(out, g) + } + return out, rows.Err() +} + +// GetDeviceGroup returns a single device group by GUID, or nil if not found. +func (pg *PostgresDB) GetDeviceGroup(guid string) (*DeviceGroup, error) { + g := &DeviceGroup{} + err := pg.pool.QueryRow(pg.ctx, + `SELECT id, guid, name, note, team_id, source_type, tag_filter, created_at::text FROM device_groups WHERE guid = $1`, guid). + Scan(&g.ID, &g.GUID, &g.Name, &g.Note, &g.TeamID, &g.SourceType, &g.TagFilter, &g.CreatedAt) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return g, nil +} + +// CreateDeviceGroup inserts a new device group. Generates a GUID if empty. +func (pg *PostgresDB) CreateDeviceGroup(g *DeviceGroup) error { + if g.GUID == "" { + g.GUID = uuid.New().String() + } + st := normalizeSourceType(g.SourceType) + tf := "" + if st == "tag" { + tf = g.TagFilter + } + _, err := pg.pool.Exec(pg.ctx, + `INSERT INTO device_groups (guid, name, note, team_id, source_type, tag_filter) VALUES ($1, $2, $3, $4, $5, $6)`, + g.GUID, g.Name, g.Note, g.TeamID, st, tf) + return err +} + +// UpdateDeviceGroup updates an existing device group. +func (pg *PostgresDB) UpdateDeviceGroup(guid string, g *DeviceGroup) error { + st := normalizeSourceType(g.SourceType) + _, err := pg.pool.Exec(pg.ctx, + `UPDATE device_groups SET name = $1, note = $2, team_id = $3, source_type = $4, tag_filter = $5 WHERE guid = $6`, + g.Name, g.Note, g.TeamID, st, g.TagFilter, guid) + return err +} + +// DeleteDeviceGroup removes a device group by GUID. +func (pg *PostgresDB) DeleteDeviceGroup(guid string) error { + _, err := pg.pool.Exec(pg.ctx, `DELETE FROM device_groups WHERE guid = $1`, guid) + return err +} + +// ── Strategies ──────────────────────────────────────────────────────── + +// ListStrategies returns all strategies. +func (pg *PostgresDB) ListStrategies() ([]*Strategy, error) { + rows, err := pg.pool.Query(pg.ctx, + `SELECT id, guid, name, user_group_guid, device_group_guid, enabled, permissions, created_at::text, updated_at::text + FROM strategies ORDER BY name ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*Strategy + for rows.Next() { + st := &Strategy{} + if err := rows.Scan(&st.ID, &st.GUID, &st.Name, &st.UserGroupGUID, &st.DeviceGroupGUID, + &st.Enabled, &st.Permissions, &st.CreatedAt, &st.UpdatedAt); err != nil { + return nil, err + } + out = append(out, st) + } + return out, rows.Err() +} + +// GetStrategy returns a single strategy by GUID, or nil if not found. +func (pg *PostgresDB) GetStrategy(guid string) (*Strategy, error) { + st := &Strategy{} + err := pg.pool.QueryRow(pg.ctx, + `SELECT id, guid, name, user_group_guid, device_group_guid, enabled, permissions, created_at::text, updated_at::text + FROM strategies WHERE guid = $1`, guid). + Scan(&st.ID, &st.GUID, &st.Name, &st.UserGroupGUID, &st.DeviceGroupGUID, + &st.Enabled, &st.Permissions, &st.CreatedAt, &st.UpdatedAt) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return st, nil +} + +// CreateStrategy inserts a new strategy. Generates a GUID if empty. +func (pg *PostgresDB) CreateStrategy(st *Strategy) error { + if st.GUID == "" { + st.GUID = uuid.New().String() + } + perms := st.Permissions + if perms == "" { + perms = "{}" + } + _, err := pg.pool.Exec(pg.ctx, + `INSERT INTO strategies (guid, name, user_group_guid, device_group_guid, enabled, permissions) + VALUES ($1, $2, $3, $4, $5, $6)`, + st.GUID, st.Name, st.UserGroupGUID, st.DeviceGroupGUID, st.Enabled, perms) + return err +} + +// UpdateStrategy updates an existing strategy. +func (pg *PostgresDB) UpdateStrategy(guid string, st *Strategy) error { + perms := st.Permissions + if perms == "" { + perms = "{}" + } + _, err := pg.pool.Exec(pg.ctx, + `UPDATE strategies SET name = $1, user_group_guid = $2, device_group_guid = $3, enabled = $4, permissions = $5, + updated_at = NOW() WHERE guid = $6`, + st.Name, st.UserGroupGUID, st.DeviceGroupGUID, st.Enabled, perms, guid) + return err +} + +// DeleteStrategy removes a strategy by GUID. +func (pg *PostgresDB) DeleteStrategy(guid string) error { + _, err := pg.pool.Exec(pg.ctx, `DELETE FROM strategies WHERE guid = $1`, guid) + return err +} diff --git a/betterdesk-server/db/audit_groups_sqlite.go b/betterdesk-server/db/audit_groups_sqlite.go new file mode 100644 index 00000000..8950f53a --- /dev/null +++ b/betterdesk-server/db/audit_groups_sqlite.go @@ -0,0 +1,494 @@ +package db + +import ( + "database/sql" + + "github.com/google/uuid" +) + +// ── Audit: Connections ──────────────────────────────────────────────── + +// InsertAuditConnection records a remote-control session event. +func (s *SQLiteDB) InsertAuditConnection(a *AuditConnection) error { + s.mu.Lock() + defer s.mu.Unlock() + _, err := s.db.Exec( + `INSERT INTO audit_connections (host_id, host_uuid, peer_id, peer_name, action, conn_type, session_id, ip) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + a.HostID, a.HostUUID, a.PeerID, a.PeerName, a.Action, a.ConnType, a.SessionID, a.IP) + return err +} + +// ListAuditConnections returns connection audit records matching the filter. +func (s *SQLiteDB) ListAuditConnections(f AuditFilter) ([]*AuditConnection, error) { + s.mu.RLock() + defer s.mu.RUnlock() + q := `SELECT id, host_id, host_uuid, peer_id, peer_name, action, conn_type, session_id, ip, created_at + FROM audit_connections WHERE 1=1` + var args []any + if f.HostID != "" { + q += " AND host_id = ?" + args = append(args, f.HostID) + } + if f.PeerID != "" { + q += " AND peer_id = ?" + args = append(args, f.PeerID) + } + if f.Action != "" { + q += " AND action = ?" + args = append(args, f.Action) + } + q += " ORDER BY created_at DESC LIMIT ? OFFSET ?" + args = append(args, auditLimit(f.Limit), auditOffset(f.Offset)) + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*AuditConnection + for rows.Next() { + a := &AuditConnection{} + if err := rows.Scan(&a.ID, &a.HostID, &a.HostUUID, &a.PeerID, &a.PeerName, &a.Action, + &a.ConnType, &a.SessionID, &a.IP, &a.CreatedAt); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// CountAuditConnections returns the total number of connection records matching the filter. +func (s *SQLiteDB) CountAuditConnections(f AuditFilter) (int, error) { + s.mu.RLock() + defer s.mu.RUnlock() + q := `SELECT COUNT(*) FROM audit_connections WHERE 1=1` + var args []any + if f.HostID != "" { + q += " AND host_id = ?" + args = append(args, f.HostID) + } + if f.PeerID != "" { + q += " AND peer_id = ?" + args = append(args, f.PeerID) + } + if f.Action != "" { + q += " AND action = ?" + args = append(args, f.Action) + } + var n int + err := s.db.QueryRow(q, args...).Scan(&n) + return n, err +} + +// ── Audit: File Transfers ───────────────────────────────────────────── + +// InsertAuditFile records a file-transfer event. +func (s *SQLiteDB) InsertAuditFile(a *AuditFile) error { + s.mu.Lock() + defer s.mu.Unlock() + filesJSON := a.FilesJSON + if filesJSON == "" { + filesJSON = "[]" + } + _, err := s.db.Exec( + `INSERT INTO audit_files (host_id, host_uuid, peer_id, direction, path, is_file, num_files, files_json, ip, peer_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + a.HostID, a.HostUUID, a.PeerID, a.Direction, a.Path, a.IsFile, a.NumFiles, filesJSON, a.IP, a.PeerName) + return err +} + +// ListAuditFiles returns file-transfer audit records matching the filter. +func (s *SQLiteDB) ListAuditFiles(f AuditFilter) ([]*AuditFile, error) { + s.mu.RLock() + defer s.mu.RUnlock() + q := `SELECT id, host_id, host_uuid, peer_id, direction, path, is_file, num_files, files_json, ip, peer_name, created_at + FROM audit_files WHERE 1=1` + var args []any + if f.HostID != "" { + q += " AND host_id = ?" + args = append(args, f.HostID) + } + if f.PeerID != "" { + q += " AND peer_id = ?" + args = append(args, f.PeerID) + } + q += " ORDER BY created_at DESC LIMIT ? OFFSET ?" + args = append(args, auditLimit(f.Limit), auditOffset(f.Offset)) + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*AuditFile + for rows.Next() { + a := &AuditFile{} + if err := rows.Scan(&a.ID, &a.HostID, &a.HostUUID, &a.PeerID, &a.Direction, &a.Path, + &a.IsFile, &a.NumFiles, &a.FilesJSON, &a.IP, &a.PeerName, &a.CreatedAt); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// CountAuditFiles returns the total number of file records matching the filter. +func (s *SQLiteDB) CountAuditFiles(f AuditFilter) (int, error) { + s.mu.RLock() + defer s.mu.RUnlock() + q := `SELECT COUNT(*) FROM audit_files WHERE 1=1` + var args []any + if f.HostID != "" { + q += " AND host_id = ?" + args = append(args, f.HostID) + } + if f.PeerID != "" { + q += " AND peer_id = ?" + args = append(args, f.PeerID) + } + var n int + err := s.db.QueryRow(q, args...).Scan(&n) + return n, err +} + +// ── Audit: Security Alarms ──────────────────────────────────────────── + +// InsertAuditAlarm records a security alarm event. +func (s *SQLiteDB) InsertAuditAlarm(a *AuditAlarm) error { + s.mu.Lock() + defer s.mu.Unlock() + details := a.Details + if details == "" { + details = "{}" + } + _, err := s.db.Exec( + `INSERT INTO audit_alarms (alarm_type, alarm_name, host_id, peer_id, ip, details) + VALUES (?, ?, ?, ?, ?, ?)`, + a.AlarmType, a.AlarmName, a.HostID, a.PeerID, a.IP, details) + return err +} + +// ListAuditAlarms returns alarm audit records matching the filter. +func (s *SQLiteDB) ListAuditAlarms(f AuditFilter) ([]*AuditAlarm, error) { + s.mu.RLock() + defer s.mu.RUnlock() + q := `SELECT id, alarm_type, alarm_name, host_id, peer_id, ip, details, created_at + FROM audit_alarms WHERE 1=1` + var args []any + if f.AlarmType != nil { + q += " AND alarm_type = ?" + args = append(args, *f.AlarmType) + } + if f.HostID != "" { + q += " AND host_id = ?" + args = append(args, f.HostID) + } + q += " ORDER BY created_at DESC LIMIT ? OFFSET ?" + args = append(args, auditLimit(f.Limit), auditOffset(f.Offset)) + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*AuditAlarm + for rows.Next() { + a := &AuditAlarm{} + if err := rows.Scan(&a.ID, &a.AlarmType, &a.AlarmName, &a.HostID, &a.PeerID, + &a.IP, &a.Details, &a.CreatedAt); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// CountAuditAlarms returns the total number of alarm records matching the filter. +func (s *SQLiteDB) CountAuditAlarms(f AuditFilter) (int, error) { + s.mu.RLock() + defer s.mu.RUnlock() + q := `SELECT COUNT(*) FROM audit_alarms WHERE 1=1` + var args []any + if f.AlarmType != nil { + q += " AND alarm_type = ?" + args = append(args, *f.AlarmType) + } + if f.HostID != "" { + q += " AND host_id = ?" + args = append(args, f.HostID) + } + var n int + err := s.db.QueryRow(q, args...).Scan(&n) + return n, err +} + +// ── User Groups ─────────────────────────────────────────────────────── + +// ListUserGroups returns all user groups with member counts. +func (s *SQLiteDB) ListUserGroups() ([]*UserGroup, error) { + s.mu.RLock() + defer s.mu.RUnlock() + rows, err := s.db.Query(`SELECT id, guid, name, note, team_id, created_at FROM user_groups ORDER BY name ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*UserGroup + for rows.Next() { + g := &UserGroup{} + if err := rows.Scan(&g.ID, &g.GUID, &g.Name, &g.Note, &g.TeamID, &g.CreatedAt); err != nil { + return nil, err + } + out = append(out, g) + } + return out, rows.Err() +} + +// GetUserGroup returns a single user group by GUID, or nil if not found. +func (s *SQLiteDB) GetUserGroup(guid string) (*UserGroup, error) { + s.mu.RLock() + defer s.mu.RUnlock() + g := &UserGroup{} + err := s.db.QueryRow( + `SELECT id, guid, name, note, team_id, created_at FROM user_groups WHERE guid = ?`, guid). + Scan(&g.ID, &g.GUID, &g.Name, &g.Note, &g.TeamID, &g.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return g, nil +} + +// CreateUserGroup inserts a new user group. Generates a GUID if empty. +func (s *SQLiteDB) CreateUserGroup(g *UserGroup) error { + s.mu.Lock() + defer s.mu.Unlock() + if g.GUID == "" { + g.GUID = uuid.New().String() + } + _, err := s.db.Exec( + `INSERT INTO user_groups (guid, name, note, team_id) VALUES (?, ?, ?, ?)`, + g.GUID, g.Name, g.Note, g.TeamID) + return err +} + +// UpdateUserGroup updates name/note/team_id on an existing user group. +func (s *SQLiteDB) UpdateUserGroup(guid string, g *UserGroup) error { + s.mu.Lock() + defer s.mu.Unlock() + _, err := s.db.Exec( + `UPDATE user_groups SET name = ?, note = ?, team_id = ? WHERE guid = ?`, + g.Name, g.Note, g.TeamID, guid) + return err +} + +// DeleteUserGroup removes a user group by GUID. +func (s *SQLiteDB) DeleteUserGroup(guid string) error { + s.mu.Lock() + defer s.mu.Unlock() + _, err := s.db.Exec(`DELETE FROM user_groups WHERE guid = ?`, guid) + return err +} + +// ── Device Groups ───────────────────────────────────────────────────── + +// ListDeviceGroups returns all device groups. +func (s *SQLiteDB) ListDeviceGroups() ([]*DeviceGroup, error) { + s.mu.RLock() + defer s.mu.RUnlock() + rows, err := s.db.Query( + `SELECT id, guid, name, note, team_id, source_type, tag_filter, created_at FROM device_groups ORDER BY name ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*DeviceGroup + for rows.Next() { + g := &DeviceGroup{} + if err := rows.Scan(&g.ID, &g.GUID, &g.Name, &g.Note, &g.TeamID, &g.SourceType, &g.TagFilter, &g.CreatedAt); err != nil { + return nil, err + } + out = append(out, g) + } + return out, rows.Err() +} + +// GetDeviceGroup returns a single device group by GUID, or nil if not found. +func (s *SQLiteDB) GetDeviceGroup(guid string) (*DeviceGroup, error) { + s.mu.RLock() + defer s.mu.RUnlock() + g := &DeviceGroup{} + err := s.db.QueryRow( + `SELECT id, guid, name, note, team_id, source_type, tag_filter, created_at FROM device_groups WHERE guid = ?`, guid). + Scan(&g.ID, &g.GUID, &g.Name, &g.Note, &g.TeamID, &g.SourceType, &g.TagFilter, &g.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return g, nil +} + +// CreateDeviceGroup inserts a new device group. Generates a GUID if empty. +func (s *SQLiteDB) CreateDeviceGroup(g *DeviceGroup) error { + s.mu.Lock() + defer s.mu.Unlock() + if g.GUID == "" { + g.GUID = uuid.New().String() + } + st := normalizeSourceType(g.SourceType) + tf := "" + if st == "tag" { + tf = g.TagFilter + } + _, err := s.db.Exec( + `INSERT INTO device_groups (guid, name, note, team_id, source_type, tag_filter) VALUES (?, ?, ?, ?, ?, ?)`, + g.GUID, g.Name, g.Note, g.TeamID, st, tf) + return err +} + +// UpdateDeviceGroup updates an existing device group. +func (s *SQLiteDB) UpdateDeviceGroup(guid string, g *DeviceGroup) error { + s.mu.Lock() + defer s.mu.Unlock() + st := normalizeSourceType(g.SourceType) + _, err := s.db.Exec( + `UPDATE device_groups SET name = ?, note = ?, team_id = ?, source_type = ?, tag_filter = ? WHERE guid = ?`, + g.Name, g.Note, g.TeamID, st, g.TagFilter, guid) + return err +} + +// DeleteDeviceGroup removes a device group by GUID. +func (s *SQLiteDB) DeleteDeviceGroup(guid string) error { + s.mu.Lock() + defer s.mu.Unlock() + _, err := s.db.Exec(`DELETE FROM device_groups WHERE guid = ?`, guid) + return err +} + +// ── Strategies ──────────────────────────────────────────────────────── + +// ListStrategies returns all strategies. +func (s *SQLiteDB) ListStrategies() ([]*Strategy, error) { + s.mu.RLock() + defer s.mu.RUnlock() + rows, err := s.db.Query( + `SELECT id, guid, name, user_group_guid, device_group_guid, enabled, permissions, created_at, updated_at + FROM strategies ORDER BY name ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*Strategy + for rows.Next() { + st := &Strategy{} + var enabled int + if err := rows.Scan(&st.ID, &st.GUID, &st.Name, &st.UserGroupGUID, &st.DeviceGroupGUID, + &enabled, &st.Permissions, &st.CreatedAt, &st.UpdatedAt); err != nil { + return nil, err + } + st.Enabled = enabled != 0 + out = append(out, st) + } + return out, rows.Err() +} + +// GetStrategy returns a single strategy by GUID, or nil if not found. +func (s *SQLiteDB) GetStrategy(guid string) (*Strategy, error) { + s.mu.RLock() + defer s.mu.RUnlock() + st := &Strategy{} + var enabled int + err := s.db.QueryRow( + `SELECT id, guid, name, user_group_guid, device_group_guid, enabled, permissions, created_at, updated_at + FROM strategies WHERE guid = ?`, guid). + Scan(&st.ID, &st.GUID, &st.Name, &st.UserGroupGUID, &st.DeviceGroupGUID, + &enabled, &st.Permissions, &st.CreatedAt, &st.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + st.Enabled = enabled != 0 + return st, nil +} + +// CreateStrategy inserts a new strategy. Generates a GUID if empty. +func (s *SQLiteDB) CreateStrategy(st *Strategy) error { + s.mu.Lock() + defer s.mu.Unlock() + if st.GUID == "" { + st.GUID = uuid.New().String() + } + perms := st.Permissions + if perms == "" { + perms = "{}" + } + enabled := 1 + if !st.Enabled { + enabled = 0 + } + _, err := s.db.Exec( + `INSERT INTO strategies (guid, name, user_group_guid, device_group_guid, enabled, permissions) + VALUES (?, ?, ?, ?, ?, ?)`, + st.GUID, st.Name, st.UserGroupGUID, st.DeviceGroupGUID, enabled, perms) + return err +} + +// UpdateStrategy updates an existing strategy. +func (s *SQLiteDB) UpdateStrategy(guid string, st *Strategy) error { + s.mu.Lock() + defer s.mu.Unlock() + perms := st.Permissions + if perms == "" { + perms = "{}" + } + enabled := 1 + if !st.Enabled { + enabled = 0 + } + _, err := s.db.Exec( + `UPDATE strategies SET name = ?, user_group_guid = ?, device_group_guid = ?, enabled = ?, permissions = ?, + updated_at = datetime('now') WHERE guid = ?`, + st.Name, st.UserGroupGUID, st.DeviceGroupGUID, enabled, perms, guid) + return err +} + +// DeleteStrategy removes a strategy by GUID. +func (s *SQLiteDB) DeleteStrategy(guid string) error { + s.mu.Lock() + defer s.mu.Unlock() + _, err := s.db.Exec(`DELETE FROM strategies WHERE guid = ?`, guid) + return err +} + +// ── Shared helpers ──────────────────────────────────────────────────── + +// auditLimit clamps a requested limit to a sane range (default 100, max 1000). +func auditLimit(limit int) int { + if limit <= 0 { + return 100 + } + if limit > 1000 { + return 1000 + } + return limit +} + +// auditOffset returns a non-negative offset. +func auditOffset(offset int) int { + if offset < 0 { + return 0 + } + return offset +} + +// normalizeSourceType coerces a device group source type to "tag" or "manual". +func normalizeSourceType(src string) string { + if src == "tag" { + return "tag" + } + return "manual" +} diff --git a/betterdesk-server/db/audit_groups_test.go b/betterdesk-server/db/audit_groups_test.go new file mode 100644 index 00000000..3f119e84 --- /dev/null +++ b/betterdesk-server/db/audit_groups_test.go @@ -0,0 +1,243 @@ +package db + +import "testing" + +// TestAuditConnectionsRoundTrip verifies insert, list (with filters) and count. +func TestAuditConnectionsRoundTrip(t *testing.T) { + db := newTestDB(t) + + for i := 0; i < 3; i++ { + if err := db.InsertAuditConnection(&AuditConnection{ + HostID: "HOST1", + PeerID: "PEER1", + Action: "connect", + ConnType: 0, + IP: "10.0.0.1", + }); err != nil { + t.Fatalf("InsertAuditConnection: %v", err) + } + } + if err := db.InsertAuditConnection(&AuditConnection{ + HostID: "HOST2", + PeerID: "PEER2", + Action: "disconnect", + }); err != nil { + t.Fatalf("InsertAuditConnection HOST2: %v", err) + } + + total, err := db.CountAuditConnections(AuditFilter{}) + if err != nil { + t.Fatalf("CountAuditConnections: %v", err) + } + if total != 4 { + t.Errorf("CountAuditConnections = %d, want 4", total) + } + + host1, err := db.ListAuditConnections(AuditFilter{HostID: "HOST1"}) + if err != nil { + t.Fatalf("ListAuditConnections: %v", err) + } + if len(host1) != 3 { + t.Errorf("ListAuditConnections(HOST1) = %d, want 3", len(host1)) + } + + n, err := db.CountAuditConnections(AuditFilter{Action: "disconnect"}) + if err != nil { + t.Fatalf("CountAuditConnections(disconnect): %v", err) + } + if n != 1 { + t.Errorf("CountAuditConnections(disconnect) = %d, want 1", n) + } +} + +// TestAuditFilesRoundTrip verifies file audit insert/list/count. +func TestAuditFilesRoundTrip(t *testing.T) { + db := newTestDB(t) + + if err := db.InsertAuditFile(&AuditFile{ + HostID: "HOST1", + PeerID: "PEER1", + Direction: 1, + Path: "/tmp/x", + IsFile: 1, + NumFiles: 2, + FilesJSON: `[{"name":"a"},{"name":"b"}]`, + }); err != nil { + t.Fatalf("InsertAuditFile: %v", err) + } + + files, err := db.ListAuditFiles(AuditFilter{HostID: "HOST1"}) + if err != nil { + t.Fatalf("ListAuditFiles: %v", err) + } + if len(files) != 1 { + t.Fatalf("ListAuditFiles = %d, want 1", len(files)) + } + if files[0].NumFiles != 2 || files[0].FilesJSON == "" { + t.Errorf("unexpected file record: %+v", files[0]) + } +} + +// TestAuditAlarmsRoundTrip verifies alarm audit insert/list/count with type filter. +func TestAuditAlarmsRoundTrip(t *testing.T) { + db := newTestDB(t) + + typ := 3 + if err := db.InsertAuditAlarm(&AuditAlarm{ + AlarmType: typ, + AlarmName: "ip_whitelist", + HostID: "HOST1", + }); err != nil { + t.Fatalf("InsertAuditAlarm: %v", err) + } + if err := db.InsertAuditAlarm(&AuditAlarm{AlarmType: 1, AlarmName: "other"}); err != nil { + t.Fatalf("InsertAuditAlarm 2: %v", err) + } + + got, err := db.ListAuditAlarms(AuditFilter{AlarmType: &typ}) + if err != nil { + t.Fatalf("ListAuditAlarms: %v", err) + } + if len(got) != 1 || got[0].AlarmName != "ip_whitelist" { + t.Errorf("ListAuditAlarms(type=3) = %+v", got) + } +} + +// TestUserGroupCRUD verifies user group create/get/update/delete and GUID generation. +func TestUserGroupCRUD(t *testing.T) { + db := newTestDB(t) + + g := &UserGroup{Name: "Ops", Note: "operators"} + if err := db.CreateUserGroup(g); err != nil { + t.Fatalf("CreateUserGroup: %v", err) + } + if g.GUID == "" { + t.Fatal("CreateUserGroup did not generate GUID") + } + + got, err := db.GetUserGroup(g.GUID) + if err != nil { + t.Fatalf("GetUserGroup: %v", err) + } + if got == nil || got.Name != "Ops" { + t.Fatalf("GetUserGroup = %+v", got) + } + + if err := db.UpdateUserGroup(g.GUID, &UserGroup{Name: "Ops2", Note: "x", TeamID: "t1"}); err != nil { + t.Fatalf("UpdateUserGroup: %v", err) + } + got, _ = db.GetUserGroup(g.GUID) + if got.Name != "Ops2" || got.TeamID != "t1" { + t.Errorf("UpdateUserGroup result = %+v", got) + } + + if err := db.DeleteUserGroup(g.GUID); err != nil { + t.Fatalf("DeleteUserGroup: %v", err) + } + got, _ = db.GetUserGroup(g.GUID) + if got != nil { + t.Errorf("group still present after delete: %+v", got) + } +} + +// TestDeviceGroupCRUD verifies device group create/get/update/delete and source_type rules. +func TestDeviceGroupCRUD(t *testing.T) { + db := newTestDB(t) + + g := &DeviceGroup{Name: "Tagged", SourceType: "tag", TagFilter: "prod"} + if err := db.CreateDeviceGroup(g); err != nil { + t.Fatalf("CreateDeviceGroup: %v", err) + } + got, err := db.GetDeviceGroup(g.GUID) + if err != nil { + t.Fatalf("GetDeviceGroup: %v", err) + } + if got.SourceType != "tag" || got.TagFilter != "prod" { + t.Errorf("device group = %+v", got) + } + + // Manual source type must clear tag filter. + m := &DeviceGroup{Name: "Manual", SourceType: "manual", TagFilter: "ignored"} + if err := db.CreateDeviceGroup(m); err != nil { + t.Fatalf("CreateDeviceGroup manual: %v", err) + } + gotM, _ := db.GetDeviceGroup(m.GUID) + if gotM.SourceType != "manual" || gotM.TagFilter != "" { + t.Errorf("manual group should clear tag_filter: %+v", gotM) + } +} + +// TestStrategyCRUD verifies strategy create/get/update/delete and enabled bool mapping. +func TestStrategyCRUD(t *testing.T) { + db := newTestDB(t) + + st := &Strategy{Name: "Default", Enabled: true, Permissions: `{"file":true}`} + if err := db.CreateStrategy(st); err != nil { + t.Fatalf("CreateStrategy: %v", err) + } + got, err := db.GetStrategy(st.GUID) + if err != nil { + t.Fatalf("GetStrategy: %v", err) + } + if !got.Enabled || got.Permissions != `{"file":true}` { + t.Errorf("strategy = %+v", got) + } + + if err := db.UpdateStrategy(st.GUID, &Strategy{Name: "Default", Enabled: false}); err != nil { + t.Fatalf("UpdateStrategy: %v", err) + } + got, _ = db.GetStrategy(st.GUID) + if got.Enabled { + t.Error("strategy should be disabled after update") + } + if got.Permissions != "{}" { + t.Errorf("empty permissions should default to {}, got %q", got.Permissions) + } + + all, err := db.ListStrategies() + if err != nil { + t.Fatalf("ListStrategies: %v", err) + } + if len(all) != 1 { + t.Errorf("ListStrategies = %d, want 1", len(all)) + } +} + +// TestMigrationBackwardCompatible verifies the new tables are added idempotently +// to a database that was migrated before they existed (existing-instance upgrade path). +func TestMigrationBackwardCompatible(t *testing.T) { + db := newTestDB(t) + + // Simulate an existing instance: drop the new tables, then re-run Migrate. + for _, tbl := range []string{ + "audit_connections", "audit_files", "audit_alarms", + "user_groups", "device_groups", "strategies", + } { + if _, err := db.db.Exec("DROP TABLE IF EXISTS " + tbl); err != nil { + t.Fatalf("drop %s: %v", tbl, err) + } + } + + // Insert a peer to ensure pre-existing data survives the migration. + if err := db.UpsertPeer(&Peer{ID: "KEEP1", UUID: "u", Status: "ONLINE"}); err != nil { + t.Fatalf("UpsertPeer: %v", err) + } + + if err := db.Migrate(); err != nil { + t.Fatalf("Migrate (upgrade path): %v", err) + } + + // New tables must now be usable. + if err := db.InsertAuditConnection(&AuditConnection{HostID: "H", Action: "connect"}); err != nil { + t.Fatalf("InsertAuditConnection after re-migrate: %v", err) + } + + // Pre-existing data must still be present. + p, err := db.GetPeer("KEEP1") + if err != nil { + t.Fatalf("GetPeer: %v", err) + } + if p == nil { + t.Error("pre-existing peer lost after migration") + } +} diff --git a/betterdesk-server/db/database.go b/betterdesk-server/db/database.go index c264d099..5c42fac9 100644 --- a/betterdesk-server/db/database.go +++ b/betterdesk-server/db/database.go @@ -40,16 +40,16 @@ type ServerConfig struct { // User represents an API user account. type User struct { - ID int64 `json:"id"` - Username string `json:"username"` - PasswordHash string `json:"-"` - Role string `json:"role"` // admin, operator, viewer - IsServerAdmin bool `json:"is_server_admin"` // Phase 3: separate server admin flag - TOTPSecret string `json:"-"` - TOTPEnabled bool `json:"totp_enabled"` - TOTPRecoveryCodes string `json:"-"` // JSON array of bcrypt-hashed recovery codes (H4) - CreatedAt string `json:"created_at"` - LastLogin string `json:"last_login,omitempty"` + ID int64 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + Role string `json:"role"` // admin, operator, viewer + IsServerAdmin bool `json:"is_server_admin"` // Phase 3: separate server admin flag + TOTPSecret string `json:"-"` + TOTPEnabled bool `json:"totp_enabled"` + TOTPRecoveryCodes string `json:"-"` // JSON array of bcrypt-hashed recovery codes (H4) + CreatedAt string `json:"created_at"` + LastLogin string `json:"last_login,omitempty"` } // RolePermission represents a custom permission override for a role. @@ -269,6 +269,100 @@ func ValidOrgRole(r string) bool { return r == OrgRoleOwner || r == OrgRoleAdmin || r == OrgRoleOperator || r == OrgRoleUser } +// AuditConnection records a remote-control session event reported by a RustDesk client. +// Mirrors the Node.js console's audit_connections table for API-port consolidation. +type AuditConnection struct { + ID int64 `json:"id"` + HostID string `json:"host_id"` + HostUUID string `json:"host_uuid"` + PeerID string `json:"peer_id"` + PeerName string `json:"peer_name"` + Action string `json:"action"` + ConnType int `json:"conn_type"` + SessionID string `json:"session_id"` + IP string `json:"ip"` + CreatedAt string `json:"created_at"` +} + +// AuditFile records a file-transfer event reported by a RustDesk client. +type AuditFile struct { + ID int64 `json:"id"` + HostID string `json:"host_id"` + HostUUID string `json:"host_uuid"` + PeerID string `json:"peer_id"` + Direction int `json:"direction"` + Path string `json:"path"` + IsFile int `json:"is_file"` + NumFiles int `json:"num_files"` + FilesJSON string `json:"files_json"` + IP string `json:"ip"` + PeerName string `json:"peer_name"` + CreatedAt string `json:"created_at"` +} + +// AuditAlarm records a security alarm event reported by a RustDesk client. +type AuditAlarm struct { + ID int64 `json:"id"` + AlarmType int `json:"alarm_type"` + AlarmName string `json:"alarm_name"` + HostID string `json:"host_id"` + PeerID string `json:"peer_id"` + IP string `json:"ip"` + Details string `json:"details"` + CreatedAt string `json:"created_at"` +} + +// AuditFilter holds optional filter parameters for audit list/count queries. +// A nil AlarmType means "no filter on alarm_type". +type AuditFilter struct { + HostID string + PeerID string + Action string + AlarmType *int + Limit int + Offset int +} + +// UserGroup represents a named group of operator/user accounts. +// Mirrors the Node.js console's user_groups table. +type UserGroup struct { + ID int64 `json:"id"` + GUID string `json:"guid"` + Name string `json:"name"` + Note string `json:"note"` + TeamID string `json:"team_id"` + MemberCount int `json:"member_count"` + CreatedAt string `json:"created_at"` +} + +// DeviceGroup represents a named group of devices. +// Mirrors the Node.js console's device_groups table. +type DeviceGroup struct { + ID int64 `json:"id"` + GUID string `json:"guid"` + Name string `json:"name"` + Note string `json:"note"` + TeamID string `json:"team_id"` + SourceType string `json:"source_type"` // "manual" or "tag" + TagFilter string `json:"tag_filter"` + MemberCount int `json:"member_count"` + CreatedAt string `json:"created_at"` +} + +// Strategy maps a user group + device group to a permission policy. +// Mirrors the Node.js console's strategies table. +type Strategy struct { + ID int64 `json:"id"` + GUID string `json:"guid"` + Name string `json:"name"` + UserGroupGUID string `json:"user_group_guid"` + DeviceGroupGUID string `json:"device_group_guid"` + Enabled bool `json:"enabled"` + Permissions string `json:"permissions"` // JSON blob + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + // Database is the interface for all database operations. // Designed to support SQLite (now) and PostgreSQL (future) as drop-in implementations. type Database interface { @@ -437,4 +531,36 @@ type Database interface { // Org-scoped device queries (RBAC Phase 52 — data scoping) ListPeersForOrg(orgID string, includeDeleted bool) ([]*Peer, error) + + // Audit logs (RustDesk client reporting — API-port consolidation Phase A) + InsertAuditConnection(a *AuditConnection) error + ListAuditConnections(f AuditFilter) ([]*AuditConnection, error) + CountAuditConnections(f AuditFilter) (int, error) + InsertAuditFile(a *AuditFile) error + ListAuditFiles(f AuditFilter) ([]*AuditFile, error) + CountAuditFiles(f AuditFilter) (int, error) + InsertAuditAlarm(a *AuditAlarm) error + ListAuditAlarms(f AuditFilter) ([]*AuditAlarm, error) + CountAuditAlarms(f AuditFilter) (int, error) + + // User groups (operator/user grouping — API-port consolidation Phase A) + ListUserGroups() ([]*UserGroup, error) + GetUserGroup(guid string) (*UserGroup, error) + CreateUserGroup(g *UserGroup) error + UpdateUserGroup(guid string, g *UserGroup) error + DeleteUserGroup(guid string) error + + // Device groups (device grouping — API-port consolidation Phase A) + ListDeviceGroups() ([]*DeviceGroup, error) + GetDeviceGroup(guid string) (*DeviceGroup, error) + CreateDeviceGroup(g *DeviceGroup) error + UpdateDeviceGroup(guid string, g *DeviceGroup) error + DeleteDeviceGroup(guid string) error + + // Strategies (permission policies — API-port consolidation Phase A) + ListStrategies() ([]*Strategy, error) + GetStrategy(guid string) (*Strategy, error) + CreateStrategy(s *Strategy) error + UpdateStrategy(guid string, s *Strategy) error + DeleteStrategy(guid string) error } diff --git a/betterdesk-server/db/postgres.go b/betterdesk-server/db/postgres.go index 6cb819ea..e037b4e9 100644 --- a/betterdesk-server/db/postgres.go +++ b/betterdesk-server/db/postgres.go @@ -284,6 +284,82 @@ func (pg *PostgresDB) Migrate() error { updated_at TIMESTAMPTZ, updated_by TEXT NOT NULL DEFAULT '' )`, + + // Audit logs (RustDesk client reporting — API-port consolidation Phase A) + `CREATE TABLE IF NOT EXISTS audit_connections ( + id BIGSERIAL PRIMARY KEY, + host_id TEXT NOT NULL, + host_uuid TEXT NOT NULL DEFAULT '', + peer_id TEXT NOT NULL DEFAULT '', + peer_name TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL DEFAULT '', + conn_type INTEGER NOT NULL DEFAULT 0, + session_id TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_audit_conn_host ON audit_connections(host_id, created_at)`, + `CREATE INDEX IF NOT EXISTS idx_audit_conn_peer ON audit_connections(peer_id, created_at)`, + + `CREATE TABLE IF NOT EXISTS audit_files ( + id BIGSERIAL PRIMARY KEY, + host_id TEXT NOT NULL, + host_uuid TEXT NOT NULL DEFAULT '', + peer_id TEXT NOT NULL DEFAULT '', + direction INTEGER NOT NULL DEFAULT 0, + path TEXT NOT NULL DEFAULT '', + is_file INTEGER NOT NULL DEFAULT 1, + num_files INTEGER NOT NULL DEFAULT 0, + files_json TEXT NOT NULL DEFAULT '[]', + ip TEXT NOT NULL DEFAULT '', + peer_name TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_audit_files_host ON audit_files(host_id, created_at)`, + `CREATE INDEX IF NOT EXISTS idx_audit_files_peer ON audit_files(peer_id, created_at)`, + + `CREATE TABLE IF NOT EXISTS audit_alarms ( + id BIGSERIAL PRIMARY KEY, + alarm_type INTEGER NOT NULL DEFAULT 0, + alarm_name TEXT NOT NULL DEFAULT '', + host_id TEXT NOT NULL DEFAULT '', + peer_id TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + details TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_audit_alarms_type ON audit_alarms(alarm_type, created_at)`, + + // User/device groups + strategies (API-port consolidation Phase A) + `CREATE TABLE IF NOT EXISTS user_groups ( + id BIGSERIAL PRIMARY KEY, + guid TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + team_id TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `CREATE TABLE IF NOT EXISTS device_groups ( + id BIGSERIAL PRIMARY KEY, + guid TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + team_id TEXT NOT NULL DEFAULT '', + source_type TEXT NOT NULL DEFAULT 'manual', + tag_filter TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `CREATE TABLE IF NOT EXISTS strategies ( + id BIGSERIAL PRIMARY KEY, + guid TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + user_group_guid TEXT NOT NULL DEFAULT '', + device_group_guid TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + permissions TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, } for _, stmt := range statements { diff --git a/betterdesk-server/db/sqlite.go b/betterdesk-server/db/sqlite.go index 02dfb44d..f423e55e 100644 --- a/betterdesk-server/db/sqlite.go +++ b/betterdesk-server/db/sqlite.go @@ -263,6 +263,82 @@ func (s *SQLiteDB) Migrate() error { UNIQUE(role, permission) )`, `CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON role_permissions(role)`, + + // Audit logs (RustDesk client reporting — API-port consolidation Phase A) + `CREATE TABLE IF NOT EXISTS audit_connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id TEXT NOT NULL, + host_uuid TEXT DEFAULT '', + peer_id TEXT DEFAULT '', + peer_name TEXT DEFAULT '', + action TEXT NOT NULL DEFAULT '', + conn_type INTEGER DEFAULT 0, + session_id TEXT DEFAULT '', + ip TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + )`, + `CREATE INDEX IF NOT EXISTS idx_audit_conn_host ON audit_connections(host_id, created_at)`, + `CREATE INDEX IF NOT EXISTS idx_audit_conn_peer ON audit_connections(peer_id, created_at)`, + + `CREATE TABLE IF NOT EXISTS audit_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id TEXT NOT NULL, + host_uuid TEXT DEFAULT '', + peer_id TEXT DEFAULT '', + direction INTEGER DEFAULT 0, + path TEXT DEFAULT '', + is_file INTEGER DEFAULT 1, + num_files INTEGER DEFAULT 0, + files_json TEXT DEFAULT '[]', + ip TEXT DEFAULT '', + peer_name TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + )`, + `CREATE INDEX IF NOT EXISTS idx_audit_files_host ON audit_files(host_id, created_at)`, + `CREATE INDEX IF NOT EXISTS idx_audit_files_peer ON audit_files(peer_id, created_at)`, + + `CREATE TABLE IF NOT EXISTS audit_alarms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + alarm_type INTEGER NOT NULL DEFAULT 0, + alarm_name TEXT DEFAULT '', + host_id TEXT DEFAULT '', + peer_id TEXT DEFAULT '', + ip TEXT DEFAULT '', + details TEXT DEFAULT '{}', + created_at TEXT DEFAULT (datetime('now')) + )`, + `CREATE INDEX IF NOT EXISTS idx_audit_alarms_type ON audit_alarms(alarm_type, created_at)`, + + // User/device groups + strategies (API-port consolidation Phase A) + `CREATE TABLE IF NOT EXISTS user_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + note TEXT DEFAULT '', + team_id TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + )`, + `CREATE TABLE IF NOT EXISTS device_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + note TEXT DEFAULT '', + team_id TEXT DEFAULT '', + source_type TEXT DEFAULT 'manual', + tag_filter TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + )`, + `CREATE TABLE IF NOT EXISTS strategies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guid TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + user_group_guid TEXT DEFAULT '', + device_group_guid TEXT DEFAULT '', + enabled INTEGER DEFAULT 1, + permissions TEXT DEFAULT '{}', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + )`, } for _, stmt := range statements {