Files
UNITRONIX b73648651d feat(console): add Client Branding for desktop remotes
Admin page and Go /api/branding schema v1 with logo/contact fields,
RustDesk-safe heartbeat projections, i18n, and handler tests.

Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
2026-09-06 12:26:39 +02:00

1087 lines
32 KiB
Go

// RustDesk Client API handlers.
// Provides RustDesk-compatible endpoints on the Go server's consolidated API port
// (default :21121, overridable via -api-port / API_PORT). When the RustDesk client
// has no explicit API Server URL, it may fall back to signal_port - 2 (21114).
//
// Endpoints:
//
// POST /api/login — RustDesk-compatible login (username/password + TOTP)
// GET /api/login-options — Available authentication methods
// POST /api/logout — Invalidate session (no-op for stateless JWT)
// GET /api/currentUser — Get current user info (Bearer token required)
// POST /api/ab — Get/update address book
// GET /api/ab — Get address book
package api
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/unitronix/betterdesk-server/audit"
"github.com/unitronix/betterdesk-server/auth"
"github.com/unitronix/betterdesk-server/db"
)
// tfaSession holds temporary state for a two-factor auth flow in progress.
type tfaSession struct {
username string
role string
userID int64
clientID string
clientUUID string
clientIP string
createdAt time.Time
}
// tfaSessionStore is a concurrency-safe in-memory store for pending 2FA sessions.
// Sessions expire after 5 minutes.
type tfaSessionStore struct {
mu sync.Mutex
sessions map[string]*tfaSession
}
func newTFASessionStore() *tfaSessionStore {
return &tfaSessionStore{sessions: make(map[string]*tfaSession)}
}
func (s *tfaSessionStore) put(secret string, sess *tfaSession) {
s.mu.Lock()
defer s.mu.Unlock()
// Prune expired sessions (>5 min)
now := time.Now()
for k, v := range s.sessions {
if now.Sub(v.createdAt) > 5*time.Minute {
delete(s.sessions, k)
}
}
// Limit total sessions to prevent memory exhaustion
if len(s.sessions) >= 1000 {
s.mu.Unlock()
return
}
s.sessions[secret] = sess
}
func (s *tfaSessionStore) take(secret string) *tfaSession {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[secret]
if !ok {
return nil
}
delete(s.sessions, secret)
if time.Since(sess.createdAt) > 5*time.Minute {
return nil // expired
}
return sess
}
// rustdeskUserPayload builds a user object in the format the RustDesk client expects.
// RustDesk 1.4.x requires `info` (UserInfo) with no serde default — omitting it makes
// auth-query success JSON fail to deserialize, so the client stays on "Waiting account auth" (#326).
func rustdeskUserPayload(username, role string) map[string]any {
return map[string]any{
"name": username,
"email": "",
"note": "",
"status": 1, // kNormal
"grp": "",
"is_admin": role == auth.RoleAdmin,
"display_name": username,
"info": map[string]any{},
}
}
// handleClientLogin processes RustDesk desktop client login requests.
// POST /api/login
//
// Request body (initial login):
//
// { "username": "...", "password": "...", "id": "DEVICE_ID", "uuid": "...", "type": "account" }
//
// Request body (2FA verification):
//
// { "verificationCode": "123456", "secret": "hex...", "id": "DEVICE_ID", "uuid": "..." }
//
// Response (success):
//
// { "type": "access_token", "access_token": "jwt...", "user": { "name": ..., "is_admin": ... } }
//
// Response (2FA required):
//
// { "type": "email_check", "tfa_type": "tfa_check", "secret": "hex..." }
//
// RustDesk 1.4.7+ expects email_check + tfa_type tfa_check (not type tfa_check).
func (s *Server) handleClientLogin(w http.ResponseWriter, r *http.Request) {
clientIP := s.remoteIP(r)
// Rate limiting
if s.loginLimiter != nil && !s.loginLimiter.Allow(clientIP) {
writeJSON(w, http.StatusTooManyRequests, map[string]string{
"error": "Too many login attempts. Please try again later.",
})
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
ID string `json:"id"` // RustDesk device ID
UUID string `json:"uuid"` // RustDesk device UUID
Type string `json:"type"` // "account" or "email_code"
VerificationCode string `json:"verificationCode"`
TfaCode string `json:"tfaCode"`
Secret string `json:"secret"` // TFA session secret
AutoLogin bool `json:"autoLogin"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("[api] /api/login: JSON decode error from %s: %v", clientIP, err)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON"})
return
}
// DEBUG: Log incoming login request fields (excluding password)
log.Printf("[api] /api/login from %s: user=%q type=%q code=%q secret_len=%d id=%q uuid=%q",
clientIP, body.Username, body.Type, body.VerificationCode, len(body.Secret), body.ID, body.UUID)
totpCode := body.VerificationCode
if totpCode == "" {
totpCode = body.TfaCode
}
// ── TFA verification step ──
if totpCode != "" && body.Secret != "" {
s.handleClientTFAVerify(w, clientIP, totpCode, body.Secret)
return
}
// ── Initial login step ──
if body.Username == "" || body.Password == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Missing credentials"})
return
}
if s.loginLimiter != nil && !s.loginLimiter.Allow("user:"+strings.ToLower(body.Username)) {
writeJSON(w, http.StatusTooManyRequests, map[string]string{
"error": "Too many login attempts. Please try again later.",
})
return
}
login := s.authenticatePasswordLogin(body.Username, body.Password, clientIP)
switch login.Status {
case passwordLoginInternal:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal error"})
return
case passwordLoginOIDC:
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "This account uses single sign-on. Please log in with your identity provider."})
return
case passwordLoginInvalid:
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid credentials"})
return
}
user := login.User
// Check if TOTP 2FA is required
if user.TOTPEnabled && user.TOTPSecret != "" {
secret := make([]byte, 16)
if _, err := rand.Read(secret); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal error"})
return
}
tfaSecret := hex.EncodeToString(secret)
s.clientTFASessions.put(tfaSecret, &tfaSession{
username: user.Username,
role: user.Role,
userID: user.ID,
clientID: body.ID,
clientUUID: body.UUID,
clientIP: clientIP,
createdAt: time.Now(),
})
if s.auditLog != nil {
s.auditLog.Log(audit.ActionAuthLoginFailed, clientIP, user.Username,
map[string]string{"reason": "2fa_required", "client_id": body.ID})
}
writeJSON(w, http.StatusOK, map[string]any{
"type": "email_check",
"tfa_type": "tfa_check",
"secret": tfaSecret,
})
return
}
// No 2FA — issue client session token
token, err := s.issueClientSession(user, body.ID, body.UUID, clientIP)
if err != nil {
log.Printf("[api] /api/login: issueClientSession failed for user=%q id=%q uuid=%q: %v",
user.Username, body.ID, body.UUID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Token generation failed"})
return
}
_ = s.db.UpdateUserLogin(user.ID)
auditFields := map[string]string{"client_id": body.ID}
if login.AuthMethod != "" {
auditFields["method"] = login.AuthMethod
}
if s.auditLog != nil {
s.auditLog.Log(audit.ActionAuthLogin, clientIP, user.Username, auditFields)
}
writeJSON(w, http.StatusOK, map[string]any{
"type": "access_token",
"access_token": token,
"user": rustdeskUserPayload(user.Username, user.Role),
})
}
// handleClientTFAVerify completes the TOTP step for a RustDesk client login.
func (s *Server) handleClientTFAVerify(w http.ResponseWriter, clientIP, totpCode, secret string) {
sess := s.clientTFASessions.take(secret)
if sess == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid or expired TFA session"})
return
}
// Validate TOTP code length (6 digits)
if len(totpCode) != 6 {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid verification code"})
return
}
user, err := s.db.GetUser(sess.username)
if err != nil || user == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "User not found"})
return
}
if !auth.ValidateTOTP(user.TOTPSecret, totpCode) {
if s.auditLog != nil {
s.auditLog.Log(audit.ActionAuthLoginFailed, clientIP, sess.username,
map[string]string{"reason": "invalid_totp", "client_id": sess.clientID})
}
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid verification code"})
return
}
token, err := s.issueClientSession(user, sess.clientID, sess.clientUUID, clientIP)
if err != nil {
log.Printf("[api] /api/login TFA: issueClientSession failed for user=%q id=%q uuid=%q: %v",
user.Username, sess.clientID, sess.clientUUID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Token generation failed"})
return
}
_ = s.db.UpdateUserLogin(user.ID)
if s.auditLog != nil {
s.auditLog.Log(audit.ActionAuthLogin, clientIP, user.Username,
map[string]string{"client_id": sess.clientID, "method": "totp"})
}
writeJSON(w, http.StatusOK, map[string]any{
"type": "access_token",
"access_token": token,
"user": rustdeskUserPayload(user.Username, user.Role),
})
}
// handleClientLoginOptions returns available authentication methods.
// GET /api/login-options
// Stock RustDesk expects [""] for password plus "oidc/<name>" entries when SSO is enabled.
func (s *Server) handleClientLoginOptions(w http.ResponseWriter, r *http.Request) {
opts := []string{""}
if s.oidcProvider != nil && s.oidcProvider.IsEnabled() {
opts = append(opts, s.oidcProvider.ClientLoginOptionToken())
}
writeJSON(w, http.StatusOK, opts)
}
// handleClientLogout handles logout for RustDesk clients.
// POST /api/logout
func (s *Server) handleClientLogout(w http.ResponseWriter, r *http.Request) {
s.revokeClientSessionToken(bearerTokenFromRequest(r))
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleClientCurrentUser returns the current user info from Bearer token.
// GET /api/currentUser
func (s *Server) handleClientCurrentUser(w http.ResponseWriter, r *http.Request) {
username := getUsernameFromCtx(r)
role := getRoleFromCtx(r)
if username == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Not authenticated"})
return
}
writeJSON(w, http.StatusOK, rustdeskUserPayload(username, role))
}
type clientAbPostBody struct {
Data json.RawMessage `json:"data"`
}
// decodeClientAbPostBody parses RustDesk legacy address book POST envelopes.
// emptyBody is true when the client sent no JSON (Content-Length: 0), which
// RustDesk 1.4.x uses for /api/ab/personal legacy-mode probing.
func decodeClientAbPostBody(r *http.Request, endpoint, username string) (clientAbPostBody, bool, error) {
var body clientAbPostBody
err := json.NewDecoder(r.Body).Decode(&body)
if err != nil {
if errors.Is(err, io.EOF) {
return clientAbPostBody{}, true, nil
}
log.Printf("[api] %s JSON decode error for %s: %v (Content-Length: %s)",
endpoint, username, err, r.Header.Get("Content-Length"))
return clientAbPostBody{}, false, err
}
return body, false, nil
}
func abDataFieldPresent(data json.RawMessage) bool {
if len(data) == 0 {
return false
}
s := strings.TrimSpace(string(data))
return s != "" && s != "null"
}
func normalizeAbDataField(data json.RawMessage) string {
dataStr := string(data)
if len(dataStr) > 0 && dataStr[0] == '"' {
var unquoted string
if err := json.Unmarshal(data, &unquoted); err == nil {
dataStr = unquoted
}
}
if dataStr == "" || dataStr == "null" {
dataStr = "{}"
}
return dataStr
}
// handleClientAddressBook handles address book get/set for RustDesk clients.
// GET /api/ab — get legacy address book
// POST /api/ab — update legacy address book
func (s *Server) handleClientAddressBook(w http.ResponseWriter, r *http.Request) {
username := getUsernameFromCtx(r)
role := getRoleFromCtx(r)
if username == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Not authenticated"})
return
}
switch r.Method {
case http.MethodGet:
data, err := s.db.GetAddressBook(username, "legacy")
if err != nil {
log.Printf("[api] GetAddressBook error for %s: %v", username, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal error"})
return
}
data = s.mergeOrgAddressBooksIntoAB(r, data)
data = s.injectOrgPeerCredentialsIntoAB(r, data)
if !auth.IsProRole(role) {
// Merge admin-set tags from peers table into AB (#76 TAG sync)
data = s.mergeAdminTagsIntoAB(data)
// RustDesk legacy AB reads tags from GET /api/ab (not /api/ab/tags).
data = s.syncServerTagsIntoAddressBook(data, r, username, role)
}
// Enforce device-group / folder ACL on Address Book peers (org merge + stale entries).
data = s.applyDeviceScopeToAddressBook(r, username, role, data)
writeJSON(w, http.StatusOK, map[string]any{"data": data, "licensed_devices": 0})
case http.MethodPost:
body, emptyBody, err := decodeClientAbPostBody(r, "POST /api/ab", username)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
return
}
dataStr := "{}"
if !emptyBody {
dataStr = normalizeAbDataField(body.Data)
}
// Limit AB size to 512 KB
if len(dataStr) > 512*1024 {
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "Address book too large"})
return
}
if err := s.db.SaveAddressBook(username, "legacy", dataStr); err != nil {
log.Printf("[api] SaveAddressBook error for %s: %v", username, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal error"})
return
}
s.syncAddressBookTagsToPeers(username, role, "legacy", dataStr)
log.Printf("[api] Saved legacy address book for %s (%d bytes)", username, len(dataStr))
writeJSON(w, http.StatusOK, map[string]any{})
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"})
}
}
// handleClientAddressBookPersonal handles personal address book get/set.
// GET /api/ab/personal — get personal address book
// POST /api/ab/personal — update personal address book
func (s *Server) handleClientAddressBookPersonal(w http.ResponseWriter, r *http.Request) {
username := getUsernameFromCtx(r)
role := getRoleFromCtx(r)
if username == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Not authenticated"})
return
}
switch r.Method {
case http.MethodGet:
data, err := s.db.GetAddressBook(username, "personal")
if err != nil {
log.Printf("[api] GetAddressBook(personal) error for %s: %v", username, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal error"})
return
}
if !auth.IsProRole(role) {
data = s.mergeAdminTagsIntoAB(data)
}
data = s.applyDeviceScopeToAddressBook(r, username, role, data)
writeJSON(w, http.StatusOK, map[string]any{"data": data})
case http.MethodPost:
body, emptyBody, err := decodeClientAbPostBody(r, "POST /api/ab/personal", username)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
return
}
// RustDesk 1.4.7 probes with an empty POST body; legacy servers return 404 (PR #14813).
if emptyBody || !abDataFieldPresent(body.Data) {
http.NotFound(w, r)
return
}
dataStr := normalizeAbDataField(body.Data)
if len(dataStr) > 512*1024 {
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "Address book too large"})
return
}
if err := s.db.SaveAddressBook(username, "personal", dataStr); err != nil {
log.Printf("[api] SaveAddressBook(personal) error for %s: %v", username, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal error"})
return
}
s.syncAddressBookTagsToPeers(username, role, "personal", dataStr)
log.Printf("[api] Saved personal address book for %s (%d bytes)", username, len(dataStr))
writeJSON(w, http.StatusOK, map[string]any{})
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"})
}
}
func normalizeClientTags(value any) []string {
var raw []string
switch v := value.(type) {
case []any:
for _, item := range v {
if tag, ok := item.(string); ok {
raw = append(raw, tag)
}
}
case []string:
raw = append(raw, v...)
case string:
if strings.TrimSpace(v) == "" {
return nil
}
if strings.HasPrefix(strings.TrimSpace(v), "[") {
var arr []string
if err := json.Unmarshal([]byte(v), &arr); err == nil {
raw = append(raw, arr...)
} else {
raw = strings.Split(v, ",")
}
} else {
raw = strings.Split(v, ",")
}
default:
return nil
}
seen := make(map[string]bool, len(raw))
tags := make([]string, 0, len(raw))
for _, tag := range raw {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
if len(tag) > 50 {
tag = tag[:50]
}
if seen[tag] {
continue
}
seen[tag] = true
tags = append(tags, tag)
if len(tags) >= 20 {
break
}
}
return tags
}
func (s *Server) syncAddressBookTagsToPeers(username, role, abType, data string) {
if role == auth.RolePro || !auth.RoleHasPermission(role, auth.PermDeviceEdit) {
return
}
var ab struct {
Peers []map[string]any `json:"peers"`
}
if err := json.Unmarshal([]byte(data), &ab); err != nil || len(ab.Peers) == 0 {
return
}
synced := 0
seenPeers := make(map[string]bool, len(ab.Peers))
for _, peer := range ab.Peers {
id, _ := peer["id"].(string)
id = strings.TrimSpace(id)
if id == "" || seenPeers[id] {
continue
}
if _, hasTags := peer["tags"]; !hasTags {
continue
}
seenPeers[id] = true
tags := normalizeClientTags(peer["tags"])
if err := s.db.UpdatePeerTags(id, strings.Join(tags, ",")); err != nil {
log.Printf("[api] Address book tag sync failed for peer %s user=%s: %v", id, username, err)
continue
}
synced++
}
if synced > 0 {
log.Printf("[api] Synced %d peer tag set(s) from %s address book for %s", synced, abType, username)
}
}
// mergeAdminTagsIntoAB merges admin-set tags from the peers table into the
// address book data. For each peer in the AB that also exists in the peers
// table with non-empty tags, the admin tags are added to the peer's tag list.
// The global tags[] array is also extended with any new admin tags.
// Peer hostname/platform/alias are enriched from the peers table if missing.
// This implements TAG sync (Issue #76) and sysinfo enrichment (Issue #138).
//
// IMPORTANT: We use map[string]any for the top-level AB object to preserve
// ALL fields (including tag_colors, rule, etc.) that the RustDesk client
// sends/expects. A typed struct would silently drop unknown fields on
// re-serialization, causing "type 'String' is not a subtype of type 'int'"
// errors in the Dart client when tag_colors disappears.
func (s *Server) mergeAdminTagsIntoAB(data string) string {
if data == "" || data == "{}" {
return data
}
// Unmarshal into a generic map to preserve ALL fields (tag_colors, etc.)
var ab map[string]any
if err := json.Unmarshal([]byte(data), &ab); err != nil {
return data
}
// Extract peers array
peersRaw, _ := ab["peers"].([]any)
if len(peersRaw) == 0 {
return data
}
peers := make([]map[string]any, 0, len(peersRaw))
for _, p := range peersRaw {
if pm, ok := p.(map[string]any); ok {
peers = append(peers, pm)
}
}
if len(peers) == 0 {
return data
}
// Extract existing tags array
var existingTags []string
if tagsRaw, ok := ab["tags"].([]any); ok {
for _, t := range tagsRaw {
if ts, ok := t.(string); ok {
existingTags = append(existingTags, ts)
}
}
}
// Collect all peer IDs from the AB
ids := make([]string, 0, len(peers))
for _, p := range peers {
if id, ok := p["id"].(string); ok && id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return data
}
// Build maps of peer_id → admin tags and peer_id → sysinfo from the peers table
adminTags := make(map[string][]string)
peerInfo := make(map[string]*db.Peer)
bannedPeers := make(map[string]bool)
peerMap, err := s.db.GetPeersByIDs(ids)
if err != nil {
return data
}
for id, peer := range peerMap {
if peer == nil {
continue
}
// Issue #138: track banned/deleted peers so they can be stripped from the AB
if peer.Banned || peer.SoftDeleted {
bannedPeers[id] = true
continue
}
peerInfo[id] = peer
if peer.Tags == "" {
continue
}
tags := strings.Split(peer.Tags, ",")
cleaned := make([]string, 0, len(tags))
for _, t := range tags {
t = strings.TrimSpace(t)
if t != "" {
cleaned = append(cleaned, t)
}
}
if len(cleaned) > 0 {
adminTags[id] = cleaned
}
}
if len(adminTags) == 0 && len(peerInfo) == 0 {
return data
}
// Build a set of existing global tags
tagSet := make(map[string]bool)
for _, t := range existingTags {
tagSet[t] = true
}
// Merge admin tags and sysinfo into each peer; strip banned/deleted peers
modified := false
filtered := make([]map[string]any, 0, len(peers))
for _, p := range peers {
id, ok := p["id"].(string)
if !ok || id == "" {
filtered = append(filtered, p)
continue
}
// Issue #138: remove banned/deleted peers from the address book entirely
if bannedPeers[id] {
modified = true
continue
}
filtered = append(filtered, p)
// Enrich peer with sysinfo from peers table (Issue #138: OS icon/name not showing)
if info, ok := peerInfo[id]; ok {
if _, hasHostname := p["hostname"]; !hasHostname || p["hostname"] == "" {
if info.Hostname != "" {
p["hostname"] = info.Hostname
modified = true
}
}
if _, hasPlatform := p["platform"]; !hasPlatform || p["platform"] == "" {
if info.OS != "" {
p["platform"] = info.OS
modified = true
}
}
if _, hasUsername := p["username"]; !hasUsername || p["username"] == "" {
if info.User != "" {
p["username"] = info.User
modified = true
}
}
}
// Merge admin tags
atags, hasAdminTags := adminTags[id]
if !hasAdminTags {
continue
}
// Get existing peer tags
existing := make(map[string]bool)
if arr, ok := p["tags"].([]any); ok {
for _, v := range arr {
if s, ok := v.(string); ok {
existing[s] = true
}
}
}
// Add admin tags that aren't already present
merged := make([]string, 0, len(existing)+len(atags))
if arr, ok := p["tags"].([]any); ok {
for _, v := range arr {
if s, ok := v.(string); ok {
merged = append(merged, s)
}
}
}
for _, t := range atags {
if !existing[t] {
merged = append(merged, t)
existing[t] = true
}
// Add to global tags if new
if !tagSet[t] {
existingTags = append(existingTags, t)
tagSet[t] = true
}
}
p["tags"] = merged
modified = true
}
if !modified {
return data
}
// Write back the modified peers and tags into the original map
// Use filtered list (banned/deleted peers removed)
peersAny := make([]any, len(filtered))
for i, p := range filtered {
peersAny[i] = p
}
ab["peers"] = peersAny
// Convert tags to []any
tagsAny := make([]any, len(existingTags))
for i, t := range existingTags {
tagsAny[i] = t
}
ab["tags"] = tagsAny
// Re-serialize — all other fields (tag_colors, rule, etc.) are preserved
out, err := json.Marshal(ab)
if err != nil {
return data
}
return string(out)
}
// handleClientAddressBookTags returns tags from the legacy address book.
// GET /api/ab/tags
func (s *Server) handleClientAddressBookTags(w http.ResponseWriter, r *http.Request) {
username := getUsernameFromCtx(r)
role := getRoleFromCtx(r)
if username == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Not authenticated"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": s.collectRustDeskTags(r, username, role)})
}
// handleClientGroupList returns panel device groups and folders (auth.db) for /api/group.
// Peer tags are exposed separately via /api/ab/tags and the tags field on /api/peers/list.
//
// GET /api/group, /api/group/get
// POST /api/group/get
func (s *Server) handleClientGroupList(w http.ResponseWriter, r *http.Request) {
if getUsernameFromCtx(r) == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Not authenticated"})
return
}
built := s.buildRustDeskDeviceGroups(r)
accessibleOnly := strings.Contains(r.URL.Path, "/device-group")
payload := make([]map[string]any, 0, len(built))
for i, g := range built {
if accessibleOnly {
// RustDesk GroupModel (/api/device-group/accessible) reads "name" only.
payload = append(payload, rustDeskAccessibleDeviceGroupPayload(g, i))
} else {
payload = append(payload, rustDeskGroupPayload(g, i))
}
}
writeJSON(w, http.StatusOK, map[string]any{
"total": len(payload),
"data": payload,
"msg": "success",
})
}
// handleClientGroupPeers returns all registered peers in the {total,data,msg}
// envelope expected by RustDesk PRO Flutter clients. Peers are enriched with
// hostname, platform, and username from the peers table so the RustDesk client
// can display OS icons and system names.
//
// GET /api/peers/list
//
// Compatibility shim suggested by progloto in PR #81, enhanced for Issue #138.
func (s *Server) handleClientGroupPeers(w http.ResponseWriter, r *http.Request) {
if getUsernameFromCtx(r) == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Not authenticated"})
return
}
data, total := s.buildRustDeskPeerList(r)
writeJSON(w, http.StatusOK, map[string]any{
"total": total,
"data": data,
"msg": "success",
})
}
// handleClientHeartbeat accepts heartbeat pings from RustDesk clients.
// POST /api/heartbeat
// Request: { "id": "DEVICE_ID", "uuid": "...", "modified_at": 0, ... }
// Response: { "modified_at": <i64 ms>, "strategy"?: { "config_options": {...} }, "sysinfo"?: true }
func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) {
// BD-2026-001: Rate-limit heartbeat requests per IP
clientIP := s.remoteIP(r)
if !s.heartbeatLimiter.Allow(clientIP) {
writeJSON(w, http.StatusOK, map[string]any{"modified_at": s.clientBrandingModifiedAt()})
return
}
var body struct {
ID string `json:"id"`
UUID string `json:"uuid"`
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
Disk float64 `json:"disk"`
ModifiedAt int64 `json:"modified_at"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusOK, map[string]any{"modified_at": s.clientBrandingModifiedAt()})
return
}
deviceID := body.ID
if deviceID == "" {
deviceID = body.UUID
}
if deviceID == "" || !peerIDRegexp.MatchString(deviceID) {
writeJSON(w, http.StatusOK, map[string]any{"modified_at": s.clientBrandingModifiedAt()})
return
}
// Verify peer exists
peer, err := s.db.GetPeer(deviceID)
if err != nil || peer == nil {
writeJSON(w, http.StatusOK, map[string]any{"modified_at": s.clientBrandingModifiedAt()})
return
}
if peer.Banned {
writeJSON(w, http.StatusOK, map[string]any{"error": "BANNED", "modified_at": s.clientBrandingModifiedAt()})
return
}
// 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 {
log.Printf("[api] Failed to save peer metrics for %s: %v", deviceID, err)
}
}
serverModifiedAt := s.clientBrandingModifiedAt()
resp := map[string]any{
"modified_at": serverModifiedAt,
}
// Request sysinfo if hostname is empty (never received)
if peer.Hostname == "" {
resp["sysinfo"] = true
}
// Push RustDesk-compatible branding subset when client cursor is stale.
if body.ModifiedAt != serverModifiedAt {
branding := s.loadBrandingConfig()
opts := branding.Profiles.RustDesk.ConfigOptions
if len(opts) > 0 {
resp["strategy"] = map[string]any{
"config_options": opts,
}
}
}
if policy, err := s.db.GetAccessPolicy(deviceID); err == nil && policy != nil {
resp["access_policy"] = map[string]any{
"unattended_enabled": policy.UnattendedEnabled,
"password_set": policy.PasswordSet,
"schedule_enabled": policy.ScheduleEnabled,
}
}
writeJSON(w, http.StatusOK, resp)
}
// clientBrandingModifiedAt returns branding revision as Unix ms for heartbeat cursors.
func (s *Server) clientBrandingModifiedAt() int64 {
cfg := s.loadBrandingConfig()
if ms := brandingRevisionMillis(cfg); ms > 0 {
return ms
}
// Stable non-zero when no branding saved yet so clients can latch a cursor.
return 1
}
// handleClientSysinfo receives hardware/software info from RustDesk clients.
// POST /api/sysinfo
// Request: { "id": "DEVICE_ID", "hostname": "...", "platform": "...", "os": "...", "version": "..." ... }
// Response: plain text "SYSINFO_UPDATED" (activates PRO mode in client),
//
// "ID_NOT_FOUND" (client retries), or "ERROR".
func (s *Server) handleClientSysinfo(w http.ResponseWriter, r *http.Request) {
// BD-2026-001: Rate-limit sysinfo requests per IP
if !s.heartbeatLimiter.Allow(s.remoteIP(r)) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("ID_NOT_FOUND")) //nolint:errcheck
return
}
var body struct {
ID string `json:"id"`
UUID string `json:"uuid"`
Hostname string `json:"hostname"`
Platform string `json:"platform"`
OS string `json:"os"`
Version string `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("ID_NOT_FOUND")) //nolint:errcheck
return
}
deviceID := body.ID
if deviceID == "" {
deviceID = body.UUID
}
if deviceID == "" || !peerIDRegexp.MatchString(deviceID) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("ID_NOT_FOUND")) //nolint:errcheck
return
}
// Verify peer exists
peer, err := s.db.GetPeer(deviceID)
if err != nil || peer == nil {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("ID_NOT_FOUND")) //nolint:errcheck
return
}
if peer.Banned {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("ID_NOT_FOUND")) //nolint:errcheck
return
}
// Use platform or os field (RustDesk client may send either)
osValue := body.Platform
if osValue == "" {
osValue = body.OS
}
// Truncate fields to safe lengths
hostname := truncate(body.Hostname, 255)
osVal := truncate(osValue, 255)
version := truncate(body.Version, 64)
if err := s.db.UpdatePeerSysinfo(deviceID, hostname, osVal, version); err != nil {
s.auditLog.Log(audit.ActionSysinfoError, deviceID, "sysinfo", map[string]string{"error": err.Error()})
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("ERROR")) //nolint:errcheck
return
}
s.auditLog.Log(audit.ActionSysinfoUpdated, deviceID, "sysinfo", map[string]string{
"hostname": hostname,
"os": osVal,
"version": version,
})
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("SYSINFO_UPDATED")) //nolint:errcheck
}
// handleClientSysinfoVer checks if sysinfo needs to be re-uploaded.
// POST /api/sysinfo_ver
// Returns a hash of existing sysinfo; empty response triggers full upload.
func (s *Server) handleClientSysinfoVer(w http.ResponseWriter, r *http.Request) {
// BD-2026-001: Rate-limit sysinfo_ver requests per IP
if !s.heartbeatLimiter.Allow(s.remoteIP(r)) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("")) //nolint:errcheck
return
}
var body struct {
ID string `json:"id"`
UUID string `json:"uuid"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("")) //nolint:errcheck
return
}
deviceID := body.ID
if deviceID == "" {
deviceID = body.UUID
}
if deviceID == "" || !peerIDRegexp.MatchString(deviceID) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("")) //nolint:errcheck
return
}
peer, err := s.db.GetPeer(deviceID)
if err != nil || peer == nil || peer.Hostname == "" {
// No sysinfo → trigger upload
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("")) //nolint:errcheck
return
}
// Build a deterministic hash from stored sysinfo fields
h := sha256.New()
h.Write([]byte(peer.Hostname))
h.Write([]byte(peer.OS))
h.Write([]byte(peer.Version))
hash := hex.EncodeToString(h.Sum(nil))[:16]
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(hash)) //nolint:errcheck
}
// truncate returns s capped at maxLen bytes.
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen]
}