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