mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 17:45:42 +00:00
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.
This commit is contained in:
@@ -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" ||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user