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:
UNITRONIX
2026-05-30 18:52:42 +02:00
parent 760c0e933d
commit ea88c58a9e
11 changed files with 2209 additions and 12 deletions
+4
View File
@@ -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)
}
+26 -2
View File
@@ -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
}
+494
View File
@@ -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"
}
+243
View File
@@ -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")
}
}
+136 -10
View File
@@ -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
}
+76
View File
@@ -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 {
+76
View File
@@ -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 {