Files
BetterDesk/betterdesk-server/api/server.go
T
UNITRONIX ed96e94806 refactor(agent): enhance session management and input handling
- Updated session control mechanisms to ensure proper handling of remote input and clipboard operations.
- Introduced session authorization checks to validate operator permissions before starting desktop sessions.
- Improved input injection logic to prevent unauthorized access during active sessions.
- Added new capabilities for managing session flags and controls, ensuring a more robust and secure desktop experience.
- Enhanced error handling and logging for better traceability of session-related actions.
2026-08-06 00:50:02 +02:00

2138 lines
79 KiB
Go

// Package api implements the BetterDesk HTTP REST API server.
// Provides endpoints for peer management, server health, statistics,
// detailed device status, blocklist management, and bandwidth stats.
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/unitronix/betterdesk-server/audit"
"github.com/unitronix/betterdesk-server/auth"
"github.com/unitronix/betterdesk-server/billing"
"github.com/unitronix/betterdesk-server/cdap"
"github.com/unitronix/betterdesk-server/config"
"github.com/unitronix/betterdesk-server/crypto"
"github.com/unitronix/betterdesk-server/db"
eventsModule "github.com/unitronix/betterdesk-server/events"
"github.com/unitronix/betterdesk-server/meshcentral"
"github.com/unitronix/betterdesk-server/metrics"
"github.com/unitronix/betterdesk-server/peer"
"github.com/unitronix/betterdesk-server/ratelimit"
"github.com/unitronix/betterdesk-server/relay"
"github.com/unitronix/betterdesk-server/security"
"github.com/unitronix/betterdesk-server/timesync"
"github.com/coder/websocket"
"golang.org/x/crypto/bcrypt"
)
// peerIDRegexp validates RustDesk peer ID format: 6-16 alphanumeric chars, hyphens, underscores.
// Mirrors the validation in signal/handler.go — must be kept in sync.
var peerIDRegexp = regexp.MustCompile(`^[A-Za-z0-9_-]{6,16}$`)
// configKeyRegexp validates config key names: 1-64 alphanumeric chars, underscores, hyphens, dots.
// Prevents arbitrary key injection into the server_config table.
var configKeyRegexp = regexp.MustCompile(`^[A-Za-z0-9_.\-]{1,64}$`)
// ldapAuthProvider is implemented by *auth.LDAPProvider and test mocks.
type ldapAuthProvider interface {
IsEnabled() bool
Authenticate(username, password string) (*auth.LDAPResult, error)
UpdateConfig(cfg *auth.LDAPConfig)
TestConnection() error
Config() auth.LDAPConfig
}
// Server is the HTTP API server.
type Server struct {
cfg *config.Config
db db.Database
peers *peer.Map
relay *relay.Server
blocklist *security.Blocklist
bwLimiter *ratelimit.BandwidthLimiter
auditLog *audit.Logger
eventBus *eventsModule.Bus
metrics *metrics.Collector
jwtManager *auth.JWTManager
loginLimiter *ratelimit.IPLimiter
heartbeatLimiter *ratelimit.IPLimiter // BD-2026-001: rate-limit heartbeat/sysinfo
// SECURITY (audit fix M-07, 2026-04-10): rate-limit public enrollment and
// 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)
meshGw *meshcentral.Gateway // MeshCentral compat (nil if disabled)
ldapProvider ldapAuthProvider // LDAP auth provider (nil if not configured)
oidcProvider *auth.OIDCProvider // OIDC/OAuth2 auth provider (nil if not configured)
clientTFASessions *tfaSessionStore
panelStore db.PanelSyncStore // device groups, folders, ACL (PostgreSQL or legacy auth.db)
timeSync *timesync.Service
billing *billing.Service
httpSrv *http.Server
wg sync.WaitGroup
version string
}
// SetPanelStore attaches the panel data source for RustDesk group/folder sync.
func (s *Server) SetPanelStore(p db.PanelSyncStore) {
s.panelStore = p
}
// SetConsoleAuth attaches legacy auth.db access (SQLite deployments without consolidated DB).
func (s *Server) SetConsoleAuth(c *db.ConsoleAuthDB) {
s.panelStore = c
}
// New creates a new API server.
func New(cfg *config.Config, database db.Database, peerMap *peer.Map, relaySrv *relay.Server, version string) *Server {
return &Server{
cfg: cfg,
db: database,
peers: peerMap,
relay: relaySrv,
version: version,
loginLimiter: ratelimit.NewIPLimiter(5, 5*time.Minute, 10*time.Minute),
heartbeatLimiter: ratelimit.NewIPLimiter(20, 60*time.Second, 5*time.Minute), // BD-2026-001: 20 req/min per IP
enrollmentLimiter: ratelimit.NewIPLimiter(20, 1*time.Minute, 5*time.Minute), // M-07: 20/min per IP, 5-min block
brandingLimiter: ratelimit.NewIPLimiter(60, 1*time.Minute, 5*time.Minute), // M-07: 60/min per IP
clientTFASessions: newTFASessionStore(),
}
}
// rateLimitPublic wraps a public (no-auth) handler with the supplied IP limiter.
// Returns HTTP 429 with a JSON body when the per-IP budget is exhausted.
// Used by audit fix M-07 (enrollment, branding endpoints).
func (s *Server) rateLimitPublic(lim *ratelimit.IPLimiter, h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if lim != nil {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
if ip == "" {
ip = r.RemoteAddr
}
if !lim.Allow(ip) {
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "rate limit exceeded"})
return
}
}
h(w, r)
}
}
// metricsGuard enforces the audit fix H-03 access policy for /metrics:
// - cfg.MetricsPublic=true => unrestricted (legacy/dev)
// - cfg.MetricsAllowlist non-empty => caller IP must match one entry
// - otherwise => caller must present a valid bearer/api-key
func (s *Server) metricsGuard(h http.HandlerFunc) http.HandlerFunc {
allowlist := s.cfg.GetMetricsAllowlist()
public := s.cfg.MetricsPublic
return func(w http.ResponseWriter, r *http.Request) {
if public {
h(w, r)
return
}
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
if ip == "" {
ip = r.RemoteAddr
}
if len(allowlist) > 0 && ipInAllowlist(ip, allowlist) {
h(w, r)
return
}
// Fall back to standard auth middleware (JWT / API key).
s.authMiddleware(h).ServeHTTP(w, r)
}
}
// ipInAllowlist reports whether ip matches any literal IP or CIDR in list.
func ipInAllowlist(ip string, list []string) bool {
parsed := net.ParseIP(ip)
if parsed == nil {
return false
}
for _, entry := range list {
if entry == ip {
return true
}
if _, cidr, err := net.ParseCIDR(entry); err == nil && cidr.Contains(parsed) {
return true
}
}
return false
}
// SetBlocklist sets the blocklist instance for the API server.
func (s *Server) SetBlocklist(bl *security.Blocklist) {
s.blocklist = bl
}
// SetBandwidthLimiter sets the bandwidth limiter for stats reporting.
func (s *Server) SetBandwidthLimiter(bw *ratelimit.BandwidthLimiter) {
s.bwLimiter = bw
}
// SetAuditLogger sets the audit logger for recording admin actions.
func (s *Server) SetAuditLogger(al *audit.Logger) {
s.auditLog = al
}
// SetEventBus sets the event bus for real-time WebSocket push.
func (s *Server) SetEventBus(eb *eventsModule.Bus) {
s.eventBus = eb
}
// SetMetrics sets the Prometheus metrics collector.
func (s *Server) SetMetrics(m *metrics.Collector) {
s.metrics = m
}
// SetJWTManager sets the JWT manager for auth.
func (s *Server) SetJWTManager(jm *auth.JWTManager) {
s.jwtManager = jm
}
// SetKeyPair sets the Ed25519 keypair for the server (used for signing IdPk).
func (s *Server) SetKeyPair(kp *crypto.KeyPair) {
s.keyPair = kp
}
// SetCDAPGateway sets the CDAP gateway for serving CDAP REST endpoints.
func (s *Server) SetCDAPGateway(gw *cdap.Gateway) {
s.cdapGw = gw
}
// SetMeshGateway sets the MeshCentral compatibility gateway.
func (s *Server) SetMeshGateway(gw *meshcentral.Gateway) {
s.meshGw = gw
}
// InitLDAP initializes the LDAP provider from the database configuration.
// Should be called after the database is ready, before Start().
func (s *Server) InitLDAP() {
cfg := s.loadLDAPConfigFromDB()
s.ldapProvider = auth.NewLDAPProvider(cfg)
if cfg.Enabled {
log.Printf("[LDAP] Provider initialized (host=%s, port=%d, tls=%v)", cfg.Host, cfg.Port, cfg.UseTLS)
}
}
// InitOIDC initializes the OIDC provider from the database configuration.
// Should be called after the database is ready, before Start().
func (s *Server) InitOIDC() {
cfg := s.loadOIDCConfigFromDB()
s.oidcProvider = auth.NewOIDCProvider(cfg)
if cfg.Enabled {
log.Printf("[OIDC] Provider initialized (issuer=%s, client_id=%s)", cfg.IssuerURL, cfg.ClientID)
}
}
// Start launches the HTTP API server.
func (s *Server) Start(ctx context.Context) error {
mux := http.NewServeMux()
// Health and public key are needed for client bootstrap. Detailed runtime
// stats use the same allowlist/auth policy as Prometheus metrics.
mux.HandleFunc("GET /api/health", s.handleHealth)
mux.HandleFunc("GET /api/server/stats", s.metricsGuard(s.handleServerStats))
mux.HandleFunc("GET /api/server/pubkey", s.handlePubKey)
// Peers (permission-based access control)
mux.HandleFunc("GET /api/peers", s.requirePermission(auth.PermDeviceView, s.handleListPeers))
mux.HandleFunc("GET /api/peers/{id}", s.requirePermission(auth.PermDeviceView, s.handleGetPeer))
mux.HandleFunc("DELETE /api/peers/{id}", s.requirePermission(auth.PermDeviceDelete, s.handleDeletePeer))
mux.HandleFunc("PATCH /api/peers/{id}", s.requirePermission(auth.PermDeviceEdit, s.handleUpdatePeerFields))
mux.HandleFunc("POST /api/peers/{id}/ban", s.requirePermission(auth.PermDeviceBan, s.handleBanPeer))
mux.HandleFunc("POST /api/peers/{id}/unban", s.requirePermission(auth.PermDeviceBan, s.handleUnbanPeer))
mux.HandleFunc("POST /api/peers/{id}/restore", s.requirePermission(auth.PermDeviceDelete, s.handleRestorePeer))
mux.HandleFunc("POST /api/peers/{id}/change-id", s.requirePermission(auth.PermDeviceChangeID, s.handleChangePeerID))
// Detailed device status (enhanced in Phase 4)
mux.HandleFunc("GET /api/peers/status/summary", s.handleStatusSummary)
mux.HandleFunc("GET /api/peers/online", s.handleOnlinePeers)
mux.HandleFunc("GET /api/peers/{id}/status", s.handlePeerStatus)
mux.HandleFunc("GET /api/peers/{id}/metrics", s.handlePeerMetrics)
mux.HandleFunc("GET /api/peers/{id}/linked", s.handleLinkedPeers)
mux.HandleFunc("POST /api/peers/{id}/wol", s.requireRole(auth.RoleOperator, s.handleWakeOnLan))
mux.HandleFunc("GET /api/peers/{id}/access-policy", s.requireRole(auth.RoleOperator, s.handleGetAccessPolicy))
mux.HandleFunc("PUT /api/peers/{id}/access-policy", s.requireRole(auth.RoleAdmin, s.handleSaveAccessPolicy))
mux.HandleFunc("DELETE /api/peers/{id}/access-policy", s.requireRole(auth.RoleAdmin, s.handleDeleteAccessPolicy))
mux.HandleFunc("POST /api/peers/{id}/session-grant", s.requireRole(auth.RoleOperator, s.handleIssueSupportSessionGrant))
mux.HandleFunc("GET /api/peers/{id}/policy", s.handleGetPeerPolicy)
// Blocklist management
mux.HandleFunc("GET /api/blocklist", s.requirePermission(auth.PermBlocklistEdit, s.handleListBlocklist))
mux.HandleFunc("POST /api/blocklist", s.requirePermission(auth.PermBlocklistEdit, s.handleAddBlocklist))
mux.HandleFunc("DELETE /api/blocklist/{entry}", s.requirePermission(auth.PermBlocklistEdit, s.handleRemoveBlocklist))
// Tags
mux.HandleFunc("PUT /api/peers/{id}/tags", s.requirePermission(auth.PermDeviceEdit, s.handleSetPeerTags))
mux.HandleFunc("GET /api/tags/{tag}/peers", s.requirePermission(auth.PermDeviceView, s.handlePeersByTag))
// Chat (requires chat.access permission)
mux.HandleFunc("GET /api/chat/history/", s.requirePermission(auth.PermChatAccess, s.handleChatHistory))
mux.HandleFunc("POST /api/chat/messages", s.requirePermission(auth.PermChatAccess, s.handleChatSendMessage))
mux.HandleFunc("POST /api/chat/read", s.requirePermission(auth.PermChatAccess, s.handleChatMarkRead))
mux.HandleFunc("GET /api/chat/unread/", s.requirePermission(auth.PermChatAccess, s.handleChatUnread))
mux.HandleFunc("GET /api/chat/contacts/", s.requirePermission(auth.PermChatAccess, s.handleChatContacts))
mux.HandleFunc("POST /api/chat/groups", s.requirePermission(auth.PermChatAccess, s.handleChatCreateGroup))
mux.HandleFunc("GET /api/chat/groups/", s.requirePermission(auth.PermChatAccess, s.handleChatListGroups))
mux.HandleFunc("PUT /api/chat/groups/", s.requirePermission(auth.PermChatAccess, s.handleChatUpdateGroup))
mux.HandleFunc("DELETE /api/chat/groups/", s.requirePermission(auth.PermChatAccess, s.handleChatDeleteGroup))
// Organizations — org membership enforced on org-specific routes
mux.HandleFunc("POST /api/org", s.requirePermission(auth.PermOrgCreate, s.handleCreateOrg))
mux.HandleFunc("GET /api/org", s.handleListOrgs) // data-scoped in handler
mux.HandleFunc("GET /api/org/{id}", s.requireOrgMembership("id", s.handleGetOrg))
mux.HandleFunc("PUT /api/org/{id}", s.requirePermission(auth.PermOrgEdit, s.requireOrgMembership("id", s.handleUpdateOrg)))
mux.HandleFunc("DELETE /api/org/{id}", s.requirePermission(auth.PermOrgDelete, s.handleDeleteOrg))
mux.HandleFunc("GET /api/org/{id}/users", s.requireOrgMembership("id", s.handleListOrgUsers))
mux.HandleFunc("POST /api/org/{id}/users", s.requirePermission(auth.PermOrgManageUsers, s.requireOrgMembership("id", s.handleCreateOrgUser)))
mux.HandleFunc("PUT /api/org/{id}/users/{uid}", s.requirePermission(auth.PermOrgManageUsers, s.requireOrgMembership("id", s.handleUpdateOrgUser)))
mux.HandleFunc("DELETE /api/org/{id}/users/{uid}", s.requirePermission(auth.PermOrgManageUsers, s.requireOrgMembership("id", s.handleDeleteOrgUser)))
mux.HandleFunc("POST /api/org/{id}/invite", s.requirePermission(auth.PermOrgManageUsers, s.requireOrgMembership("id", s.handleCreateOrgInvitation)))
mux.HandleFunc("GET /api/org/{id}/invitations", s.requireOrgMembership("id", s.handleListOrgInvitations))
mux.HandleFunc("POST /api/org/{id}/devices", s.requirePermission(auth.PermOrgManageDevices, s.requireOrgMembership("id", s.handleAssignOrgDevice)))
mux.HandleFunc("GET /api/org/{id}/devices", s.requireOrgMembership("id", s.handleListOrgDevices))
mux.HandleFunc("DELETE /api/org/{id}/devices/{did}", s.requirePermission(auth.PermOrgManageDevices, s.requireOrgMembership("id", s.handleUnassignOrgDevice)))
mux.HandleFunc("GET /api/org/{id}/settings", s.requireOrgMembership("id", s.handleListOrgSettings))
mux.HandleFunc("PUT /api/org/{id}/settings", s.requirePermission(auth.PermOrgEdit, s.requireOrgMembership("id", s.handleSetOrgSetting)))
mux.HandleFunc("GET /api/org/{id}/address-book", s.requireOrgMembership("id", s.handleGetOrgAddressBook))
mux.HandleFunc("PUT /api/org/{id}/address-book", s.requirePermission(auth.PermOrgEdit, s.requireOrgMembership("id", s.handleSetOrgAddressBook)))
mux.HandleFunc("POST /api/org/login", s.handleOrgLogin) // public — no auth required
// User-Org Linking (Issue #106)
mux.HandleFunc("GET /api/org/{id}/available-users", s.requirePermission(auth.PermOrgManageUsers, s.requireOrgMembership("id", s.handleListAvailableUsers)))
mux.HandleFunc("POST /api/org/{id}/members", s.requirePermission(auth.PermOrgManageUsers, s.requireOrgMembership("id", s.handleLinkUserToOrg)))
mux.HandleFunc("DELETE /api/org/{id}/members/{userId}", s.requirePermission(auth.PermOrgManageUsers, s.requireOrgMembership("id", s.handleUnlinkUserFromOrg)))
// Organization Policies (stored as org_settings with policy_ prefix)
mux.HandleFunc("GET /api/org/{id}/policy", s.requireOrgMembership("id", s.handleGetOrgPolicy))
mux.HandleFunc("PUT /api/org/{id}/policy/{category}", s.requirePermission(auth.PermOrgEdit, s.requireOrgMembership("id", s.handleSetOrgPolicy)))
mux.HandleFunc("GET /api/org/{id}/policy/effective/{deviceId}", s.requireOrgMembership("id", s.handleGetEffectivePolicy))
mux.HandleFunc("GET /api/org/{id}/policy/audit", s.requireOrgMembership("id", s.handleGetPolicyAudit))
// Audit (audit.view permission required)
mux.HandleFunc("GET /api/audit/events", s.requirePermission(auth.PermAuditView, s.handleAuditEvents))
// WebSocket real-time events (audit.view permission required — events stream
// includes peer/auth/system telemetry intended for audit-capable roles only)
mux.HandleFunc("GET /api/ws/events", s.requirePermission(auth.PermAuditView, s.handleWSEvents))
// Config (server.config permission)
mux.HandleFunc("GET /api/config/{key}", s.requirePermission(auth.PermServerConfig, s.handleGetConfig))
mux.HandleFunc("PUT /api/config/{key}", s.requirePermission(auth.PermServerConfig, s.handleSetConfig))
// Auth (public — no auth required, handled by middleware exclusion)
mux.HandleFunc("POST /api/auth/login", s.handleLogin)
mux.HandleFunc("POST /api/auth/login/2fa", s.handleLogin2FA)
mux.HandleFunc("GET /api/auth/me", s.handleAuthMe)
// RustDesk Client API (same consolidated HTTP port as REST — default :21121).
// Clients with an explicit API Server URL use that; otherwise some builds
// fall back to signal_port - 2 (21114).
mux.HandleFunc("POST /api/login", s.handleClientLogin)
mux.HandleFunc("GET /api/login-options", s.handleClientLoginOptions)
mux.HandleFunc("POST /api/oidc/auth", s.handleClientOIDCAuth)
mux.HandleFunc("GET /api/oidc/auth-query", s.handleClientOIDCAuthQuery)
mux.HandleFunc("GET /api/oidc/callback", s.handleOIDCCallback) // alias; same IdP Redirect URL family
mux.HandleFunc("POST /api/logout", s.handleClientLogout)
mux.HandleFunc("GET /api/currentUser", s.handleClientCurrentUser)
mux.HandleFunc("POST /api/currentUser", s.handleClientCurrentUser)
mux.HandleFunc("GET /api/ab", s.handleClientAddressBook)
mux.HandleFunc("POST /api/ab", s.handleClientAddressBook)
mux.HandleFunc("GET /api/ab/personal", s.handleClientAddressBookPersonal)
mux.HandleFunc("POST /api/ab/personal", s.handleClientAddressBookPersonal)
mux.HandleFunc("GET /api/ab/tags", s.handleClientAddressBookTags)
// RustDesk PRO group endpoint stubs — Flutter clients query these and expect
// the {total,data,msg} envelope; without them the device list never finishes
// loading. We expose empty-but-valid responses so the UI gracefully falls
// back to address-book mode. Idea credit: progloto (PR #81).
mux.HandleFunc("GET /api/group", s.handleClientGroupList)
mux.HandleFunc("GET /api/group/get", s.handleClientGroupList)
mux.HandleFunc("POST /api/group/get", s.handleClientGroupList)
mux.HandleFunc("GET /api/device-group", s.handleClientGroupList)
mux.HandleFunc("GET /api/device-group/accessible", s.handleClientGroupList)
mux.HandleFunc("POST /api/device-group", s.requirePanelAdmin(s.handleDeviceGroupsPost))
mux.HandleFunc("GET /api/peers/list", s.handleClientGroupPeers)
mux.HandleFunc("POST /api/peers/list", s.handleClientGroupPeers)
mux.HandleFunc("POST /api/heartbeat", s.handleClientHeartbeat)
mux.HandleFunc("POST /api/sysinfo", s.handleClientSysinfo)
mux.HandleFunc("POST /api/sysinfo_ver", s.handleClientSysinfoVer)
// RustDesk client compat (Phase A — handlers in client_*.go, mirrored from Node :21121)
mux.HandleFunc("GET /api/server-key", s.handleServerKey)
mux.HandleFunc("GET /api/server-key/fingerprint", s.handleServerKeyFingerprint)
mux.HandleFunc("GET /api/software", s.handleClientSoftware)
mux.HandleFunc("GET /api/software/client-download-link", s.handleClientSoftwareDownloadLink)
mux.HandleFunc("GET /api/user/group", s.handleClientUserGroup)
mux.HandleFunc("GET /api/user-groups", s.handleUserGroupsGet)
mux.HandleFunc("POST /api/user-groups", s.requirePanelAdmin(s.handleUserGroupsPost))
mux.HandleFunc("GET /api/strategies", s.handleStrategiesGet)
mux.HandleFunc("POST /api/strategies", s.requirePanelAdmin(s.handleStrategiesPost))
mux.HandleFunc("POST /api/strategies/assign", s.requirePanelAdmin(s.handleStrategiesAssign))
mux.HandleFunc("GET /api/strategies/{guid}", s.handleStrategiesGetByGUID)
mux.HandleFunc("PUT /api/strategies/{guid}/status", s.requirePanelAdmin(s.handleStrategiesStatus))
mux.HandleFunc("DELETE /api/strategies/{guid}", s.requirePanelAdmin(s.handleStrategiesDelete))
mux.HandleFunc("GET /api/devices", s.requirePermission(auth.PermDeviceView, s.handleProDevicesList))
mux.HandleFunc("POST /api/devices/{guid}/assign", s.requirePanelAdmin(s.handleProDeviceAssign))
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))
mux.HandleFunc("GET /api/audit", s.requirePermission(auth.PermAuditView, s.handleClientAuditSummary))
mux.HandleFunc("GET /api/peer-key/{id}", s.handlePeerKey)
// 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 —
// if it fails (403), _getPeers() is never called and the Available Devices
// tab stays empty. Use a wrapper that detects client requests and returns
// only the current user before the permission check.
mux.HandleFunc("GET /api/users", s.handleUsersWithClientFallback)
mux.HandleFunc("POST /api/users", s.requirePermission(auth.PermUserCreate, s.handleCreateUser))
mux.HandleFunc("PUT /api/users/{id}", s.requirePermission(auth.PermUserEdit, s.handleUpdateUser))
mux.HandleFunc("DELETE /api/users/{id}", s.requirePermission(auth.PermUserDelete, s.handleDeleteUser))
mux.HandleFunc("GET /api/users/{id}/organizations", s.requirePermission(auth.PermUserView, s.handleListUserOrganizations))
mux.HandleFunc("POST /api/users/{id}/organizations", s.requirePermission(auth.PermOrgManageUsers, s.handleAssignUserToOrg))
// Roles and permissions (RBAC Phase 52)
mux.HandleFunc("GET /api/roles", s.requirePermission(auth.PermUserView, s.handleListRoles))
mux.HandleFunc("GET /api/roles/{role}/permissions", s.requirePermission(auth.PermUserView, s.handleGetRolePermissions))
mux.HandleFunc("GET /api/role-permissions", s.requirePermission(auth.PermServerConfig, s.handleListRolePermissionOverrides))
mux.HandleFunc("POST /api/role-permissions", s.requirePermission(auth.PermServerConfig, s.handleSetRolePermission))
mux.HandleFunc("DELETE /api/role-permissions/{role}/{permission}", s.requirePermission(auth.PermServerConfig, s.handleDeleteRolePermission))
// TOTP management (admin only)
mux.HandleFunc("POST /api/users/{id}/totp/setup", s.requireRole(auth.RoleAdmin, s.handleSetupTOTP))
mux.HandleFunc("POST /api/users/{id}/totp/confirm", s.requireRole(auth.RoleAdmin, s.handleConfirmTOTP))
mux.HandleFunc("DELETE /api/users/{id}/totp", s.requireRole(auth.RoleAdmin, s.handleDisableTOTP))
// API key management (server.keys permission)
mux.HandleFunc("GET /api/keys", s.requirePermission(auth.PermServerKeys, s.handleListAPIKeys))
mux.HandleFunc("POST /api/keys", s.requirePermission(auth.PermServerKeys, s.handleCreateAPIKey))
mux.HandleFunc("DELETE /api/keys/{id}", s.requirePermission(auth.PermServerKeys, s.handleDeleteAPIKey))
// Device token management (Dual Key System - admin only)
mux.HandleFunc("GET /api/tokens", s.requireRole(auth.RoleAdmin, s.handleListDeviceTokens))
mux.HandleFunc("POST /api/tokens", s.requireRole(auth.RoleAdmin, s.handleCreateDeviceToken))
mux.HandleFunc("GET /api/tokens/{id}", s.requireRole(auth.RoleAdmin, s.handleGetDeviceToken))
mux.HandleFunc("PUT /api/tokens/{id}", s.requireRole(auth.RoleAdmin, s.handleUpdateDeviceToken))
mux.HandleFunc("DELETE /api/tokens/{id}", s.requireRole(auth.RoleAdmin, s.handleRevokeDeviceToken))
mux.HandleFunc("POST /api/tokens/generate-bulk", s.requireRole(auth.RoleAdmin, s.handleBulkGenerateTokens))
mux.HandleFunc("POST /api/tokens/{id}/bind", s.requireRole(auth.RoleAdmin, s.handleBindTokenToPeer))
// Enrollment mode management (Dual Key System - admin only)
mux.HandleFunc("GET /api/enrollment/mode", s.requireRole(auth.RoleAdmin, s.handleGetEnrollmentMode))
mux.HandleFunc("PUT /api/enrollment/mode", s.requireRole(auth.RoleAdmin, s.handleSetEnrollmentMode))
// Enrollment — device self-registration (public, no auth, rate-limited via M-07)
mux.HandleFunc("POST /api/devices/register", s.rateLimitPublic(s.enrollmentLimiter, s.handleDeviceRegister))
mux.HandleFunc("GET /api/devices/register/status", s.rateLimitPublic(s.enrollmentLimiter, s.handleDeviceRegisterStatus))
mux.HandleFunc("POST /api/devices/self/access-policy", s.rateLimitPublic(s.enrollmentLimiter, s.handleDeviceSelfAccessPolicy))
mux.HandleFunc("POST /api/devices/self/help-request", s.rateLimitPublic(s.enrollmentLimiter, s.handleDeviceSelfHelpRequest))
mux.HandleFunc("POST /api/devices/self/totp", s.rateLimitPublic(s.enrollmentLimiter, s.handleDeviceSelfTOTP))
// Help requests — operator panel (raised by agents via CDAP or REST self endpoint)
mux.HandleFunc("GET /api/help/requests", s.requirePermission(auth.PermChatAccess, s.handleListHelpRequests))
mux.HandleFunc("GET /api/help/requests/{id}", s.requirePermission(auth.PermChatAccess, s.handleGetHelpRequest))
mux.HandleFunc("POST /api/help/requests/{id}/acknowledge", s.requirePermission(auth.PermChatAccess, s.handleAcknowledgeHelpRequest))
mux.HandleFunc("POST /api/help/requests/{id}/resolve", s.requirePermission(auth.PermChatAccess, s.handleResolveHelpRequest))
// Time sync / billing (commercialization)
mux.HandleFunc("GET /api/timesync/status", s.requirePermission(auth.PermBillingView, s.handleTimeSyncStatus))
mux.HandleFunc("POST /api/timesync/check", s.requirePermission(auth.PermServerConfig, s.handleTimeSyncCheck))
mux.HandleFunc("GET /api/billing/check", s.requirePermission(auth.PermDeviceConnect, s.handleBillingConnectionCheck))
mux.HandleFunc("GET /api/billing/packages", s.requirePermission(auth.PermBillingView, s.handleListBillingPackages))
mux.HandleFunc("POST /api/billing/packages", s.requirePermission(auth.PermBillingManage, s.handleCreateBillingPackage))
mux.HandleFunc("PUT /api/billing/packages/{id}", s.requirePermission(auth.PermBillingManage, s.handleUpdateBillingPackage))
mux.HandleFunc("DELETE /api/billing/packages/{id}", s.requirePermission(auth.PermBillingManage, s.handleDeleteBillingPackage))
mux.HandleFunc("GET /api/billing/contracts", s.requirePermission(auth.PermBillingView, s.handleListBillingContracts))
mux.HandleFunc("POST /api/billing/contracts", s.requirePermission(auth.PermBillingManage, s.handleCreateBillingContract))
mux.HandleFunc("PUT /api/billing/contracts/{id}", s.requirePermission(auth.PermBillingManage, s.handleUpdateBillingContract))
mux.HandleFunc("DELETE /api/billing/contracts/{id}", s.requirePermission(auth.PermBillingManage, s.handleDeleteBillingContract))
mux.HandleFunc("GET /api/billing/stats", s.requirePermission(auth.PermBillingView, s.handleBillingStats))
mux.HandleFunc("GET /api/billing/sessions", s.requirePermission(auth.PermBillingView, s.handleListBillingSessions))
mux.HandleFunc("GET /api/billing/sessions/pending", s.requirePermission(auth.PermBillingReports, s.handleGetPendingBillingSession))
mux.HandleFunc("GET /api/billing/sessions/export", s.requirePermission(auth.PermBillingExport, s.handleExportBillingSessions))
mux.HandleFunc("GET /api/billing/reports", s.requirePermission(auth.PermBillingView, s.handleListBillingReports))
mux.HandleFunc("GET /api/billing/reports/export", s.requirePermission(auth.PermBillingExport, s.handleExportBillingReports))
mux.HandleFunc("POST /api/billing/sessions/{id}/report", s.requirePermission(auth.PermBillingReports, s.handleSubmitBillingReport))
mux.HandleFunc("GET /api/billing/currencies", s.requirePermission(auth.PermBillingView, s.handleListBillingCurrencies))
mux.HandleFunc("PUT /api/billing/currencies/{code}", s.requirePermission(auth.PermBillingManage, s.handleUpsertBillingCurrency))
// Enrollment — operator approval (admin/operator)
mux.HandleFunc("GET /api/enrollment/pending", s.requireRole(auth.RoleOperator, s.handleListPendingDevices))
mux.HandleFunc("GET /api/enrollment/history", s.requireRole(auth.RoleOperator, s.handleListEnrollmentHistory))
mux.HandleFunc("POST /api/enrollment/approve/{id}", s.requireRole(auth.RoleOperator, s.handleApproveDevice))
mux.HandleFunc("POST /api/enrollment/reject/{id}", s.requireRole(auth.RoleOperator, s.handleRejectDevice))
mux.HandleFunc("POST /api/enrollment/clear-rejection/{id}", s.requireRole(auth.RoleOperator, s.handleClearEnrollmentRejection))
// LDAP configuration (server.config permission)
mux.HandleFunc("GET /api/auth/ldap/config", s.requirePermission(auth.PermServerConfig, s.handleGetLDAPConfig))
mux.HandleFunc("PUT /api/auth/ldap/config", s.requirePermission(auth.PermServerConfig, s.handleSaveLDAPConfig))
mux.HandleFunc("POST /api/auth/ldap/test", s.requirePermission(auth.PermServerConfig, s.handleTestLDAPConnection))
mux.HandleFunc("POST /api/auth/ldap/verify", s.handleLDAPVerifyCredentials)
// OIDC/OAuth2 configuration (server.config permission)
mux.HandleFunc("GET /api/auth/oidc/config", s.requirePermission(auth.PermServerConfig, s.handleGetOIDCConfig))
mux.HandleFunc("PUT /api/auth/oidc/config", s.requirePermission(auth.PermServerConfig, s.handleSaveOIDCConfig))
mux.HandleFunc("POST /api/auth/oidc/test", s.requirePermission(auth.PermServerConfig, s.handleTestOIDCDiscovery))
// OIDC public endpoints (no auth — used by login page and IdP callback)
mux.HandleFunc("GET /api/auth/oidc/status", s.handleOIDCLoginStatus)
mux.HandleFunc("GET /api/auth/oidc/authorize", s.handleOIDCAuthorize)
mux.HandleFunc("GET /api/auth/oidc/callback", s.handleOIDCCallback)
mux.HandleFunc("GET /api/auth/oidc/session", s.handleOIDCSessionRedirect)
mux.HandleFunc("POST /api/auth/oidc/exchange", s.handleOIDCExchange)
// Combined SSO status — public, used by Node.js console to detect
// whether LDAP or OIDC is enabled and auto-provision LDAP-authenticated
// users on first login (#148).
mux.HandleFunc("GET /api/auth/sso/status", s.handleSSOStatus)
// Branding (GET is public for desktop clients, POST is admin)
mux.HandleFunc("GET /api/branding", s.rateLimitPublic(s.brandingLimiter, s.handleGetBranding))
mux.HandleFunc("POST /api/branding", s.requireRole(auth.RoleAdmin, s.handleSaveBranding))
// CDAP device management (requires CDAP gateway to be enabled)
mux.HandleFunc("GET /api/cdap/status", s.handleCDAPStatus)
mux.HandleFunc("GET /api/cdap/devices", s.handleCDAPListDevices)
mux.HandleFunc("GET /api/cdap/devices/{id}", s.handleCDAPDeviceInfo)
mux.HandleFunc("GET /api/cdap/devices/{id}/manifest", s.handleCDAPDeviceManifest)
mux.HandleFunc("GET /api/cdap/devices/{id}/state", s.handleCDAPDeviceState)
mux.HandleFunc("POST /api/cdap/devices/{id}/command", s.requireRole(auth.RoleOperator, s.handleCDAPSendCommand))
mux.HandleFunc("GET /api/cdap/alerts", s.handleCDAPAlerts)
// CDAP auth delegation (admin only)
mux.HandleFunc("POST /api/cdap/delegate", s.requireRole(auth.RoleAdmin, s.handleCDAPDelegateCreate))
mux.HandleFunc("DELETE /api/cdap/delegate/{id}", s.requireRole(auth.RoleAdmin, s.handleCDAPDelegateRevoke))
mux.HandleFunc("GET /api/cdap/delegations", s.requireRole(auth.RoleAdmin, s.handleCDAPDelegateList))
// CDAP terminal WebSocket (admin only, upgraded inside handler)
mux.HandleFunc("GET /api/cdap/devices/{id}/terminal", s.requireRole(auth.RoleAdmin, s.handleCDAPTerminal))
// CDAP remote desktop WebSocket (admin only)
mux.HandleFunc("GET /api/cdap/devices/{id}/desktop", s.requireRole(auth.RoleAdmin, s.handleCDAPDesktop))
// CDAP video stream WebSocket (operator+)
mux.HandleFunc("GET /api/cdap/devices/{id}/video", s.requireRole(auth.RoleOperator, s.handleCDAPVideo))
// CDAP file browser WebSocket (admin only)
mux.HandleFunc("GET /api/cdap/devices/{id}/files", s.requireRole(auth.RoleAdmin, s.handleCDAPFileBrowser))
// CDAP audio stream WebSocket (operator+)
mux.HandleFunc("GET /api/cdap/devices/{id}/audio", s.requireRole(auth.RoleOperator, s.handleCDAPAudio))
// MeshCentral compatibility REST
mux.HandleFunc("GET /api/mesh/server-id", s.requirePermission(auth.PermServerConfig, s.handleMeshServerID))
mux.HandleFunc("GET /api/mesh/status", s.requirePermission(auth.PermDeviceView, s.handleMeshStatus))
mux.HandleFunc("GET /api/mesh/groups", s.requirePermission(auth.PermServerConfig, s.handleMeshGroupsList))
mux.HandleFunc("POST /api/mesh/groups", s.requirePermission(auth.PermServerConfig, s.handleMeshGroupsSave))
mux.HandleFunc("GET /api/mesh/download.msh", s.requirePermission(auth.PermServerConfig, s.handleMeshDownloadMSH))
mux.HandleFunc("POST /api/mesh/devices/{id}/desktop", s.requirePermission(auth.PermDeviceConnect, s.handleMeshDesktopTunnel))
mux.HandleFunc("POST /api/mesh/devices/{id}/terminal", s.requirePermission(auth.PermMeshTerminal, s.handleMeshTerminalTunnel))
mux.HandleFunc("POST /api/mesh/devices/{id}/files", s.requirePermission(auth.PermMeshFiles, s.handleMeshFilesTunnel))
mux.HandleFunc("POST /api/mesh/devices/{id}/share", s.requireRole(auth.RoleOperator, s.handleMeshShareCreate))
mux.HandleFunc("GET /api/mesh/share/validate", s.handleMeshShareValidate)
// Guest Access Links (temporary RdClient allowlist links)
mux.HandleFunc("POST /api/guest/access-links", s.requirePermission(auth.PermDeviceConnect, s.handleGuestAccessCreate))
mux.HandleFunc("GET /api/guest/access-links", s.requirePermission(auth.PermDeviceConnect, s.handleGuestAccessList))
mux.HandleFunc("DELETE /api/guest/access-links/{id}", s.requirePermission(auth.PermDeviceConnect, s.handleGuestAccessRevoke))
mux.HandleFunc("GET /api/guest/access-links/validate", s.handleGuestAccessValidate)
mux.HandleFunc("GET /api/guest/access-links/peers", s.handleGuestAccessPeers)
mux.HandleFunc("POST /api/mesh/devices/{id}/tcp", s.requirePermission(auth.PermDeviceConnect, s.handleMeshTcpRelay))
mux.HandleFunc("POST /api/mesh/devices/{id}/udp", s.requirePermission(auth.PermDeviceConnect, s.handleMeshUdpRelay))
mux.HandleFunc("POST /api/mesh/devices/{id}/power", s.requirePermission(auth.PermMeshPower, s.handleMeshPower))
mux.HandleFunc("POST /api/mesh/devices/{id}/group", s.requirePermission(auth.PermServerConfig, s.handleMeshDeviceGroup))
mux.HandleFunc("GET /api/mesh/recordings", s.requirePermission(auth.PermDeviceView, s.handleMeshRecordingsList))
mux.HandleFunc("GET /api/mesh/recordings/{id}", s.requirePermission(auth.PermDeviceConnect, s.handleMeshRecordingDownload))
mux.HandleFunc("GET /api/session/recordings", s.requirePermission(auth.PermDeviceView, s.handleSessionRecordingsList))
mux.HandleFunc("POST /api/mesh/devices/{id}/exec", s.requireRole(auth.RoleOperator, s.handleMeshExec))
mux.HandleFunc("POST /api/peers/{id}/exec", s.requireRole(auth.RoleOperator, s.handleUnifiedPeerExec))
if s.meshGw != nil {
s.meshGw.RegisterRoutes(mux)
}
// BetterDesk desktop client management WebSocket (no API key — device auth)
mux.HandleFunc("GET /ws/bd-mgmt/{device_id}", s.handleBdMgmt)
// Management REST endpoints (admin/operator only)
mux.HandleFunc("GET /api/bd/mgmt/{device_id}/status", s.handleBdMgmtStatus)
mux.HandleFunc("POST /api/bd/mgmt/{device_id}/send", s.requireRole(auth.RoleOperator, s.handleBdMgmtSend))
mux.HandleFunc("GET /api/bd/mgmt/connected", s.handleBdMgmtConnected)
// Prometheus metrics. Gated via H-03 — see handleMetrics for the actual
// IP-allowlist / auth check. We register a wrapper here that enforces the
// policy before any metric data is exposed.
mux.HandleFunc("GET /metrics", s.metricsGuard(s.handleMetrics))
// Catch-all: return JSON 404 for unmatched routes.
// Go's default ServeMux returns HTML which breaks RustDesk (Dart) client parsing.
// Logs every miss so missing client compatibility endpoints are easy to spot.
// Diagnostics suggestion credit: progloto (PR #81).
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
if ip == "" {
ip = r.RemoteAddr
}
ua := r.Header.Get("User-Agent")
if len(ua) > 80 {
ua = ua[:80] + "…"
}
log.Printf("[api] 404 %s %s from %s ua=%q", r.Method, r.URL.Path, ip, ua)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error":"not found"}`))
})
addr := fmt.Sprintf(":%d", s.cfg.APIPort)
s.httpSrv = &http.Server{
Addr: addr,
Handler: s.authMiddleware(mux),
ReadTimeout: 10 * time.Second,
// No WriteTimeout — WebSocket connections need unlimited write time.
// Individual REST handlers are responsible for their own deadlines.
IdleTimeout: 120 * time.Second,
BaseContext: func(l net.Listener) context.Context {
return ctx
},
}
// Enable TLS only if explicitly opted in via --tls-api or --force-https
useTLS := s.cfg.APITLSEnabled()
if useTLS {
tlsCfg, err := config.LoadTLSConfig(s.cfg.TLSCertFile, s.cfg.TLSKeyFile)
if err != nil {
return fmt.Errorf("api: %w", err)
}
s.httpSrv.TLSConfig = tlsCfg
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
if useTLS {
log.Printf("[api] HTTPS listening on %s (TLS enabled)", addr)
if err := s.httpSrv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
log.Printf("[api] HTTPS server error: %v", err)
}
} else {
log.Printf("[api] HTTP listening on %s", addr)
if err := s.httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[api] HTTP server error: %v", err)
}
}
}()
return nil
}
// Stop gracefully shuts down the HTTP server.
func (s *Server) Stop() {
log.Printf("[api] Shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.httpSrv.Shutdown(ctx)
s.wg.Wait()
log.Printf("[api] Stopped")
}
// --- Middleware ---
// --- Handlers ---
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
total, online, _ := s.db.GetPeerCount()
payload := map[string]any{
"status": "ok",
"version": s.version,
"peers_total": total,
"peers_online": online,
"uptime": time.Since(startTime).String(),
"tls": s.cfg.TLSCertFile != "" && s.cfg.TLSKeyFile != "",
}
if s.peers != nil {
peerStats := s.peers.GetStats(config.DegradedThreshold, config.CriticalThreshold)
payload["peers_in_memory"] = peerStats.Total
payload["peers_degraded"] = peerStats.Degraded
payload["peers_critical"] = peerStats.Critical
payload["peers_udp"] = peerStats.UDP
payload["peers_tcp"] = peerStats.TCP
payload["peers_ws"] = peerStats.WS
}
if s.relay != nil {
payload["relay_active_sessions"] = s.relay.ActiveSessions.Load()
}
if s.billing != nil {
payload["billing_pending_relays"] = s.billing.PendingRelayCount()
}
if s.timeSync != nil {
payload["clock_synced"] = s.timeSync.IsSynced()
}
if s.cfg != nil {
payload["connection"] = map[string]any{
"p2p_first": s.cfg.P2PFirst,
"always_use_relay": s.cfg.AlwaysUseRelay,
"p2p_fallback_ms": s.cfg.P2PFallbackMs,
"same_nat_relay": s.cfg.SameNATRelay,
"relay_servers": s.cfg.RelayServers,
}
}
writeJSON(w, http.StatusOK, payload)
}
// handlePubKey returns the server's Ed25519 public key in base64 format.
// This is the "key" value that RustDesk clients use for E2E encryption verification.
// Public endpoint — no authentication required.
func (s *Server) handlePubKey(w http.ResponseWriter, r *http.Request) {
if s.keyPair == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "Server keypair not initialized",
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"key": s.keyPair.PublicKeyBase64(),
"key_hex": fmt.Sprintf("%x", s.keyPair.PublicKey),
})
}
func (s *Server) handleServerStats(w http.ResponseWriter, r *http.Request) {
total, online, _ := s.db.GetPeerCount()
memStats := runtime.MemStats{}
runtime.ReadMemStats(&memStats)
peerStats := s.peers.GetStats(config.DegradedThreshold, config.CriticalThreshold)
stats := map[string]any{
"version": s.version,
"go_version": runtime.Version(),
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"goroutines": runtime.NumGoroutine(),
"memory_alloc_mb": float64(memStats.Alloc) / 1024 / 1024,
"memory_sys_mb": float64(memStats.Sys) / 1024 / 1024,
"peers_total": total,
"peers_online": online,
"peers_in_memory": s.peers.Count(),
"uptime": time.Since(startTime).String(),
"uptime_seconds": int(time.Since(startTime).Seconds()),
// Enhanced status stats from peer map
"peers_online_live": peerStats.Online,
"peers_degraded": peerStats.Degraded,
"peers_critical": peerStats.Critical,
"peers_udp": peerStats.UDP,
"peers_tcp": peerStats.TCP,
"peers_ws": peerStats.WS,
"peers_banned": func() int {
if n, err := s.db.GetBannedPeerCount(); err == nil {
return n
}
return peerStats.Banned
}(),
"peers_disabled": peerStats.Disabled,
"avg_uptime_secs": peerStats.AvgUptimeSecs,
"avg_beat_age_secs": peerStats.AvgBeatAge,
"total_registrations": s.peers.TotalRegistrations(),
"total_expired": s.peers.TotalExpired(),
}
if s.relay != nil {
stats["relay_active_sessions"] = s.relay.ActiveSessions.Load()
stats["relay_total_relayed"] = s.relay.TotalRelayed.Load()
}
if s.bwLimiter != nil {
bwStats := s.bwLimiter.Stats()
stats["bandwidth_bytes_transferred"] = bwStats.TotalBytesTransferred
stats["bandwidth_active_sessions"] = bwStats.ActiveSessions
stats["bandwidth_throttle_hits"] = bwStats.ThrottleHits
}
if s.blocklist != nil {
stats["blocklist_count"] = s.blocklist.Count()
}
writeJSON(w, http.StatusOK, stats)
}
func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
// Detect RustDesk client group model request: the Flutter client sends
// ?accessible=&status=1&pageSize=100 and expects {total,data} envelope
// with PeerPayload format (status as int, info as nested map).
// Issue #138: without this detection, the client gets status:"ONLINE"
// (string) where it expects int, causing "type 'String' is not a subtype
// of type 'int?'" error.
if r.URL.Query().Has("accessible") || r.URL.Query().Has("pageSize") {
s.handleClientPeersList(w, r)
return
}
includeDeleted := r.URL.Query().Get("include_deleted") == "true"
limit, offset, paginated := parsePeerListPagination(r)
// Data scoping: org-scoped users only see their org's devices
orgID := getOrgIDFromCtx(r)
username := getUsernameFromCtx(r)
role := getRoleFromCtx(r)
needsDeviceACL := username != "" && role != "" &&
!auth.IsSuperAdminRole(role) && role != auth.RoleGlobalAdmin && role != auth.RoleServerAdmin
var peers []*db.Peer
var total int
var err error
// When device-group ACL applies, load the full candidate set first so
// pagination cannot leak peers on later pages from an unscoped DB query.
if needsDeviceACL {
if orgID != "" {
peers, err = s.db.ListPeersForOrg(orgID, includeDeleted)
} else {
peers, err = s.db.ListPeers(includeDeleted)
}
} else if paginated {
if orgID != "" {
peers, total, err = s.db.ListPeersForOrgPaginated(orgID, includeDeleted, limit, offset)
} else {
peers, total, err = s.db.ListPeersPaginated(includeDeleted, limit, offset)
}
} else if orgID != "" {
peers, err = s.db.ListPeersForOrg(orgID, includeDeleted)
} else {
peers, err = s.db.ListPeers(includeDeleted)
}
if err != nil {
writeInternalError(w, err, "ListPeers")
return
}
if needsDeviceACL {
peerByID := make(map[string]*db.Peer, len(peers))
for _, p := range peers {
if p != nil {
peerByID[p.ID] = p
}
}
user := s.rustDeskUserForGroups(r, username, role)
if visible := s.rustDeskVisiblePeerSet(user, role, peerByID); visible != nil {
filtered := make([]*db.Peer, 0, len(peers))
for _, p := range peers {
if p != nil && visible[p.ID] {
filtered = append(filtered, p)
}
}
peers = filtered
}
total = len(peers)
if paginated {
start := offset
if start < 0 {
start = 0
}
if start > len(peers) {
peers = nil
} else {
end := start + limit
if end > len(peers) {
end = len(peers)
}
peers = peers[start:end]
}
}
}
// Enrich with live online status and status tier from memory map.
// Issue #138 hardening: override db.Peer.Status (string) with an int
// so any RustDesk client that reaches this handler without the
// ?accessible / ?pageSize detection still receives a valid int.
type peerResponse struct {
*db.Peer
Status int `json:"status"` // 1=active, 0=disabled (overrides db.Peer.Status string)
StatusText string `json:"status_text"` // Original string status for admin panel
LiveOnline bool `json:"live_online"`
LiveStatus peer.Status `json:"live_status"`
Platform string `json:"platform"`
CDAPConnected bool `json:"cdap_connected"`
MeshConnected bool `json:"mesh_connected"`
MeshNodeID string `json:"mesh_node_id,omitempty"`
}
result := make([]peerResponse, len(peers))
for i, p := range peers {
liveOnline := s.peers.IsOnline(p.ID, config.RegTimeout)
liveStatus := peer.StatusOffline
if snap, ok := s.peers.GetSnapshot(p.ID, config.DegradedThreshold, config.CriticalThreshold); ok {
liveStatus = snap.Status
}
// CDAP overlay: device connected via CDAP gateway is online
cdapConnected := s.cdapGw != nil && s.cdapGw.IsConnected(p.ID)
if cdapConnected && !liveOnline {
liveOnline = true
liveStatus = peer.StatusOnline
}
meshConnected := s.meshGw != nil && s.meshGw.IsConnected(p.ID)
if meshConnected && !liveOnline {
liveOnline = true
liveStatus = peer.StatusOnline
}
meshNodeID := ""
if s.meshGw != nil {
meshNodeID = s.meshGw.MeshNodeID(p.ID)
}
statusInt := 1
if p.Disabled {
statusInt = 0
}
result[i] = peerResponse{
Peer: p,
Status: statusInt,
StatusText: p.Status,
LiveOnline: liveOnline,
LiveStatus: liveStatus,
Platform: p.OS,
CDAPConnected: cdapConnected,
MeshConnected: meshConnected,
MeshNodeID: meshNodeID,
}
}
if paginated {
writeJSON(w, http.StatusOK, map[string]any{
"total": total,
"data": result,
})
return
}
writeJSON(w, http.StatusOK, result)
}
func parsePeerListPagination(r *http.Request) (limit, offset int, paginated bool) {
limitStr := r.URL.Query().Get("limit")
if limitStr == "" {
limitStr = r.URL.Query().Get("page_size")
}
if limitStr == "" {
return 0, 0, false
}
limit, _ = strconv.Atoi(limitStr)
if limit <= 0 {
limit = 50
}
if limit > 500 {
limit = 500
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
return limit, (page - 1) * limit, true
}
// handleClientPeersList returns peers in the {total,data} envelope format
// expected by the RustDesk Flutter client's group model. The PeerPayload
// format requires:
// - status: int (1=active, 0=disabled) — NOT the string "ONLINE"/"OFFLINE"
// - info: map with device_name, os, username — NOT flat hostname/os fields
// - user: string (owner username)
// - user_name: string (display name)
//
// This fixes Issue #138: "type 'String' is not a subtype of type 'int?'"
// which occurred because the admin API returned status as a string.
func (s *Server) handleClientPeersList(w http.ResponseWriter, r *http.Request) {
data, total := s.buildRustDeskPeerList(r)
writeJSON(w, http.StatusOK, map[string]any{
"total": total,
"data": data,
})
}
func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// Org scope check: org-scoped users can only access devices in their org.
if !s.peerOrgScopeCheck(w, r, id) {
return
}
p, err := s.db.GetPeer(id)
if err != nil {
writeInternalError(w, err, "GetPeer")
return
}
if p == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Peer not found"})
return
}
// Enrich with live status from memory map (same as handleListPeers)
liveOnline := s.peers.IsOnline(p.ID, config.RegTimeout)
liveStatus := peer.StatusOffline
if snap, ok := s.peers.GetSnapshot(p.ID, config.DegradedThreshold, config.CriticalThreshold); ok {
liveStatus = snap.Status
}
// CDAP overlay
cdapConnected := s.cdapGw != nil && s.cdapGw.IsConnected(p.ID)
if cdapConnected && !liveOnline {
liveOnline = true
liveStatus = peer.StatusOnline
}
meshConnected := s.meshGw != nil && s.meshGw.IsConnected(p.ID)
if meshConnected && !liveOnline {
liveOnline = true
liveStatus = peer.StatusOnline
}
meshNodeID := ""
if s.meshGw != nil {
meshNodeID = s.meshGw.MeshNodeID(p.ID)
}
type singlePeerResponse struct {
*db.Peer
Status int `json:"status"` // 1=active, 0=disabled (overrides db.Peer.Status string)
StatusText string `json:"status_text"` // Original string status for admin panel
LiveOnline bool `json:"live_online"`
LiveStatus peer.Status `json:"live_status"`
Platform string `json:"platform"`
CDAPConnected bool `json:"cdap_connected"`
MeshConnected bool `json:"mesh_connected"`
MeshNodeID string `json:"mesh_node_id,omitempty"`
}
statusInt := 1
if p.Disabled {
statusInt = 0
}
writeJSON(w, http.StatusOK, singlePeerResponse{
Peer: p,
Status: statusInt,
StatusText: p.Status,
LiveOnline: liveOnline,
LiveStatus: liveStatus,
Platform: p.OS,
CDAPConnected: cdapConnected,
MeshConnected: meshConnected,
MeshNodeID: meshNodeID,
})
}
// handleLinkedPeers returns all peers linked to the given peer ID.
// GET /api/peers/{id}/linked
func (s *Server) handleLinkedPeers(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
linked, err := s.db.GetLinkedPeers(id)
if err != nil {
writeInternalError(w, err, "GetLinkedPeers")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"peer_id": id,
"linked": linked,
"total": len(linked),
})
}
// handleUpdatePeerFields partially updates a peer's editable fields (note, user, tags).
// PATCH /api/peers/{id}
func (s *Server) handleUpdatePeerFields(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.peerOrgScopeCheck(w, r, id) {
return
}
var body struct {
Note *string `json:"note"`
User *string `json:"user"`
Tags *string `json:"tags"`
DisplayName *string `json:"display_name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON"})
return
}
fields := make(map[string]string)
if body.Note != nil {
fields["note"] = *body.Note
}
if body.User != nil {
fields["user"] = *body.User
}
if body.Tags != nil {
fields["tags"] = *body.Tags
}
if body.DisplayName != nil {
fields["display_name"] = *body.DisplayName
}
if len(fields) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "No fields to update"})
return
}
if err := s.db.UpdatePeerFields(id, fields); err != nil {
writeInternalError(w, err, "UpdatePeerFields")
return
}
if s.auditLog != nil {
s.auditLog.Log(audit.ActionPeerUpdated, s.remoteIP(r), id, nil)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "updated", "id": id})
}
func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.peerOrgScopeCheck(w, r, id) {
return
}
hard := r.URL.Query().Get("hard") == "true"
revoke := r.URL.Query().Get("revoke") == "true"
cascade := r.URL.Query().Get("cascade") == "true"
// Collect linked peers before deletion (for cascade and response).
var linkedIDs []string
if revoke || cascade {
if linked, err := s.db.GetLinkedPeers(id); err == nil {
for _, lp := range linked {
linkedIDs = append(linkedIDs, lp.ID)
}
}
}
var err error
if hard {
err = s.db.HardDeletePeer(id)
} else {
err = s.db.DeletePeer(id)
}
if err != nil {
writeInternalError(w, err, "DeletePeer")
return
}
if revoke && !hard {
if err := s.db.BanPeer(id, "revoked via panel"); err != nil {
writeInternalError(w, err, "RevokePeer")
return
}
}
// Remove from memory (closes TCP/WS connections — Phase 3.9).
s.peers.Remove(id)
// Revocation: add device ID to blocklist to prevent re-registration.
if revoke && s.blocklist != nil {
s.blocklist.BlockID(id, "revoked via panel")
}
// CDAP revocation: send revoke message and disconnect CDAP device.
if revoke && s.cdapGw != nil {
if err := s.cdapGw.SendRevoke(r.Context(), id, "revoked via panel"); err != nil {
// Not an error — device may not be CDAP-connected
_ = err
}
}
// Cascade: revoke linked devices (e.g., paired mobile→desktop).
var cascadedIDs []string
if cascade && len(linkedIDs) > 0 {
for _, lid := range linkedIDs {
if hard {
s.db.HardDeletePeer(lid)
} else {
s.db.DeletePeer(lid)
if revoke {
s.db.BanPeer(lid, "revoked via cascade")
}
}
s.peers.Remove(lid)
if revoke && s.blocklist != nil {
s.blocklist.BlockID(lid, "revoked via cascade")
}
if revoke && s.cdapGw != nil {
s.cdapGw.SendRevoke(r.Context(), lid, "revoked via cascade")
}
cascadedIDs = append(cascadedIDs, lid)
}
}
// Publish event.
if s.eventBus != nil {
action := eventsModule.EventPeerDeleted
if revoke {
action = eventsModule.EventPeerRevoked
}
s.eventBus.Publish(eventsModule.Event{
Type: action,
Data: map[string]string{
"id": id,
"hard": fmt.Sprintf("%v", hard),
"revoke": fmt.Sprintf("%v", revoke),
"cascade": fmt.Sprintf("%v", cascadedIDs),
},
})
}
// Audit log.
if s.auditLog != nil {
action := audit.ActionPeerDeleted
if revoke {
action = audit.ActionPeerRevoked
}
details := map[string]string{
"hard": fmt.Sprintf("%v", hard),
"revoke": fmt.Sprintf("%v", revoke),
"cascade": fmt.Sprintf("%v", cascade),
}
if len(cascadedIDs) > 0 {
details["cascaded_ids"] = fmt.Sprintf("%v", cascadedIDs)
}
s.auditLog.Log(action, s.remoteIP(r), id, details)
}
resp := map[string]interface{}{
"status": "deleted",
"id": id,
"revoked": revoke,
}
if len(cascadedIDs) > 0 {
resp["cascaded"] = cascadedIDs
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleBanPeer(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.peerOrgScopeCheck(w, r, id) {
return
}
var body struct {
Reason string `json:"reason"`
}
json.NewDecoder(r.Body).Decode(&body)
if err := s.db.BanPeer(id, body.Reason); err != nil {
writeInternalError(w, err, "BanPeer")
return
}
// Remove peer from in-memory map to force immediate disconnect.
// The peer will be rejected on next RegisterPeer/RegisterPk attempt.
s.peers.Remove(id)
// Also update database status to OFFLINE since the peer is now banned
s.db.UpdatePeerStatus(id, "OFFLINE", "")
if s.auditLog != nil {
s.auditLog.Log(audit.ActionPeerBanned, s.remoteIP(r), id, map[string]string{"reason": body.Reason})
}
writeJSON(w, http.StatusOK, map[string]string{"status": "banned", "id": id})
}
func (s *Server) handleUnbanPeer(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.peerOrgScopeCheck(w, r, id) {
return
}
if err := s.db.UnbanPeer(id); err != nil {
writeInternalError(w, err, "UnbanPeer")
return
}
entry := s.peers.Get(id)
if entry != nil {
entry.Banned = false
}
// Also clear enrollment rejection lock so the device can re-queue (#351).
s.clearEnrollmentRejectionState(id)
if s.auditLog != nil {
s.auditLog.Log(audit.ActionPeerUnbanned, s.remoteIP(r), id, nil)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unbanned", "id": id})
}
// handleRestorePeer clears the soft_deleted flag on a previously deleted peer.
// SECURITY (GHSA-3v82-3gf8-fxx8): This is the ONLY supported path for bringing
// a deleted device back. UpsertPeer no longer restores rows implicitly.
func (s *Server) handleRestorePeer(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.peerOrgScopeCheck(w, r, id) {
return
}
deleted, err := s.db.IsPeerSoftDeleted(id)
if err != nil {
writeInternalError(w, err, "IsPeerSoftDeleted")
return
}
if !deleted {
live, gerr := s.db.GetPeer(id)
if gerr != nil {
writeInternalError(w, gerr, "GetPeer")
return
}
if live == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "peer not found"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "already_active", "id": id})
return
}
if err := s.db.RestorePeer(id); err != nil {
writeInternalError(w, err, "RestorePeer")
return
}
if err := s.db.UnbanPeer(id); err != nil {
log.Printf("[api] handleRestorePeer: UnbanPeer failed for %s: %v", id, err)
}
if s.blocklist != nil {
s.blocklist.UnblockID(id)
}
if s.eventBus != nil {
s.eventBus.Publish(eventsModule.Event{
Type: eventsModule.EventPeerRestored,
Data: map[string]string{"id": id},
})
}
if s.auditLog != nil {
s.auditLog.Log(audit.ActionPeerRestored, s.remoteIP(r), id, nil)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "restored", "id": id})
}
func (s *Server) handleChangePeerID(w http.ResponseWriter, r *http.Request) {
oldID := r.PathValue("id")
if !s.peerOrgScopeCheck(w, r, oldID) {
return
}
var body struct {
NewID string `json:"new_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.NewID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "new_id is required"})
return
}
// Validate new peer ID format (H1: prevent injection of arbitrary IDs)
if !peerIDRegexp.MatchString(body.NewID) {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "Invalid peer ID format. Must be 6-16 alphanumeric characters, hyphens, or underscores.",
})
return
}
targetState, err := s.db.GetPeerIDState(body.NewID)
if err != nil {
writeInternalError(w, err, "GetPeerIDState")
return
}
switch targetState {
case db.PeerIDActive:
writeJSON(w, http.StatusConflict, map[string]string{"error": "Device ID already exists"})
return
case db.PeerIDSoftDeleted:
writeJSON(w, http.StatusConflict, map[string]string{"error": "This ID belongs to a deleted device. Restore or permanently delete that device before reusing the ID."})
return
}
if err := s.db.ChangePeerID(oldID, body.NewID, "panel"); err != nil {
if errors.Is(err, db.ErrPeerIDExists) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "Device ID already exists"})
return
}
if errors.Is(err, db.ErrPeerIDSoftDeleted) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "This ID belongs to a deleted device. Restore or permanently delete that device before reusing the ID."})
return
}
if errors.Is(err, db.ErrPeerNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "peer not found"})
return
}
// Log the actual error but return a generic message to prevent leakage
log.Printf("[API] ChangePeerID %s -> %s: %v", oldID, body.NewID, err)
writeJSON(w, http.StatusConflict, map[string]string{"error": "Failed to change peer ID (ID conflict or not found)"})
return
}
// Update memory map
entry := s.peers.Remove(oldID)
if entry != nil {
entry.ID = body.NewID
s.peers.Put(entry)
}
if s.auditLog != nil {
s.auditLog.Log(audit.ActionPeerIDChanged, s.remoteIP(r), oldID, map[string]string{"new_id": body.NewID})
}
if s.eventBus != nil {
s.eventBus.Publish(eventsModule.Event{
Type: eventsModule.EventPeerIDChanged,
Data: map[string]string{
"old_id": oldID,
"new_id": body.NewID,
"source": "panel",
},
})
}
writeJSON(w, http.StatusOK, map[string]string{
"status": "changed",
"old_id": oldID,
"new_id": body.NewID,
})
}
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
// M6: Validate config key name to prevent arbitrary key injection
if !configKeyRegexp.MatchString(key) {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "Invalid config key. Must be 1-64 alphanumeric characters, underscores, hyphens, or dots.",
})
return
}
val, err := s.db.GetConfig(key)
if err != nil {
writeInternalError(w, err, "GetConfig")
return
}
writeJSON(w, http.StatusOK, map[string]string{"key": key, "value": val})
}
func (s *Server) handleSetConfig(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
// M6: Validate config key name to prevent arbitrary key injection
if !configKeyRegexp.MatchString(key) {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "Invalid config key. Must be 1-64 alphanumeric characters, underscores, hyphens, or dots.",
})
return
}
var body struct {
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON"})
return
}
if err := s.db.SetConfig(key, body.Value); err != nil {
writeInternalError(w, err, "SetConfig")
return
}
if s.auditLog != nil {
s.auditLog.Log(audit.ActionConfigChanged, s.remoteIP(r), key, map[string]string{"value": body.Value})
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "key": key})
}
// --- New Phase 4 Handlers ---
// handleStatusSummary returns aggregate device status counts.
// GET /api/peers/status/summary
func (s *Server) handleStatusSummary(w http.ResponseWriter, r *http.Request) {
stats := s.peers.GetStats(config.DegradedThreshold, config.CriticalThreshold)
writeJSON(w, http.StatusOK, stats)
}
// handleOnlinePeers returns detailed snapshots of all currently online peers.
// GET /api/peers/online
func (s *Server) handleOnlinePeers(w http.ResponseWriter, r *http.Request) {
snapshots := s.peers.GetAllSnapshots(config.DegradedThreshold, config.CriticalThreshold)
writeJSON(w, http.StatusOK, map[string]any{
"count": len(snapshots),
"peers": snapshots,
})
}
// handlePeerStatus returns detailed live status for a specific peer.
// GET /api/peers/{id}/status
func (s *Server) handlePeerStatus(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
snap, ok := s.peers.GetSnapshot(id, config.DegradedThreshold, config.CriticalThreshold)
if !ok {
// Not in memory — check database for historical data
dbPeer, err := s.db.GetPeer(id)
if err != nil {
writeInternalError(w, err, "GetPeer (status)")
return
}
if dbPeer == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Peer not found"})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": id,
"status": peer.StatusOffline,
"in_memory": false,
"db_status": dbPeer.Status,
"last_online": dbPeer.LastOnline,
"hostname": dbPeer.Hostname,
"os": dbPeer.OS,
"version": dbPeer.Version,
})
return
}
// Peer is in memory — return full live snapshot
writeJSON(w, http.StatusOK, map[string]any{
"in_memory": true,
"snapshot": snap,
})
}
// handlePeerMetrics returns historical metrics (CPU, memory, disk) for a peer.
// GET /api/peers/{id}/metrics?limit=100
func (s *Server) handlePeerMetrics(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" || !peerIDRegexp.MatchString(id) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid peer ID"})
return
}
if !s.peerOrgScopeCheck(w, r, id) {
return
}
// Parse optional limit param (default 100, max 1000)
limit := 100
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 1000 {
limit = parsed
}
}
metrics, err := s.db.GetPeerMetrics(id, limit)
if err != nil {
writeInternalError(w, err, "GetPeerMetrics")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"peer_id": id,
"count": len(metrics),
"metrics": metrics,
})
}
// handleListBlocklist returns all blocklist entries.
// GET /api/blocklist
func (s *Server) handleListBlocklist(w http.ResponseWriter, r *http.Request) {
if s.blocklist == nil {
writeJSON(w, http.StatusOK, map[string]any{
"enabled": false,
"entries": []any{},
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"enabled": true,
"count": s.blocklist.Count(),
"entries": s.blocklist.List(),
})
}
// handleAddBlocklist adds an entry to the blocklist.
// POST /api/blocklist
func (s *Server) handleAddBlocklist(w http.ResponseWriter, r *http.Request) {
if s.blocklist == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Blocklist not enabled",
})
return
}
var body struct {
Value string `json:"value"`
Type string `json:"type"` // "ip", "id", or "cidr"
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Value == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "value is required"})
return
}
switch body.Type {
case "ip":
s.blocklist.BlockIP(body.Value, body.Reason)
case "id":
s.blocklist.BlockID(body.Value, body.Reason)
case "cidr":
if err := s.blocklist.BlockCIDR(body.Value, body.Reason); err != nil {
log.Printf("[API] Invalid CIDR %s: %v", body.Value, err)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid CIDR notation"})
return
}
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "type must be ip, id, or cidr"})
return
}
if s.auditLog != nil {
s.auditLog.Log(audit.ActionBlocklistAdd, s.remoteIP(r), body.Value, map[string]string{
"type": body.Type, "reason": body.Reason,
})
}
writeJSON(w, http.StatusOK, map[string]string{"status": "blocked", "value": body.Value})
}
// handleRemoveBlocklist removes an entry from the blocklist.
// DELETE /api/blocklist/{entry}
func (s *Server) handleRemoveBlocklist(w http.ResponseWriter, r *http.Request) {
entry := r.PathValue("entry")
if s.blocklist == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Blocklist not enabled",
})
return
}
removed := s.blocklist.UnblockIP(entry) || s.blocklist.UnblockID(entry) || s.blocklist.UnblockCIDR(entry)
if !removed {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Entry not found"})
return
}
if s.auditLog != nil {
s.auditLog.Log(audit.ActionBlocklistRemove, s.remoteIP(r), entry, nil)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unblocked", "entry": entry})
}
// --- Helpers ---
var startTime = time.Now()
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
// writeInternalError logs the actual error internally and returns a generic
// "Internal server error" message to the client. This prevents leaking
// sensitive implementation details (file paths, SQL queries, stack traces).
func writeInternalError(w http.ResponseWriter, err error, action string) {
log.Printf("[API] %s failed: %v", action, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal server error"})
}
// remoteIP extracts the client IP from a request.
// When TrustProxy is enabled and the direct peer is in TRUSTED_PROXIES,
// respects X-Forwarded-For and X-Real-IP headers. Otherwise uses RemoteAddr.
func (s *Server) remoteIP(r *http.Request) string {
if s.cfg != nil && s.cfg.ShouldHonorForwardedHeaders(r.RemoteAddr) {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Use the first (leftmost) IP — the original client
client := strings.TrimSpace(xff)
if idx := strings.Index(client, ","); idx != -1 {
client = strings.TrimSpace(client[:idx])
}
// Strip optional :port and reject non-IP / port 0.
if host, port, err := net.SplitHostPort(client); err == nil {
if port != "0" {
if ip := net.ParseIP(host); ip != nil {
return ip.String()
}
}
} else if ip := net.ParseIP(client); ip != nil {
return ip.String()
}
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
client := strings.TrimSpace(xri)
if host, port, err := net.SplitHostPort(client); err == nil {
if port != "0" {
if ip := net.ParseIP(host); ip != nil {
return ip.String()
}
}
} else if ip := net.ParseIP(client); ip != nil {
return ip.String()
}
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// --- Tags Handlers ---
// handleSetPeerTags updates tags for a peer.
// PUT /api/peers/{id}/tags
// Accepts either { "tags": "tag1,tag2" } (string) or { "tags": ["tag1","tag2"] } (array).
func (s *Server) handleSetPeerTags(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var raw json.RawMessage
var wrapper struct {
Tags json.RawMessage `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&wrapper); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON"})
return
}
raw = wrapper.Tags
// Determine if tags is a string or an array
var tagsStr string
if len(raw) > 0 && raw[0] == '"' {
// JSON string
if err := json.Unmarshal(raw, &tagsStr); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid tags value"})
return
}
} else if len(raw) > 0 && raw[0] == '[' {
// JSON array
var arr []string
if err := json.Unmarshal(raw, &arr); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid tags array"})
return
}
tagsStr = strings.Join(arr, ",")
} else if len(raw) == 0 || string(raw) == "null" {
tagsStr = ""
} else {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Tags must be a string or array"})
return
}
if err := s.db.UpdatePeerTags(id, tagsStr); err != nil {
writeInternalError(w, err, "UpdatePeerTags")
return
}
if s.auditLog != nil {
s.auditLog.Log(audit.ActionPeerTagsUpdated, s.remoteIP(r), id, map[string]string{"tags": tagsStr})
}
writeJSON(w, http.StatusOK, map[string]string{"status": "updated", "id": id, "tags": tagsStr})
}
// handlePeersByTag returns peers matching a tag.
// GET /api/peers/by-tag/{tag}
func (s *Server) handlePeersByTag(w http.ResponseWriter, r *http.Request) {
tag := r.PathValue("tag")
peers, err := s.db.ListPeersByTag(tag)
if err != nil {
writeInternalError(w, err, "ListPeersByTag")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"tag": tag,
"count": len(peers),
"peers": peers,
})
}
// --- Audit Handler ---
// handleAuditEvents returns recent audit events.
// GET /api/audit/events?limit=50&action=peer_banned
func (s *Server) handleAuditEvents(w http.ResponseWriter, r *http.Request) {
if s.auditLog == nil {
writeJSON(w, http.StatusOK, map[string]any{
"enabled": false,
"events": []any{},
})
return
}
limit := 50
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := fmt.Sscanf(v, "%d", &limit); err != nil || n != 1 {
limit = 50
}
}
if limit > 1000 {
limit = 1000
}
action := r.URL.Query().Get("action")
var events []audit.Event
if action != "" {
events = s.auditLog.RecentByAction(audit.Action(action), limit)
} else {
events = s.auditLog.Recent(limit)
}
writeJSON(w, http.StatusOK, map[string]any{
"enabled": true,
"total": s.auditLog.Total(),
"count": len(events),
"events": events,
})
}
// --- Metrics Handler ---
// handleMetrics serves Prometheus text exposition format metrics.
// GET /metrics
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
if s.metrics == nil {
http.Error(w, "metrics not available", http.StatusServiceUnavailable)
return
}
// Refresh gauge values from current state
if s.peers != nil {
peerStats := s.peers.GetStats(config.DegradedThreshold, config.CriticalThreshold)
snap := s.peers.GetAllSnapshots(
config.DegradedThreshold,
config.CriticalThreshold,
)
var online, degraded, critical, offline int64
for _, p := range snap {
switch p.Status {
case peer.StatusOnline:
online++
case peer.StatusDegraded:
degraded++
case peer.StatusCritical:
critical++
default:
offline++
}
}
s.metrics.PeersTotal.Store(int64(len(snap)))
s.metrics.PeersOnline.Store(online)
s.metrics.PeersDegraded.Store(degraded)
s.metrics.PeersCritical.Store(critical)
s.metrics.PeersOffline.Store(offline)
s.metrics.PeersUDP.Store(int64(peerStats.UDP))
s.metrics.PeersTCP.Store(int64(peerStats.TCP))
s.metrics.PeersWS.Store(int64(peerStats.WS))
}
if s.relay != nil {
s.metrics.RelayActiveSessions.Store(s.relay.ActiveSessions.Load())
s.metrics.TotalRelaySessions.Store(s.relay.TotalRelayed.Load())
}
if s.billing != nil {
s.metrics.BillingPendingRelays.Store(int64(s.billing.PendingRelayCount()))
}
if s.blocklist != nil {
s.metrics.BlocklistCount.Store(int64(s.blocklist.Count()))
}
if s.eventBus != nil {
s.metrics.EventSubscribers.Store(int64(s.eventBus.Count()))
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
s.metrics.WritePrometheus(w)
}
// --- WebSocket Events Handler ---
// handleWSEvents upgrades the HTTP connection to WebSocket and streams events.
// GET /api/ws/events?filter=peer_online
func (s *Server) handleWSEvents(w http.ResponseWriter, r *http.Request) {
if s.eventBus == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Event bus not available",
})
return
}
acceptOpts := &websocket.AcceptOptions{}
if origins := s.cfg.GetAPIAllowedWSOrigins(); len(origins) > 0 {
acceptOpts.OriginPatterns = origins
}
conn, err := websocket.Accept(w, r, acceptOpts)
if err != nil {
log.Printf("[api] WebSocket upgrade failed: %v", err)
return
}
defer conn.CloseNow()
filter := eventsModule.EventType(r.URL.Query().Get("filter"))
sub := s.eventBus.Subscribe(filter)
defer s.eventBus.Unsubscribe(sub)
ctx := r.Context()
log.Printf("[api] WebSocket events client connected (filter=%q)", filter)
for {
select {
case <-ctx.Done():
conn.Close(websocket.StatusNormalClosure, "server closing")
return
case evt, ok := <-sub.Ch:
if !ok {
conn.Close(websocket.StatusNormalClosure, "unsubscribed")
return
}
data := eventsModule.MarshalEvent(evt)
if err := conn.Write(ctx, websocket.MessageText, data); err != nil {
log.Printf("[api] WebSocket write error: %v", err)
return
}
}
}
}
// POST /api/peers/{id}/wol — Send Wake-on-LAN magic packet (Phase 44)
func (s *Server) handleWakeOnLan(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
http.Error(w, `{"error":"missing peer id"}`, http.StatusBadRequest)
return
}
peer, err := s.db.GetPeer(id)
if err != nil || peer == nil {
http.Error(w, `{"error":"peer not found"}`, http.StatusNotFound)
return
}
// Try to get MAC from request body (operator may provide it)
var body struct {
MAC string `json:"mac"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
mac := body.MAC
if mac == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{
"success": false,
"error": "MAC address required. Provide {\"mac\": \"AA:BB:CC:DD:EE:FF\"} in request body.",
})
return
}
if err := sendWOLPacket(mac); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{
"success": false,
"error": fmt.Sprintf("Failed to send WOL packet: %v", err),
})
return
}
if s.auditLog != nil {
s.auditLog.Log(audit.ActionPeerUpdated, s.remoteIP(r), id, map[string]string{"action": "wol", "mac": mac})
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "mac": mac})
}
// sendWOLPacket sends a Wake-on-LAN magic packet to the given MAC address.
func sendWOLPacket(macStr string) error {
mac, err := net.ParseMAC(macStr)
if err != nil {
return fmt.Errorf("invalid MAC address %q: %w", macStr, err)
}
// Build magic packet: 6 bytes of 0xFF + 16 repetitions of MAC address
var packet [102]byte
for i := 0; i < 6; i++ {
packet[i] = 0xFF
}
for i := 0; i < 16; i++ {
copy(packet[6+i*6:], mac)
}
// Send via UDP broadcast
addr, err := net.ResolveUDPAddr("udp4", "255.255.255.255:9")
if err != nil {
return err
}
conn, err := net.DialUDP("udp4", nil, addr)
if err != nil {
return err
}
defer conn.Close()
_, err = conn.Write(packet[:])
return err
}
// ============================================================
// Access Policy Handlers (Unattended Access Management)
// ============================================================
func (s *Server) handleGetAccessPolicy(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "peer ID required"})
return
}
policy, err := s.db.GetAccessPolicy(id)
if err != nil {
// No policy = defaults (all disabled)
writeJSON(w, http.StatusOK, map[string]any{
"peer_id": id,
"unattended_enabled": false,
"password_set": false,
"schedule_enabled": false,
"schedule_days": "",
"schedule_start_time": "",
"schedule_end_time": "",
"schedule_timezone": "",
"allowed_operators": "",
"updated_at": "",
"updated_by": "",
})
return
}
writeJSON(w, http.StatusOK, policy)
}
func (s *Server) handleSaveAccessPolicy(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "peer ID required"})
return
}
var body struct {
UnattendedEnabled bool `json:"unattended_enabled"`
Password string `json:"password,omitempty"` // Plain text — will be hashed
ClearPassword bool `json:"clear_password,omitempty"` // If true, remove password
ScheduleEnabled bool `json:"schedule_enabled"`
ScheduleDays string `json:"schedule_days"`
ScheduleStartTime string `json:"schedule_start_time"`
ScheduleEndTime string `json:"schedule_end_time"`
ScheduleTimezone string `json:"schedule_timezone"`
AllowedOperators string `json:"allowed_operators"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid request body"})
return
}
// Validate schedule time format (HH:MM)
timeRe := regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`)
if body.ScheduleEnabled {
if body.ScheduleStartTime != "" && !timeRe.MatchString(body.ScheduleStartTime) {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid schedule_start_time format (HH:MM)"})
return
}
if body.ScheduleEndTime != "" && !timeRe.MatchString(body.ScheduleEndTime) {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid schedule_end_time format (HH:MM)"})
return
}
}
// Validate schedule days
validDays := map[string]bool{"mon": true, "tue": true, "wed": true, "thu": true, "fri": true, "sat": true, "sun": true}
if body.ScheduleDays != "" {
for _, d := range strings.Split(body.ScheduleDays, ",") {
if !validDays[strings.TrimSpace(strings.ToLower(d))] {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid day in schedule_days: " + d})
return
}
}
}
policy := &db.AccessPolicy{
PeerID: id,
UnattendedEnabled: body.UnattendedEnabled,
ScheduleEnabled: body.ScheduleEnabled,
ScheduleDays: body.ScheduleDays,
ScheduleStartTime: body.ScheduleStartTime,
ScheduleEndTime: body.ScheduleEndTime,
ScheduleTimezone: body.ScheduleTimezone,
AllowedOperators: body.AllowedOperators,
UpdatedBy: s.remoteIP(r),
}
// Hash password if provided
if body.Password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "failed to hash password"})
return
}
policy.PasswordHash = string(hash)
} else if body.ClearPassword {
policy.PasswordHash = "CLEAR"
}
// If PasswordHash is empty string, SaveAccessPolicy preserves existing hash
if err := s.db.SaveAccessPolicy(policy); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "failed to save access policy"})
return
}
if s.auditLog != nil {
s.auditLog.Log(audit.ActionPeerUpdated, s.remoteIP(r), id, map[string]string{
"action": "access_policy_updated",
"unattended": fmt.Sprintf("%v", body.UnattendedEnabled),
"schedule": fmt.Sprintf("%v", body.ScheduleEnabled),
})
}
writeJSON(w, http.StatusOK, map[string]any{"success": true})
}
func (s *Server) handleDeleteAccessPolicy(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "peer ID required"})
return
}
if err := s.db.DeleteAccessPolicy(id); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "failed to delete access policy"})
return
}
if s.auditLog != nil {
s.auditLog.Log(audit.ActionPeerUpdated, s.remoteIP(r), id, map[string]string{
"action": "access_policy_deleted",
})
}
writeJSON(w, http.StatusOK, map[string]any{"success": true})
}