diff --git a/CHANGELOG.md b/CHANGELOG.md index 878a5457..e725a804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/betterdesk-server/api/client_api_handlers.go b/betterdesk-server/api/client_api_handlers.go index b0d435d9..6aae644b 100644 --- a/betterdesk-server/api/client_api_handlers.go +++ b/betterdesk-server/api/client_api_handlers.go @@ -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 { diff --git a/betterdesk-server/api/client_sessions.go b/betterdesk-server/api/client_sessions.go index d97f8d4c..55933303 100644 --- a/betterdesk-server/api/client_sessions.go +++ b/betterdesk-server/api/client_sessions.go @@ -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 } diff --git a/betterdesk-server/api/client_sessions_test.go b/betterdesk-server/api/client_sessions_test.go index 00f924e3..02937a56 100644 --- a/betterdesk-server/api/client_sessions_test.go +++ b/betterdesk-server/api/client_sessions_test.go @@ -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() diff --git a/betterdesk-server/db/client_session_owner.go b/betterdesk-server/db/client_session_owner.go new file mode 100644 index 00000000..6a6720bb --- /dev/null +++ b/betterdesk-server/db/client_session_owner.go @@ -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 +} diff --git a/betterdesk-server/db/client_sessions_postgres.go b/betterdesk-server/db/client_sessions_postgres.go index 5e434356..15503a52 100644 --- a/betterdesk-server/db/client_sessions_postgres.go +++ b/betterdesk-server/db/client_sessions_postgres.go @@ -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, diff --git a/betterdesk-server/db/client_sessions_sqlite.go b/betterdesk-server/db/client_sessions_sqlite.go index 7ef74e07..19c00047 100644 --- a/betterdesk-server/db/client_sessions_sqlite.go +++ b/betterdesk-server/db/client_sessions_sqlite.go @@ -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() diff --git a/betterdesk-server/db/client_sessions_test.go b/betterdesk-server/db/client_sessions_test.go index a9ded84f..93e0b60d 100644 --- a/betterdesk-server/db/client_sessions_test.go +++ b/betterdesk-server/db/client_sessions_test.go @@ -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:") diff --git a/betterdesk-server/db/database.go b/betterdesk-server/db/database.go index 6623a5ae..9cc564c8 100644 --- a/betterdesk-server/db/database.go +++ b/betterdesk-server/db/database.go @@ -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 diff --git a/betterdesk-server/signal/handler.go b/betterdesk-server/signal/handler.go index 7a52a730..e32d4107 100644 --- a/betterdesk-server/signal/handler.go +++ b/betterdesk-server/signal/handler.go @@ -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))