feat: implement RustDesk client login binding to BetterDesk accounts

- Added functionality to bind RustDesk client logins to BetterDesk accounts, allowing for inventory and audit tracking of devices.
- Introduced new database methods to retrieve active client sessions by client ID or UUID.
- Updated client session handling to ensure ownership is applied even if the peer row is created after login.
- Enhanced tests to cover new session binding logic and ensure correct behavior for both existing and late-appearing peers.
This commit is contained in:
UNITRONIX
2026-07-16 20:05:10 +02:00
parent 9601ed04fe
commit 39010ebecf
10 changed files with 313 additions and 0 deletions
+3
View File
@@ -1,5 +1,8 @@
## [Unreleased]
### Added
- **RustDesk client login → device owner (#270):** successful client login maps the device (`peers.user`) to the BetterDesk account for inventory/audit (shared logins, credential misuse). Does **not** block remote connections.
### Fixed
- **Windows panel update (#272):** default install under `C:\BetterDeskConsole` no longer treats drive root `C:\` as the project root. Installer/Docker files are written beside the console (avoids `EPERM: mkdir 'C:\'`), quick compose filenames are non-critical for SHA tracking, and NSSM `Access is denied` when restarting BetterDeskServer no longer leaves a stuck “updates available” state.
@@ -880,6 +880,9 @@ func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) {
// Update peer status to ONLINE
_ = s.db.UpdatePeerStatus(deviceID, "ONLINE", clientIP)
// If the user logged in before the peer row existed, bind owner now.
db.ApplyActiveSessionOwner(s.db, deviceID, body.UUID)
// Save metrics if any values provided (values > 0)
if body.CPU > 0 || body.Memory > 0 || body.Disk > 0 {
if err := s.db.SavePeerMetric(deviceID, body.CPU, body.Memory, body.Disk); err != nil {
+4
View File
@@ -124,6 +124,10 @@ func (s *Server) issueClientSession(user *db.User, clientID, clientUUID, clientI
if err := s.db.CreateClientSession(sess); err != nil {
return "", err
}
// Map this RustDesk client device to the BetterDesk account (inventory/audit).
// No connection blocking — ownership only. If the peer row does not exist yet,
// heartbeat / RegisterPk will apply the binding via ApplyActiveSessionOwner.
db.BindPeerOwner(s.db, clientID, clientUUID, user.Username)
return plainToken, nil
}
@@ -6,8 +6,85 @@ import (
"regexp"
"testing"
"time"
"github.com/unitronix/betterdesk-server/db"
)
func TestHandleClientLoginBindsPeerOwner(t *testing.T) {
database := testSetupDB(t)
defer database.Close()
createClientLoginTestUser(t, database, "admin", "correct-password", false)
if err := database.UpsertPeer(&db.Peer{
ID: "testdev-bind",
UUID: "test-uuid-bind",
Status: "ONLINE",
}); err != nil {
t.Fatalf("UpsertPeer: %v", err)
}
srv := newClientLoginTestServer(database)
rec, _ := postClientLogin(t, srv, map[string]any{
"username": "admin",
"password": "correct-password",
"type": "account",
"id": "testdev-bind",
"uuid": "test-uuid-bind",
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
peer, err := database.GetPeer("testdev-bind")
if err != nil || peer == nil {
t.Fatalf("GetPeer: %v peer=%v", err, peer)
}
if peer.User != "admin" {
t.Fatalf("peers.user = %q, want %q", peer.User, "admin")
}
}
func TestApplyActiveSessionOwnerBindsAfterPeerAppears(t *testing.T) {
database := testSetupDB(t)
defer database.Close()
createClientLoginTestUser(t, database, "admin", "correct-password", false)
srv := newClientLoginTestServer(database)
rec, _ := postClientLogin(t, srv, map[string]any{
"username": "admin",
"password": "correct-password",
"type": "account",
"id": "late-peer-1",
"uuid": "late-uuid-1",
})
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d; body=%s", rec.Code, rec.Body.String())
}
// Peer did not exist at login time.
if peer, _ := database.GetPeer("late-peer-1"); peer != nil {
t.Fatal("expected no peer yet")
}
if err := database.UpsertPeer(&db.Peer{
ID: "late-peer-1",
UUID: "late-uuid-1",
Status: "ONLINE",
}); err != nil {
t.Fatalf("UpsertPeer: %v", err)
}
db.ApplyActiveSessionOwner(database, "late-peer-1", "late-uuid-1")
peer, err := database.GetPeer("late-peer-1")
if err != nil || peer == nil {
t.Fatalf("GetPeer: %v", err)
}
if peer.User != "admin" {
t.Fatalf("peers.user = %q, want admin after ApplyActiveSessionOwner", peer.User)
}
}
func TestHandleClientLoginIssuesOpaqueSessionToken(t *testing.T) {
database := testSetupDB(t)
defer database.Close()
@@ -0,0 +1,71 @@
package db
import (
"log"
"strings"
)
// BindPeerOwner sets peers.user for the peer identified by clientID or clientUUID.
// No-op when the peer row does not exist yet (caller may retry after registration).
func BindPeerOwner(database Database, clientID, clientUUID, username string) {
if database == nil {
return
}
username = strings.TrimSpace(username)
clientID = strings.TrimSpace(clientID)
clientUUID = strings.TrimSpace(clientUUID)
if username == "" || (clientID == "" && clientUUID == "") {
return
}
peer, err := resolvePeerForClient(database, clientID, clientUUID)
if err != nil || peer == nil {
return
}
if peer.User == username {
return
}
if err := database.UpdatePeerFields(peer.ID, map[string]string{"user": username}); err != nil {
log.Printf("[db] bind peer owner %s → %s: %v", peer.ID, username, err)
}
}
// ApplyActiveSessionOwner sets peers.user from the newest active client_session
// for this device. Used when the peer appears after login (register / heartbeat).
// Does not clear peers.user when no session is active (keeps last known owner for audit).
func ApplyActiveSessionOwner(database Database, peerID, peerUUID string) {
if database == nil {
return
}
peerID = strings.TrimSpace(peerID)
peerUUID = strings.TrimSpace(peerUUID)
if peerID == "" && peerUUID == "" {
return
}
sess, err := database.GetActiveClientSessionByClient(peerID, peerUUID)
if err != nil || sess == nil {
return
}
user, err := database.GetUserByID(sess.UserID)
if err != nil || user == nil || strings.TrimSpace(user.Username) == "" {
return
}
BindPeerOwner(database, peerID, peerUUID, user.Username)
}
func resolvePeerForClient(database Database, clientID, clientUUID string) (*Peer, error) {
if clientID != "" {
peer, err := database.GetPeer(clientID)
if err != nil {
return nil, err
}
if peer != nil {
return peer, nil
}
}
if clientUUID != "" {
return database.GetPeerByUUID(clientUUID)
}
return nil, nil
}
@@ -2,6 +2,7 @@ package db
import (
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -48,6 +49,45 @@ func (pg *PostgresDB) GetClientSessionByTokenHash(tokenHash string) (*ClientSess
return sess, nil
}
// GetActiveClientSessionByClient returns the newest active session for a RustDesk
// client id and/or uuid, or nil when none match.
func (pg *PostgresDB) GetActiveClientSessionByClient(clientID, clientUUID string) (*ClientSession, error) {
clientID = strings.TrimSpace(clientID)
clientUUID = strings.TrimSpace(clientUUID)
if clientID == "" && clientUUID == "" {
return nil, nil
}
sess := &ClientSession{}
var revoked bool
err := pg.pool.QueryRow(pg.ctx,
`SELECT id, token_hash, user_id, client_id, client_uuid,
to_char(expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS'),
to_char(last_used AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS'),
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS'),
revoked, ip_address
FROM client_sessions
WHERE revoked = FALSE AND expires_at > NOW()
AND (
($1 <> '' AND client_id = $1)
OR ($2 <> '' AND client_uuid = $2)
)
ORDER BY COALESCE(last_used, created_at) DESC, id DESC
LIMIT 1`,
clientID, clientUUID,
).Scan(
&sess.ID, &sess.TokenHash, &sess.UserID, &sess.ClientID, &sess.ClientUUID,
&sess.ExpiresAt, &sess.LastUsed, &sess.CreatedAt, &revoked, &sess.IPAddress)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, err
}
sess.Revoked = revoked
return sess, nil
}
// TouchClientSession updates expiry and last_used for sliding session renewal.
func (pg *PostgresDB) TouchClientSession(id int64, expiresAt, lastUsed string) error {
_, err := pg.pool.Exec(pg.ctx,
@@ -3,6 +3,7 @@ package db
import (
"database/sql"
"fmt"
"strings"
"time"
)
@@ -47,6 +48,43 @@ func (s *SQLiteDB) GetClientSessionByTokenHash(tokenHash string) (*ClientSession
return sess, nil
}
// GetActiveClientSessionByClient returns the newest active session for a RustDesk
// client id and/or uuid, or nil when none match.
func (s *SQLiteDB) GetActiveClientSessionByClient(clientID, clientUUID string) (*ClientSession, error) {
clientID = strings.TrimSpace(clientID)
clientUUID = strings.TrimSpace(clientUUID)
if clientID == "" && clientUUID == "" {
return nil, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
sess := &ClientSession{}
var revoked int
err := s.db.QueryRow(`SELECT id, token_hash, user_id, client_id, client_uuid, expires_at,
last_used, created_at, revoked, ip_address
FROM client_sessions
WHERE revoked = 0 AND expires_at > datetime('now')
AND (
(? != '' AND client_id = ?)
OR (? != '' AND client_uuid = ?)
)
ORDER BY COALESCE(last_used, created_at) DESC, id DESC
LIMIT 1`,
clientID, clientID, clientUUID, clientUUID).Scan(
&sess.ID, &sess.TokenHash, &sess.UserID, &sess.ClientID, &sess.ClientUUID,
&sess.ExpiresAt, &sess.LastUsed, &sess.CreatedAt, &revoked, &sess.IPAddress)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
sess.Revoked = revoked != 0
return sess, nil
}
// TouchClientSession updates expiry and last_used for sliding session renewal.
func (s *SQLiteDB) TouchClientSession(id int64, expiresAt, lastUsed string) error {
s.mu.Lock()
@@ -54,6 +54,79 @@ func TestClientSessionLifecycleSQLite(t *testing.T) {
}
}
func TestGetActiveClientSessionByClient(t *testing.T) {
database := openTestSQLiteDB(t)
defer database.Close()
user := &User{Username: "owner1", PasswordHash: "hash", Role: "admin"}
if err := database.CreateUser(user); err != nil {
t.Fatal(err)
}
expires := time.Now().UTC().Add(7 * 24 * time.Hour).Format("2006-01-02 15:04:05")
if err := database.CreateClientSession(&ClientSession{
TokenHash: "hash-a",
UserID: user.ID,
ClientID: "dev-a",
ClientUUID: "uuid-a",
ExpiresAt: expires,
}); err != nil {
t.Fatal(err)
}
got, err := database.GetActiveClientSessionByClient("dev-a", "")
if err != nil || got == nil || got.TokenHash != "hash-a" {
t.Fatalf("by client_id: err=%v got=%#v", err, got)
}
got, err = database.GetActiveClientSessionByClient("", "uuid-a")
if err != nil || got == nil || got.TokenHash != "hash-a" {
t.Fatalf("by client_uuid: err=%v got=%#v", err, got)
}
got, err = database.GetActiveClientSessionByClient("missing", "missing-uuid")
if err != nil || got != nil {
t.Fatalf("expected nil for unknown client, err=%v got=%#v", err, got)
}
}
func TestBindPeerOwnerAndApplyActiveSessionOwner(t *testing.T) {
database := openTestSQLiteDB(t)
defer database.Close()
user := &User{Username: "bounduser", PasswordHash: "hash", Role: "operator"}
if err := database.CreateUser(user); err != nil {
t.Fatal(err)
}
if err := database.UpsertPeer(&Peer{ID: "P-OWN", UUID: "u-own", Status: "ONLINE"}); err != nil {
t.Fatal(err)
}
BindPeerOwner(database, "P-OWN", "u-own", "bounduser")
peer, _ := database.GetPeer("P-OWN")
if peer == nil || peer.User != "bounduser" {
t.Fatalf("BindPeerOwner failed: %#v", peer)
}
// Login-before-peer: clear user, create session, re-apply via session lookup.
_ = database.UpdatePeerFields("P-OWN", map[string]string{"user": ""})
expires := time.Now().UTC().Add(24 * time.Hour).Format("2006-01-02 15:04:05")
if err := database.CreateClientSession(&ClientSession{
TokenHash: "hash-own",
UserID: user.ID,
ClientID: "P-OWN",
ClientUUID: "u-own",
ExpiresAt: expires,
}); err != nil {
t.Fatal(err)
}
ApplyActiveSessionOwner(database, "P-OWN", "u-own")
peer, _ = database.GetPeer("P-OWN")
if peer == nil || peer.User != "bounduser" {
t.Fatalf("ApplyActiveSessionOwner failed: %#v", peer)
}
}
func openTestSQLiteDB(t *testing.T) Database {
t.Helper()
db, err := OpenSQLite(":memory:")
+1
View File
@@ -711,6 +711,7 @@ type Database interface {
// RustDesk client sessions (Issue #242 — DB-backed opaque tokens with sliding expiry)
CreateClientSession(sess *ClientSession) error
GetClientSessionByTokenHash(tokenHash string) (*ClientSession, error)
GetActiveClientSessionByClient(clientID, clientUUID string) (*ClientSession, error)
TouchClientSession(id int64, expiresAt, lastUsed string) error
RevokeClientSessionByTokenHash(tokenHash string) error
RevokeClientSessionsForDevice(userID int64, clientID, clientUUID string) error
+3
View File
@@ -453,6 +453,9 @@ func (s *Server) processRegisterPk(msg *pb.RegisterPk, addrStr string) *pb.Rende
}
if err := s.db.UpsertPeer(dbPeer); err != nil {
log.Printf("[signal] Failed to upsert peer %s: %v", id, err)
} else {
// Bind peers.user when an active RustDesk client login exists for this device.
db.ApplyActiveSessionOwner(s.db, id, dbPeer.UUID)
}
log.Printf("[signal] PK registered for %s (pk=%d bytes)", id, len(msg.Pk))