Compare commits

...

10 Commits

Author SHA1 Message Date
Alex P 16509188b0 fix: use permission-based guards for RPC initialization calls
Replace UI-state based guards (showNicknameModal, currentMode checks) with
actual permission checks from PermissionsProvider. This ensures RPC calls
are only made when sessions have the required permissions.

Changes:
- getVideoState now checks for Permission.VIDEO_VIEW
- getKeyboardLedState checks for Permission.KEYBOARD_INPUT
- getKeyDownState checks for Permission.KEYBOARD_INPUT
- All checks wait for permissions to load (isLoadingPermissions)

This prevents "Permission denied" errors that occurred when RPC calls
were made before sessions received proper permissions.
2025-10-11 00:31:20 +03:00
Alex P 554b43fae9 Cleanup: Remove accidentally removed file 2025-10-11 00:23:09 +03:00
Alex P f27c2f4eb2 fix: prevent RPC calls before session approval
Issue:
- RPC calls (getVideoState, getKeyboardLedState, getKeyDownState) were being made
  immediately when RPC data channel opened, before permissions were granted
- This caused "Permission denied" errors in console for pending/queued sessions
- Sessions waiting for nickname or approval were triggering permission errors

Solution:
- Added currentMode checks to guard RPC initialization calls
- Only make RPC calls when session is in "primary" or "observer" mode
- Skip RPC calls for "pending" or "queued" sessions

Result: No more permission errors before session approval
2025-10-11 00:17:49 +03:00
Alex P 335c6ee35e refactor: centralize permissions with context provider and remove redundant code
Improvements:
- Centralized permission state management in PermissionsProvider
  - Eliminates duplicate RPC calls across components
  - Single source of truth for permission state
  - Automatic HID re-initialization on permission changes
- Split exports into separate files for React Fast Refresh compliance
  - Created types/permissions.ts for Permission enum
  - Created hooks/usePermissions.ts for the hook with safe defaults
  - Created contexts/PermissionsContext.ts for context definition
  - Updated PermissionsProvider.tsx to only export the provider component
- Removed redundant getSessionSettings RPC call (settings already in WebSocket/WebRTC messages)
- Added connectionModeChanged event handler for seamless emergency promotions
- Fixed approval dialog race condition by checking isLoadingPermissions
- Removed all redundant comments and code for leaner implementation
- Updated imports across 10+ component files

Result: Zero ESLint warnings, cleaner architecture, no duplicate RPC calls, all functionality preserved
2025-10-11 00:11:20 +03:00
Alex P f9e190f8b9 fix: prevent multiple getPermissions RPC calls on page load
The getPermissions useEffect had send and pollPermissions in its dependency
array. Since send gets recreated when rpcDataChannel changes, this caused
multiple getPermissions RPC calls (5 observed) on page load.

Fix:
- Add rpcDataChannel readiness check to prevent calls before channel is open
- Remove send and pollPermissions from dependency array
- Keep only currentMode and rpcDataChannel.readyState as dependencies

This ensures getPermissions is called only when:
1. The RPC channel becomes ready (readyState changes to "open")
2. The session mode changes (observer <-> primary)

Eliminates duplicate RPC calls while maintaining correct behavior for
mode changes and initial connection.
2025-10-10 22:23:25 +03:00
Alex P 00e6edbfa8 fix: prevent infinite getLocalVersion RPC calls on refresh
The getLocalVersion useEffect had getLocalVersion and hasPermission in
its dependency array. Since these functions are recreated on every render,
this caused an infinite loop of RPC calls when refreshing the primary
session, resulting in 100+ identical getLocalVersion requests.

Fix: Remove function references from dependency array, only keep appVersion
which is the actual data dependency. The effect now only runs once when
appVersion changes from null to a value.

This is the same pattern as the previous fix for getVideoState,
getKeyboardLedState, and getKeyDownState.
2025-10-10 22:15:44 +03:00
Alex P f90c255656 fix: prevent unnecessary RPC calls for pending sessions and increase rate limit
Changes:
- Add permission checks before making getVideoState, getKeyboardLedState,
  and getKeyDownState RPC calls to prevent rejected requests for sessions
  without VIDEO_VIEW permission
- Fix infinite loop issue by excluding hasPermission from useEffect
  dependency arrays (functions recreated on render cause infinite loops)
- Increase RPC rate limit from 100 to 500 per second to support 10+
  concurrent sessions with broadcasts and state updates

This eliminates console spam from permission denied errors and log spam
from continuous RPC calls, while improving multi-session performance.
2025-10-10 22:10:33 +03:00
Alex P 821675cd21 security: fix critical race conditions and add validation to session management
This commit addresses multiple CRITICAL and HIGH severity security issues
identified during the multi-session implementation review.

CRITICAL Fixes:
- Fix race condition in session approval handlers (jsonrpc.go)
  Previously approveNewSession and denyNewSession directly mutated
  session.Mode without holding the SessionManager.mu lock, potentially
  causing data corruption during concurrent access.

- Add validation to ApprovePrimaryRequest (session_manager.go:795-810)
  Now verifies that requester session exists and is in Queued mode
  before approving transfer, preventing invalid state transitions.

- Close dual-primary window during reconnection (session_manager.go:208)
  Added explicit primaryExists check to prevent brief window where two
  sessions could both be primary during reconnection.

HIGH Priority Fixes:
- Add nickname uniqueness validation (session_manager.go:152-159)
  Prevents multiple sessions from having the same nickname, both in
  AddSession and updateSessionNickname RPC handler.

Code Quality:
- Remove debug scaffolding from cloud.go (lines 515-520, 530)
  Cleaned up temporary debug logs that are no longer needed.

Thread Safety:
- Add centralized ApproveSession() method (session_manager.go:870-890)
- Add centralized DenySession() method (session_manager.go:894-912)
  Both methods properly acquire locks and validate session state.

- Update RPC handlers to use thread-safe methods
  approveNewSession and denyNewSession now call sessionManager methods
  instead of direct session mutation.

All changes have been verified with linters (golangci-lint: 0 issues).
2025-10-10 20:04:44 +03:00
Alex P 825299257d fix: correct grace period protection during primary reconnection
The session manager had backwards logic that prevented sessions from
restoring their primary status when reconnecting within the grace period.
This caused browser refreshes to demote primary sessions to observers.

Changes:
- Fix conditional in AddSession to allow primary restoration within grace
- Remove excessive debug logging throughout session manager
- Clean up unused decrActiveSessions function
- Remove unnecessary leading newline in NewSessionManager
- Update lastPrimaryID handling to support WebRTC reconnections
- Preserve grace periods during transfers to allow browser refreshes

The fix ensures that when a primary session refreshes their browser:
1. RemoveSession adds a grace period entry
2. New connection checks wasWithinGracePeriod and wasPreviouslyPrimary
3. Session correctly reclaims primary status

Blacklist system prevents demoted sessions from immediate re-promotion
while grace periods allow legitimate reconnections.
2025-10-10 19:33:49 +03:00
Alex P 309126bef6 [WIP] Bugfixes: session promotion 2025-10-10 10:16:21 +03:00
20 changed files with 473 additions and 525 deletions

View File

@ -512,12 +512,8 @@ func handleSessionRequest(
_ = wsjson.Write(context.Background(), c, gin.H{"error": "session manager not initialized"})
return fmt.Errorf("session manager not initialized")
}
scopedLogger.Debug().Msg("About to call AddSession")
err = sessionManager.AddSession(session, req.SessionSettings)
scopedLogger.Debug().
Bool("addSessionSucceeded", err == nil).
Str("error", fmt.Sprintf("%v", err)).
Msg("AddSession returned")
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to add session to session manager")
if err == ErrMaxSessionsReached {
@ -527,7 +523,6 @@ func handleSessionRequest(
}
return err
}
scopedLogger.Debug().Msg("AddSession completed successfully, continuing")
if session.HasPermission(PermissionPaste) {
cancelKeyboardMacro()

View File

@ -192,12 +192,10 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
if err := RequirePermission(session, PermissionSessionApprove); err != nil {
handlerErr = err
} else if sessionID, ok := request.Params["sessionId"].(string); ok {
if targetSession := sessionManager.GetSession(sessionID); targetSession != nil && targetSession.Mode == SessionModePending {
targetSession.Mode = SessionModeObserver
sessionManager.broadcastSessionListUpdate()
handlerErr = sessionManager.ApproveSession(sessionID)
if handlerErr == nil {
go sessionManager.broadcastSessionListUpdate()
result = map[string]interface{}{"status": "approved"}
} else {
handlerErr = errors.New("session not found or not pending")
}
} else {
handlerErr = errors.New("invalid sessionId parameter")
@ -206,14 +204,18 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
if err := RequirePermission(session, PermissionSessionApprove); err != nil {
handlerErr = err
} else if sessionID, ok := request.Params["sessionId"].(string); ok {
if targetSession := sessionManager.GetSession(sessionID); targetSession != nil && targetSession.Mode == SessionModePending {
writeJSONRPCEvent("sessionAccessDenied", map[string]interface{}{
"message": "Access denied by primary session",
}, targetSession)
sessionManager.broadcastSessionListUpdate()
handlerErr = sessionManager.DenySession(sessionID)
if handlerErr == nil {
// Notify the denied session
if targetSession := sessionManager.GetSession(sessionID); targetSession != nil {
go func() {
writeJSONRPCEvent("sessionAccessDenied", map[string]interface{}{
"message": "Access denied by primary session",
}, targetSession)
sessionManager.broadcastSessionListUpdate()
}()
}
result = map[string]interface{}{"status": "denied"}
} else {
handlerErr = errors.New("session not found or not pending")
}
} else {
handlerErr = errors.New("invalid sessionId parameter")
@ -251,24 +253,35 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
} else if targetSession := sessionManager.GetSession(sessionID); targetSession != nil {
// Users can update their own nickname, or admins can update any
if targetSession.ID == session.ID || session.HasPermission(PermissionSessionManage) {
targetSession.Nickname = nickname
// If session is pending and approval is required, send the approval request now that we have a nickname
if targetSession.Mode == SessionModePending && currentSessionSettings != nil && currentSessionSettings.RequireApproval {
if primary := sessionManager.GetPrimarySession(); primary != nil {
go func() {
writeJSONRPCEvent("newSessionPending", map[string]interface{}{
"sessionId": targetSession.ID,
"source": targetSession.Source,
"identity": targetSession.Identity,
"nickname": targetSession.Nickname,
}, primary)
}()
// Check nickname uniqueness
allSessions := sessionManager.GetAllSessions()
for _, existingSession := range allSessions {
if existingSession.ID != sessionID && existingSession.Nickname == nickname {
handlerErr = fmt.Errorf("nickname '%s' is already in use by another session", nickname)
break
}
}
sessionManager.broadcastSessionListUpdate()
result = map[string]interface{}{"status": "updated"}
if handlerErr == nil {
targetSession.Nickname = nickname
// If session is pending and approval is required, send the approval request now that we have a nickname
if targetSession.Mode == SessionModePending && currentSessionSettings != nil && currentSessionSettings.RequireApproval {
if primary := sessionManager.GetPrimarySession(); primary != nil {
go func() {
writeJSONRPCEvent("newSessionPending", map[string]interface{}{
"sessionId": targetSession.ID,
"source": targetSession.Source,
"identity": targetSession.Identity,
"nickname": targetSession.Nickname,
}, primary)
}()
}
}
sessionManager.broadcastSessionListUpdate()
result = map[string]interface{}{"status": "updated"}
}
} else {
handlerErr = errors.New("permission denied: can only update own nickname")
}

View File

@ -29,6 +29,9 @@ func Main() {
}
currentSessionSettings = config.SessionSettings
// Initialize global session manager (must be called after config and logger are ready)
initSessionManager()
var cancel context.CancelFunc
appCtx, cancel = context.WithCancel(context.Background())
defer cancel()

View File

@ -132,9 +132,6 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
return errors.New("session cannot be nil")
}
sm.logger.Debug().
Str("sessionID", session.ID).
Msg("AddSession ENTRY")
// Validate nickname if provided (matching frontend validation)
if session.Nickname != "" {
if len(session.Nickname) < 2 {
@ -152,6 +149,15 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
sm.mu.Lock()
defer sm.mu.Unlock()
// Check nickname uniqueness (only for non-empty nicknames)
if session.Nickname != "" {
for id, existingSession := range sm.sessions {
if id != session.ID && existingSession.Nickname == session.Nickname {
return fmt.Errorf("nickname '%s' is already in use by another session", session.Nickname)
}
}
}
wasWithinGracePeriod := false
wasPreviouslyPrimary := false
wasPreviouslyPending := false
@ -163,25 +169,17 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
wasPreviouslyPending = (reconnectInfo.Mode == SessionModePending)
}
}
delete(sm.reconnectGrace, session.ID)
}
// Check if a session with this ID already exists (reconnection)
if existing, exists := sm.sessions[session.ID]; exists {
sm.logger.Debug().
Str("sessionID", session.ID).
Msg("AddSession: session ID already exists - RECONNECTION PATH")
// SECURITY: Verify identity matches to prevent session hijacking
if existing.Identity != session.Identity || existing.Source != session.Source {
return fmt.Errorf("session ID already in use by different user (identity mismatch)")
}
// CRITICAL: Close old connection to prevent multiple active connections for same session ID
// Close old connection to prevent multiple active connections for same session ID
if existing.peerConnection != nil {
sm.logger.Info().
Str("sessionID", session.ID).
Msg("Closing old peer connection for session reconnection")
existing.peerConnection.Close()
}
@ -205,42 +203,24 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
// If this was the primary, try to restore primary status
if existing.Mode == SessionModePrimary {
// Check if this session is still the reserved primary AND not blacklisted
isBlacklisted := sm.isSessionBlacklisted(session.ID)
if sm.lastPrimaryID == session.ID && !isBlacklisted {
// This is the rightful primary reconnecting within grace period
// SECURITY: Prevent dual-primary window - only restore if no other primary exists
primaryExists := sm.primarySessionID != "" && sm.sessions[sm.primarySessionID] != nil
if sm.lastPrimaryID == session.ID && !isBlacklisted && !primaryExists {
sm.primarySessionID = session.ID
sm.lastPrimaryID = "" // Clear since primary successfully reconnected
delete(sm.reconnectGrace, session.ID) // Clear grace period
sm.logger.Debug().
Str("sessionID", session.ID).
Msg("Primary session successfully reconnected within grace period")
sm.lastPrimaryID = ""
delete(sm.reconnectGrace, session.ID)
} else {
// This session was primary but grace period expired, another took over, or is blacklisted
// Grace period expired, another session took over, or primary already exists
session.Mode = SessionModeObserver
sm.logger.Debug().
Str("sessionID", session.ID).
Str("currentPrimaryID", sm.primarySessionID).
Bool("isBlacklisted", isBlacklisted).
Msg("Former primary session reconnected but grace period expired, another took over, or session is blacklisted - demoting to observer")
}
}
// NOTE: Skip validation during reconnection to preserve grace period
// validateSinglePrimary() would clear primary slot during reconnection window
sm.logger.Debug().
Str("sessionID", session.ID).
Msg("AddSession: RETURNING from reconnection path")
go sm.broadcastSessionListUpdate()
return nil
}
if len(sm.sessions) >= sm.maxSessions {
sm.logger.Warn().
Int("currentSessions", len(sm.sessions)).
Int("maxSessions", sm.maxSessions).
Msg("AddSession: MAX SESSIONS REACHED")
return ErrMaxSessionsReached
}
@ -249,15 +229,6 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
session.ID = uuid.New().String()
}
// Clean up any grace period entries for this session since it's reconnecting
if wasWithinGracePeriod {
delete(sm.reconnectGrace, session.ID)
delete(sm.reconnectInfo, session.ID)
sm.logger.Info().
Str("sessionID", session.ID).
Msg("Session reconnected within grace period - cleaned up grace period entries")
}
// Set nickname from client settings if provided
if clientSettings != nil && clientSettings.Nickname != "" {
session.Nickname = clientSettings.Nickname
@ -266,9 +237,6 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
// Use global settings for requirements (not client-provided)
globalSettings := currentSessionSettings
// Set mode based on current state and global settings
// ATOMIC CHECK AND ASSIGN: Check if there's currently no primary session
// and assign primary status atomically to prevent race conditions
primaryExists := sm.primarySessionID != "" && sm.sessions[sm.primarySessionID] != nil
// Check if there's an active grace period for a primary session (different from this session)
@ -285,65 +253,28 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
}
}
// Check if this session was recently demoted via transfer
isBlacklisted := sm.isSessionBlacklisted(session.ID)
sm.logger.Debug().
Str("newSessionID", session.ID).
Str("nickname", session.Nickname).
Str("currentPrimarySessionID", sm.primarySessionID).
Bool("primaryExists", primaryExists).
Bool("hasActivePrimaryGracePeriod", hasActivePrimaryGracePeriod).
Int("totalSessions", len(sm.sessions)).
Bool("wasWithinGracePeriod", wasWithinGracePeriod).
Bool("wasPreviouslyPrimary", wasPreviouslyPrimary).
Bool("wasPreviouslyPending", wasPreviouslyPending).
Bool("isBlacklisted", isBlacklisted).
Msg("AddSession state analysis")
// Become primary only if:
// 1. Was previously primary (within grace) AND no current primary AND no other session has grace period, OR
// 2. There's no primary at all AND not recently transferred away AND no active grace period
// Never allow primary promotion if already restored within grace period or another session has grace period
shouldBecomePrimary := !wasWithinGracePeriod && !hasActivePrimaryGracePeriod && ((wasPreviouslyPrimary && !primaryExists) || (!primaryExists && !isBlacklisted))
if wasWithinGracePeriod {
sm.logger.Debug().
Str("sessionID", session.ID).
Bool("wasPreviouslyPrimary", wasPreviouslyPrimary).
Bool("primaryExists", primaryExists).
Str("currentPrimarySessionID", sm.primarySessionID).
Msg("Session within grace period - skipping primary promotion logic")
}
// Determine if this session should become primary
// If there's no primary AND this is the ONLY session, ALWAYS promote regardless of blacklist
isOnlySession := len(sm.sessions) == 0
shouldBecomePrimary := (wasWithinGracePeriod && wasPreviouslyPrimary && !primaryExists && !hasActivePrimaryGracePeriod) ||
(!wasWithinGracePeriod && !hasActivePrimaryGracePeriod && !primaryExists && (!isBlacklisted || isOnlySession))
if shouldBecomePrimary {
// Double-check primary doesn't exist (race condition prevention)
if sm.primarySessionID == "" || sm.sessions[sm.primarySessionID] == nil {
// Since we now generate nicknames automatically when required,
// we can always promote to primary when no primary exists
session.Mode = SessionModePrimary
sm.primarySessionID = session.ID
sm.lastPrimaryID = "" // Clear since we have a new primary
sm.lastPrimaryID = ""
// Clear all existing grace periods when a new primary is established
// This prevents multiple sessions from fighting for primary status via grace period
if len(sm.reconnectGrace) > 0 || len(sm.reconnectInfo) > 0 {
sm.logger.Debug().
Int("clearedGracePeriods", len(sm.reconnectGrace)).
Int("clearedReconnectInfo", len(sm.reconnectInfo)).
Str("newPrimarySessionID", session.ID).
Msg("Clearing all existing grace periods for new primary session in AddSession")
// Clear all existing grace periods and reconnect info
for oldSessionID := range sm.reconnectGrace {
delete(sm.reconnectGrace, oldSessionID)
}
for oldSessionID := range sm.reconnectInfo {
delete(sm.reconnectInfo, oldSessionID)
}
for oldSessionID := range sm.reconnectGrace {
delete(sm.reconnectGrace, oldSessionID)
}
for oldSessionID := range sm.reconnectInfo {
delete(sm.reconnectInfo, oldSessionID)
}
// Reset HID availability to force re-handshake for input functionality
session.hidRPCAvailable = false
} else {
session.Mode = SessionModeObserver
@ -381,8 +312,6 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
session.CreatedAt = time.Now()
session.LastActive = time.Now()
// Add session to sessions map BEFORE primary checks
// This ensures that primary existence checks work correctly during restoration
sm.sessions[session.ID] = session
sm.logger.Info().
@ -394,9 +323,14 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
// Ensure session has auto-generated nickname if needed
sm.ensureNickname(session)
// Validate sessions but respect grace periods
sm.validateSinglePrimary()
// Clean up grace period after validation completes
if wasWithinGracePeriod {
delete(sm.reconnectGrace, session.ID)
delete(sm.reconnectInfo, session.ID)
}
// Notify all sessions about the new connection
go sm.broadcastSessionListUpdate()
@ -410,9 +344,6 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
session, exists := sm.sessions[sessionID]
if !exists {
sm.logger.Debug().
Str("sessionID", sessionID).
Msg("RemoveSession called but session not found in map")
return
}
@ -431,12 +362,8 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
// Check if this session was marked for immediate removal (intentional logout)
isIntentionalLogout := false
if graceTime, exists := sm.reconnectGrace[sessionID]; exists {
// If grace period is already expired, this was intentional logout
if time.Now().After(graceTime) {
isIntentionalLogout = true
sm.logger.Info().
Str("sessionID", sessionID).
Msg("Detected intentional logout - skipping grace period")
delete(sm.reconnectGrace, sessionID)
delete(sm.reconnectInfo, sessionID)
}
@ -450,12 +377,9 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
// Only add grace period if this is NOT an intentional logout
if !isIntentionalLogout {
// Add a grace period for reconnection for all sessions
// Limit grace period entries to prevent memory exhaustion (DoS protection)
const maxGraceEntries = 10 // Reduced from 20 to limit memory usage
// Limit grace period entries to prevent memory exhaustion
const maxGraceEntries = 10
for len(sm.reconnectGrace) >= maxGraceEntries {
// Find and remove the oldest grace period entry
var oldestID string
var oldestTime time.Time
for id, graceTime := range sm.reconnectGrace {
@ -468,7 +392,7 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
delete(sm.reconnectGrace, oldestID)
delete(sm.reconnectInfo, oldestID)
} else {
break // Safety check to prevent infinite loop
break
}
}
@ -500,14 +424,8 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
sm.lastPrimaryID = sessionID // Remember this was the primary for grace period
sm.primarySessionID = "" // Clear primary slot so other sessions can be promoted
// Clear all blacklists to allow emergency promotion after grace period expires
// The blacklist is meant to prevent immediate re-promotion during manual transfers,
// but should not block emergency promotion after accidental disconnects
// Clear all blacklists to allow promotion after grace period expires
if len(sm.transferBlacklist) > 0 {
sm.logger.Info().
Int("clearedBlacklistEntries", len(sm.transferBlacklist)).
Str("disconnectedPrimaryID", sessionID).
Msg("Clearing transfer blacklist to allow grace period promotion")
sm.transferBlacklist = make([]TransferBlacklistEntry, 0)
}
@ -520,11 +438,6 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
// Trigger validation for potential promotion
if len(sm.sessions) > 0 {
sm.logger.Debug().
Str("removedPrimaryID", sessionID).
Bool("intentionalLogout", isIntentionalLogout).
Int("remainingSessions", len(sm.sessions)).
Msg("Triggering immediate validation for potential promotion")
sm.validateSinglePrimary()
}
}
@ -879,6 +792,23 @@ func (sm *SessionManager) ApprovePrimaryRequest(currentPrimaryID, requesterID st
return errors.New("not the primary session")
}
// SECURITY: Verify requester session exists and is in Queued mode
requesterSession, exists := sm.sessions[requesterID]
if !exists {
sm.logger.Error().
Str("requesterID", requesterID).
Msg("Requester session not found")
return errors.New("requester session not found")
}
if requesterSession.Mode != SessionModeQueued {
sm.logger.Error().
Str("requesterID", requesterID).
Str("actualMode", string(requesterSession.Mode)).
Msg("Requester session is not in queued mode")
return fmt.Errorf("requester session is not in queued mode (current mode: %s)", requesterSession.Mode)
}
// Remove requester from queue
sm.removeFromQueue(requesterID)
@ -936,6 +866,51 @@ func (sm *SessionManager) DenyPrimaryRequest(currentPrimaryID, requesterID strin
return nil
}
// ApproveSession approves a pending session (thread-safe)
func (sm *SessionManager) ApproveSession(sessionID string) error {
sm.mu.Lock()
defer sm.mu.Unlock()
session, exists := sm.sessions[sessionID]
if !exists {
return ErrSessionNotFound
}
if session.Mode != SessionModePending {
return errors.New("session is not in pending mode")
}
// Promote session to observer
session.Mode = SessionModeObserver
sm.logger.Info().
Str("sessionID", sessionID).
Msg("Session approved and promoted to observer")
return nil
}
// DenySession denies a pending session (thread-safe)
func (sm *SessionManager) DenySession(sessionID string) error {
sm.mu.Lock()
defer sm.mu.Unlock()
session, exists := sm.sessions[sessionID]
if !exists {
return ErrSessionNotFound
}
if session.Mode != SessionModePending {
return errors.New("session is not in pending mode")
}
sm.logger.Info().
Str("sessionID", sessionID).
Msg("Session denied - notifying session")
return nil
}
// ForEachSession executes a function for each active session
func (sm *SessionManager) ForEachSession(fn func(*Session)) {
sm.mu.RLock()
@ -967,17 +942,6 @@ func (sm *SessionManager) UpdateLastActive(sessionID string) {
func (sm *SessionManager) validateSinglePrimary() {
primarySessions := make([]*Session, 0)
sm.logger.Debug().
Int("sm.sessions_len", len(sm.sessions)).
Interface("sm.sessions_keys", func() []string {
keys := make([]string, 0, len(sm.sessions))
for k := range sm.sessions {
keys = append(keys, k)
}
return keys
}()).
Msg("validateSinglePrimary: checking sm.sessions map")
// Find all sessions that think they're primary
for _, session := range sm.sessions {
if session.Mode == SessionModePrimary {
@ -985,26 +949,18 @@ func (sm *SessionManager) validateSinglePrimary() {
}
}
// If we have multiple primaries, this is a critical bug - fix it
// If we have multiple primaries, fix it
if len(primarySessions) > 1 {
sm.logger.Error().
Int("primaryCount", len(primarySessions)).
Msg("CRITICAL BUG: Multiple primary sessions detected, fixing...")
Msg("Multiple primary sessions detected, fixing")
// Keep the first one as primary, demote the rest
for i, session := range primarySessions {
if i == 0 {
// Keep this as primary and update manager state
sm.primarySessionID = session.ID
sm.logger.Info().
Str("keptPrimaryID", session.ID).
Msg("Kept session as primary")
} else {
// Demote all others
session.Mode = SessionModeObserver
sm.logger.Info().
Str("demotedSessionID", session.ID).
Msg("Demoted duplicate primary session")
}
}
}
@ -1019,25 +975,14 @@ func (sm *SessionManager) validateSinglePrimary() {
}
// Don't clear primary slot if there's a grace period active
// This prevents instant promotion during primary session reconnection
if len(primarySessions) == 0 && sm.primarySessionID != "" {
// Check if the current primary is in grace period waiting to reconnect
if sm.lastPrimaryID == sm.primarySessionID {
if graceTime, exists := sm.reconnectGrace[sm.primarySessionID]; exists {
if time.Now().Before(graceTime) {
// Primary is in grace period, DON'T clear the slot yet
sm.logger.Info().
Str("gracePrimaryID", sm.primarySessionID).
Msg("Primary slot preserved - session in grace period")
return // Exit validation, keep primary slot reserved
return // Keep primary slot reserved during grace period
}
}
}
// No grace period, safe to clear orphaned primary
sm.logger.Warn().
Str("orphanedPrimaryID", sm.primarySessionID).
Msg("Cleared orphaned primary ID")
sm.primarySessionID = ""
}
@ -1048,30 +993,12 @@ func (sm *SessionManager) validateSinglePrimary() {
if reconnectInfo, hasInfo := sm.reconnectInfo[sessionID]; hasInfo {
if reconnectInfo.Mode == SessionModePrimary {
hasActivePrimaryGracePeriod = true
sm.logger.Debug().
Str("gracePrimaryID", sessionID).
Dur("remainingGrace", time.Until(graceTime)).
Msg("Active grace period detected for primary session - blocking auto-promotion")
break
}
}
}
}
// Build session IDs list for debugging
sessionIDs := make([]string, 0, len(sm.sessions))
for id := range sm.sessions {
sessionIDs = append(sessionIDs, id)
}
sm.logger.Debug().
Int("primarySessionCount", len(primarySessions)).
Str("primarySessionID", sm.primarySessionID).
Int("totalSessions", len(sm.sessions)).
Strs("sessionIDs", sessionIDs).
Bool("hasActivePrimaryGracePeriod", hasActivePrimaryGracePeriod).
Msg("validateSinglePrimary state check")
// Auto-promote if there are NO primary sessions at all AND no active grace period
if len(primarySessions) == 0 && sm.primarySessionID == "" && len(sm.sessions) > 0 && !hasActivePrimaryGracePeriod {
// Find a session to promote to primary
@ -1093,13 +1020,6 @@ func (sm *SessionManager) validateSinglePrimary() {
sm.logger.Warn().
Msg("No eligible session found for emergency auto-promotion")
}
} else {
sm.logger.Debug().
Int("primarySessions", len(primarySessions)).
Str("primarySessionID", sm.primarySessionID).
Bool("hasSessions", len(sm.sessions) > 0).
Bool("hasActivePrimaryGracePeriod", hasActivePrimaryGracePeriod).
Msg("Emergency auto-promotion conditions not met")
}
}
@ -1134,6 +1054,9 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
if fromExists && fromSession.Mode == SessionModePrimary {
fromSession.Mode = SessionModeObserver
fromSession.hidRPCAvailable = false
// Always delete grace period when demoting - no exceptions
// If a session times out or is manually transferred, it should not auto-reclaim primary
delete(sm.reconnectGrace, fromSessionID)
delete(sm.reconnectInfo, fromSessionID)
@ -1160,7 +1083,11 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
toSession.Mode = SessionModePrimary
toSession.hidRPCAvailable = false // Force re-handshake
sm.primarySessionID = toSessionID
sm.lastPrimaryID = toSessionID // Set to new primary so grace period works on refresh
// ALWAYS set lastPrimaryID to the new primary to support WebRTC reconnections
// This allows the newly promoted session to handle page refreshes correctly
// The blacklist system prevents unwanted takeovers during manual transfers
sm.lastPrimaryID = toSessionID
// Clear input state
sm.clearInputState()
@ -1171,39 +1098,48 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
}
// Apply bidirectional blacklisting - protect newly promoted session
// Only apply blacklisting for MANUAL transfers, not emergency promotions
// Emergency promotions need to happen immediately without blacklist interference
isManualTransfer := (transferType == "direct_transfer" || transferType == "approval_transfer" || transferType == "release_transfer")
now := time.Now()
blacklistDuration := 60 * time.Second
blacklistedCount := 0
// First, clear any existing blacklist entries for the newly promoted session
cleanedBlacklist := make([]TransferBlacklistEntry, 0)
for _, entry := range sm.transferBlacklist {
if entry.SessionID != toSessionID { // Remove any old blacklist entries for the new primary
cleanedBlacklist = append(cleanedBlacklist, entry)
if isManualTransfer {
// First, clear any existing blacklist entries for the newly promoted session
cleanedBlacklist := make([]TransferBlacklistEntry, 0)
for _, entry := range sm.transferBlacklist {
if entry.SessionID != toSessionID { // Remove any old blacklist entries for the new primary
cleanedBlacklist = append(cleanedBlacklist, entry)
}
}
}
sm.transferBlacklist = cleanedBlacklist
sm.transferBlacklist = cleanedBlacklist
// Then blacklist all other sessions
for sessionID := range sm.sessions {
if sessionID != toSessionID { // Don't blacklist the newly promoted session
sm.transferBlacklist = append(sm.transferBlacklist, TransferBlacklistEntry{
SessionID: sessionID,
ExpiresAt: now.Add(blacklistDuration),
})
blacklistedCount++
// Then blacklist all other sessions
for sessionID := range sm.sessions {
if sessionID != toSessionID { // Don't blacklist the newly promoted session
sm.transferBlacklist = append(sm.transferBlacklist, TransferBlacklistEntry{
SessionID: sessionID,
ExpiresAt: now.Add(blacklistDuration),
})
blacklistedCount++
}
}
}
// Clear all grace periods to prevent conflicts
if len(sm.reconnectGrace) > 0 || len(sm.reconnectInfo) > 0 {
for oldSessionID := range sm.reconnectGrace {
delete(sm.reconnectGrace, oldSessionID)
}
for oldSessionID := range sm.reconnectInfo {
delete(sm.reconnectInfo, oldSessionID)
}
}
// DON'T clear grace periods during transfers!
// Grace periods and blacklisting serve different purposes:
// - Grace periods: Allow disconnected sessions to reconnect and reclaim their role
// - Blacklisting: Prevent recently demoted sessions from immediately taking primary again
//
// When a primary session is transferred to another session:
// 1. The newly promoted session should be able to refresh its browser without losing primary
// 2. When it refreshes, RemoveSession is called, which adds a grace period
// 3. When it reconnects, it should find itself in lastPrimaryID and reclaim primary
//
// The blacklist prevents the OLD primary from immediately reclaiming control,
// while the grace period allows the NEW primary to safely refresh its browser.
// These mechanisms complement each other and should not interfere.
sm.logger.Info().
Str("fromSessionID", fromSessionID).
@ -1214,8 +1150,9 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
Dur("blacklistDuration", blacklistDuration).
Msg("Primary role transferred with bidirectional protection")
// Validate session consistency after role transfer
sm.validateSinglePrimary()
// DON'T validate here - causes recursive calls and map iteration issues
// The caller (AddSession, RemoveSession, etc.) will validate after we return
// sm.validateSinglePrimary() // REMOVED to prevent recursion
// Handle WebRTC connection state for promoted sessions
// When a session changes from observer to primary, the existing WebRTC connection
@ -1629,22 +1566,31 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
if currentSessionSettings != nil && currentSessionSettings.RequireApproval {
isEmergencyPromotion = true
// Rate limiting for emergency promotions
if now.Sub(sm.lastEmergencyPromotion) < 30*time.Second {
sm.logger.Warn().
Str("expiredSessionID", sessionID).
Dur("timeSinceLastEmergency", now.Sub(sm.lastEmergencyPromotion)).
Msg("Emergency promotion rate limit exceeded - potential attack")
continue // Skip this grace period expiration
}
// Limit consecutive emergency promotions
if sm.consecutiveEmergencyPromotions >= 3 {
// CRITICAL: Ensure we ALWAYS have a primary session
// If there's NO primary, bypass rate limits entirely
hasPrimary := sm.primarySessionID != ""
if !hasPrimary {
sm.logger.Error().
Str("expiredSessionID", sessionID).
Int("consecutiveCount", sm.consecutiveEmergencyPromotions).
Msg("Too many consecutive emergency promotions - blocking for security")
continue // Skip this grace period expiration
Msg("CRITICAL: No primary session exists - bypassing all rate limits")
} else {
// Rate limiting for emergency promotions (only when we have a primary)
if now.Sub(sm.lastEmergencyPromotion) < 30*time.Second {
sm.logger.Warn().
Str("expiredSessionID", sessionID).
Dur("timeSinceLastEmergency", now.Sub(sm.lastEmergencyPromotion)).
Msg("Emergency promotion rate limit exceeded - potential attack")
continue // Skip this grace period expiration
}
// Limit consecutive emergency promotions
if sm.consecutiveEmergencyPromotions >= 3 {
sm.logger.Error().
Str("expiredSessionID", sessionID).
Int("consecutiveCount", sm.consecutiveEmergencyPromotions).
Msg("Too many consecutive emergency promotions - blocking for security")
continue // Skip this grace period expiration
}
}
promotedSessionID = sm.findMostTrustedSessionForEmergency()
@ -1745,13 +1691,23 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
if currentSessionSettings != nil && currentSessionSettings.RequireApproval {
isEmergencyPromotion = true
// Rate limiting for emergency promotions
if now.Sub(sm.lastEmergencyPromotion) < 30*time.Second {
sm.logger.Warn().
// CRITICAL: Ensure we ALWAYS have a primary session
// primarySessionID was just cleared above, so this will always be empty
// But check anyway for completeness
hasPrimary := sm.primarySessionID != ""
if !hasPrimary {
sm.logger.Error().
Str("timedOutSessionID", timedOutSessionID).
Dur("timeSinceLastEmergency", now.Sub(sm.lastEmergencyPromotion)).
Msg("Emergency promotion rate limit exceeded during timeout - potential attack")
continue // Skip this timeout
Msg("CRITICAL: No primary session after timeout - bypassing all rate limits")
} else {
// Rate limiting for emergency promotions (only when we have a primary)
if now.Sub(sm.lastEmergencyPromotion) < 30*time.Second {
sm.logger.Warn().
Str("timedOutSessionID", timedOutSessionID).
Dur("timeSinceLastEmergency", now.Sub(sm.lastEmergencyPromotion)).
Msg("Emergency promotion rate limit exceeded during timeout - potential attack")
continue // Skip this timeout
}
}
// Use trust-based selection but exclude the timed-out session
@ -1820,14 +1776,12 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
// Run validation immediately if a grace period expired, otherwise run periodically
if gracePeriodExpired {
sm.logger.Debug().Msg("Running immediate validation after grace period expiration")
sm.validateSinglePrimary()
} else {
// Periodic validateSinglePrimary to catch deadlock states
validationCounter++
if validationCounter >= 10 { // Every 10 seconds
validationCounter = 0
sm.logger.Debug().Msg("Running periodic session validation to catch deadlock states")
sm.validateSinglePrimary()
}
}
@ -1843,7 +1797,16 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
}
// Global session manager instance
var sessionManager = NewSessionManager(websocketLogger)
var (
sessionManager *SessionManager
sessionManagerOnce sync.Once
)
func initSessionManager() {
sessionManagerOnce.Do(func() {
sessionManager = NewSessionManager(websocketLogger)
})
}
// Global session settings - references config.SessionSettings for persistence
var currentSessionSettings *SessionSettings

View File

@ -21,7 +21,8 @@ import ExtensionPopover from "@/components/popovers/ExtensionPopover";
import SessionPopover from "@/components/popovers/SessionPopover";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { useSessionStore } from "@/stores/sessionStore";
import { usePermissions, Permission } from "@/hooks/usePermissions";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
export default function Actionbar({
requestFullscreen,

View File

@ -6,7 +6,8 @@ import Container from "@components/Container";
import { useMacrosStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { usePermissions, Permission } from "@/hooks/usePermissions";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
export default function MacroBar() {
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();

View File

@ -8,7 +8,8 @@ import clsx from "clsx";
import { useSessionStore } from "@/stores/sessionStore";
import { sessionApi } from "@/api/sessionApi";
import { Button } from "@/components/Button";
import { usePermissions, Permission } from "@/hooks/usePermissions";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;

View File

@ -2,7 +2,8 @@ import { PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
import clsx from "clsx";
import { formatters } from "@/utils";
import { usePermissions, Permission } from "@/hooks/usePermissions";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
interface Session {
id: string;

View File

@ -14,7 +14,8 @@ import {
useSettingsStore,
useVideoStore,
} from "@/hooks/stores";
import { usePermissions, Permission } from "@/hooks/usePermissions";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
import useMouse from "@/hooks/useMouse";
import {

View File

@ -0,0 +1,5 @@
import { createContext } from "react";
import { PermissionsContextValue } from "@/hooks/usePermissions";
export const PermissionsContext = createContext<PermissionsContextValue | undefined>(undefined);

View File

@ -1,169 +1,34 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useContext } from "react";
import { useJsonRpc, JsonRpcRequest } from "@/hooks/useJsonRpc";
import { useSessionStore } from "@/stores/sessionStore";
import { useRTCStore } from "@/hooks/stores";
import { PermissionsContext } from "@/contexts/PermissionsContext";
import { Permission } from "@/types/permissions";
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
// Permission types matching backend
export enum Permission {
// Video/Display permissions
VIDEO_VIEW = "video.view",
// Input permissions
KEYBOARD_INPUT = "keyboard.input",
MOUSE_INPUT = "mouse.input",
PASTE = "clipboard.paste",
// Session management permissions
SESSION_TRANSFER = "session.transfer",
SESSION_APPROVE = "session.approve",
SESSION_KICK = "session.kick",
SESSION_REQUEST_PRIMARY = "session.request_primary",
SESSION_RELEASE_PRIMARY = "session.release_primary",
SESSION_MANAGE = "session.manage",
// Mount/Media permissions
MOUNT_MEDIA = "mount.media",
UNMOUNT_MEDIA = "mount.unmedia",
MOUNT_LIST = "mount.list",
// Extension permissions
EXTENSION_MANAGE = "extension.manage",
EXTENSION_ATX = "extension.atx",
EXTENSION_DC = "extension.dc",
EXTENSION_SERIAL = "extension.serial",
EXTENSION_WOL = "extension.wol",
// Settings permissions
SETTINGS_READ = "settings.read",
SETTINGS_WRITE = "settings.write",
SETTINGS_ACCESS = "settings.access",
// System permissions
SYSTEM_REBOOT = "system.reboot",
SYSTEM_UPDATE = "system.update",
SYSTEM_NETWORK = "system.network",
// Power/USB control permissions
POWER_CONTROL = "power.control",
USB_CONTROL = "usb.control",
// Terminal/Serial permissions
TERMINAL_ACCESS = "terminal.access",
SERIAL_ACCESS = "serial.access",
}
interface PermissionsResponse {
mode: string;
export interface PermissionsContextValue {
permissions: Record<string, boolean>;
isLoading: boolean;
hasPermission: (permission: Permission) => boolean;
hasAnyPermission: (...perms: Permission[]) => boolean;
hasAllPermissions: (...perms: Permission[]) => boolean;
isPrimary: () => boolean;
isObserver: () => boolean;
isPending: () => boolean;
}
export function usePermissions() {
const { currentMode } = useSessionStore();
const { setRpcHidProtocolVersion, rpcHidChannel } = useRTCStore();
const [permissions, setPermissions] = useState<Record<string, boolean>>({});
const [isLoading, setIsLoading] = useState(true);
const previousCanControl = useRef<boolean>(false);
export function usePermissions(): PermissionsContextValue {
const context = useContext(PermissionsContext);
// Function to poll permissions
const pollPermissions = useCallback((send: RpcSendFunction) => {
if (!send) return;
if (context === undefined) {
return {
permissions: {},
isLoading: true,
hasPermission: () => false,
hasAnyPermission: () => false,
hasAllPermissions: () => false,
isPrimary: () => false,
isObserver: () => false,
isPending: () => false,
};
}
setIsLoading(true);
send("getPermissions", {}, (response: { result?: unknown; error?: { message: string } }) => {
if (!response.error && response.result) {
const result = response.result as PermissionsResponse;
setPermissions(result.permissions);
}
setIsLoading(false);
});
}, []);
// Handle connectionModeChanged events that require WebRTC reconnection
const handleRpcRequest = useCallback((request: JsonRpcRequest) => {
if (request.method === "connectionModeChanged") {
console.info("Connection mode changed, WebRTC reconnection required", request.params);
// For session promotion that requires reconnection, refresh the page
// This ensures WebRTC connection is re-established with proper mode
const params = request.params as { action?: string; reason?: string };
if (params.action === "reconnect_required" && params.reason === "session_promotion") {
console.info("Session promoted, refreshing page to re-establish WebRTC connection");
// Small delay to ensure all state updates are processed
setTimeout(() => {
window.location.reload();
}, 500);
}
}
}, []);
const { send } = useJsonRpc(handleRpcRequest);
useEffect(() => {
pollPermissions(send);
}, [send, currentMode, pollPermissions]);
// Monitor permission changes and re-initialize HID when gaining control
useEffect(() => {
const currentCanControl = hasPermission(Permission.KEYBOARD_INPUT) && hasPermission(Permission.MOUSE_INPUT);
const hadControl = previousCanControl.current;
// If we just gained control permissions, re-initialize HID
if (currentCanControl && !hadControl && rpcHidChannel?.readyState === "open") {
console.info("Gained control permissions, re-initializing HID");
// Reset protocol version to force re-handshake
setRpcHidProtocolVersion(null);
// Import handshake functionality dynamically
import("./hidRpc").then(({ HID_RPC_VERSION, HandshakeMessage }) => {
// Send handshake after a small delay
setTimeout(() => {
if (rpcHidChannel?.readyState === "open") {
const handshakeMessage = new HandshakeMessage(HID_RPC_VERSION);
try {
const data = handshakeMessage.marshal();
rpcHidChannel.send(data as unknown as ArrayBuffer);
console.info("Sent HID handshake after permission change");
} catch (e) {
console.error("Failed to send HID handshake", e);
}
}
}, 100);
});
}
previousCanControl.current = currentCanControl;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [permissions, rpcHidChannel, setRpcHidProtocolVersion]); // hasPermission depends on permissions which is already in deps
const hasPermission = (permission: Permission): boolean => {
return permissions[permission] === true;
};
const hasAnyPermission = (...perms: Permission[]): boolean => {
return perms.some(perm => hasPermission(perm));
};
const hasAllPermissions = (...perms: Permission[]): boolean => {
return perms.every(perm => hasPermission(perm));
};
// Session mode helpers
const isPrimary = () => currentMode === "primary";
const isObserver = () => currentMode === "observer";
const isPending = () => currentMode === "pending";
return {
permissions,
isLoading,
hasPermission,
hasAnyPermission,
hasAllPermissions,
isPrimary,
isObserver,
isPending,
};
}
return context;
}

View File

@ -16,6 +16,10 @@ interface ModeChangedData {
mode: string;
}
interface ConnectionModeChangedData {
newMode: string;
}
export function useSessionEvents(sendFn: RpcSendFunction | null) {
const {
currentMode,
@ -27,7 +31,6 @@ export function useSessionEvents(sendFn: RpcSendFunction | null) {
const sendFnRef = useRef(sendFn);
sendFnRef.current = sendFn;
// Handle session-related RPC events
const handleSessionEvent = (method: string, params: unknown) => {
switch (method) {
case "sessionsUpdated":
@ -36,6 +39,9 @@ export function useSessionEvents(sendFn: RpcSendFunction | null) {
case "modeChanged":
handleModeChanged(params as ModeChangedData);
break;
case "connectionModeChanged":
handleConnectionModeChanged(params as ConnectionModeChangedData);
break;
case "hidReadyForPrimary":
handleHidReadyForPrimary();
break;
@ -103,23 +109,25 @@ export function useSessionEvents(sendFn: RpcSendFunction | null) {
}
};
const handleConnectionModeChanged = (data: ConnectionModeChangedData) => {
if (data.newMode) {
handleModeChanged({ mode: data.newMode });
}
};
const handleHidReadyForPrimary = () => {
// Backend signals that HID system is ready for primary session re-initialization
const { rpcHidChannel } = useRTCStore.getState();
if (rpcHidChannel?.readyState === "open") {
// Trigger HID re-handshake
rpcHidChannel.dispatchEvent(new Event("open"));
}
};
const handleOtherSessionConnected = () => {
// Another session is trying to connect
notify.warning("Another session is connecting", {
duration: 5000
});
};
// Fetch initial sessions when component mounts
useEffect(() => {
if (!sendFnRef.current) return;
@ -136,7 +144,6 @@ export function useSessionEvents(sendFn: RpcSendFunction | null) {
fetchSessions();
}, [setSessions, setSessionError]);
// Set up periodic session refresh
useEffect(() => {
if (!sendFnRef.current) return;

View File

@ -3,7 +3,8 @@ import { useEffect, useCallback, useState } from "react";
import { useSessionStore } from "@/stores/sessionStore";
import { useSessionEvents } from "@/hooks/useSessionEvents";
import { useSettingsStore } from "@/hooks/stores";
import { usePermissions, Permission } from "@/hooks/usePermissions";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
@ -32,21 +33,19 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
clearSession
} = useSessionStore();
const { hasPermission } = usePermissions();
const { hasPermission, isLoading: isLoadingPermissions } = usePermissions();
const { requireSessionApproval } = useSettingsStore();
const { handleSessionEvent } = useSessionEvents(sendFn);
const [primaryControlRequest, setPrimaryControlRequest] = useState<PrimaryControlRequest | null>(null);
const [newSessionRequest, setNewSessionRequest] = useState<NewSessionRequest | null>(null);
// Handle session info from WebRTC answer
const handleSessionResponse = useCallback((response: SessionResponse) => {
if (response.sessionId && response.mode) {
setCurrentSession(response.sessionId, response.mode as "primary" | "observer" | "queued" | "pending");
}
}, [setCurrentSession]);
// Handle approval of primary control request
const handleApprovePrimaryRequest = useCallback(async (requestId: string) => {
if (!sendFn) return;
@ -63,7 +62,6 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
});
}, [sendFn]);
// Handle denial of primary control request
const handleDenyPrimaryRequest = useCallback(async (requestId: string) => {
if (!sendFn) return;
@ -80,7 +78,6 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
});
}, [sendFn]);
// Handle approval of new session
const handleApproveNewSession = useCallback(async (sessionId: string) => {
if (!sendFn) return;
@ -97,7 +94,6 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
});
}, [sendFn]);
// Handle denial of new session
const handleDenyNewSession = useCallback(async (sessionId: string) => {
if (!sendFn) return;
@ -114,34 +110,30 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
});
}, [sendFn]);
// Handle RPC events
const handleRpcEvent = useCallback((method: string, params: unknown) => {
// Pass session events to the session event handler
if (method === "sessionsUpdated" ||
method === "modeChanged" ||
method === "connectionModeChanged" ||
method === "otherSessionConnected") {
handleSessionEvent(method, params);
}
// Handle new session approval request (only if approval is required and user has permission)
if (method === "newSessionPending" && requireSessionApproval && hasPermission(Permission.SESSION_APPROVE)) {
setNewSessionRequest(params as NewSessionRequest);
if (method === "newSessionPending" && requireSessionApproval) {
if (isLoadingPermissions || hasPermission(Permission.SESSION_APPROVE)) {
setNewSessionRequest(params as NewSessionRequest);
}
}
// Handle primary control request
if (method === "primaryControlRequested") {
setPrimaryControlRequest(params as PrimaryControlRequest);
}
// Handle approval/denial responses
if (method === "primaryControlApproved") {
// Clear requesting state in store
const { setRequestingPrimary } = useSessionStore.getState();
setRequestingPrimary(false);
}
if (method === "primaryControlDenied") {
// Clear requesting state and show error
const { setRequestingPrimary, setSessionError } = useSessionStore.getState();
setRequestingPrimary(false);
setSessionError("Your primary control request was denied");
@ -152,9 +144,14 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
const errorParams = params as { message?: string };
setSessionError(errorParams.message || "Session access was denied by the primary session");
}
}, [handleSessionEvent, hasPermission, requireSessionApproval]);
}, [handleSessionEvent, hasPermission, isLoadingPermissions, requireSessionApproval]);
useEffect(() => {
if (!isLoadingPermissions && newSessionRequest && !hasPermission(Permission.SESSION_APPROVE)) {
setNewSessionRequest(null);
}
}, [isLoadingPermissions, hasPermission, newSessionRequest]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearSession();

View File

@ -0,0 +1,106 @@
import { useState, useEffect, useRef, useCallback, ReactNode } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useSessionStore } from "@/stores/sessionStore";
import { useRTCStore } from "@/hooks/stores";
import { Permission } from "@/types/permissions";
import { PermissionsContextValue } from "@/hooks/usePermissions";
import { PermissionsContext } from "@/contexts/PermissionsContext";
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
interface PermissionsResponse {
mode: string;
permissions: Record<string, boolean>;
}
export function PermissionsProvider({ children }: { children: ReactNode }) {
const { currentMode } = useSessionStore();
const { setRpcHidProtocolVersion, rpcHidChannel, rpcDataChannel } = useRTCStore();
const [permissions, setPermissions] = useState<Record<string, boolean>>({});
const [isLoading, setIsLoading] = useState(true);
const previousCanControl = useRef<boolean>(false);
const pollPermissions = useCallback((send: RpcSendFunction) => {
if (!send) return;
setIsLoading(true);
send("getPermissions", {}, (response: { result?: unknown; error?: { message: string } }) => {
if (!response.error && response.result) {
const result = response.result as PermissionsResponse;
setPermissions(result.permissions);
}
setIsLoading(false);
});
}, []);
const { send } = useJsonRpc();
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
pollPermissions(send);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentMode, rpcDataChannel?.readyState]);
const hasPermission = useCallback((permission: Permission): boolean => {
return permissions[permission] === true;
}, [permissions]);
const hasAnyPermission = useCallback((...perms: Permission[]): boolean => {
return perms.some(perm => hasPermission(perm));
}, [hasPermission]);
const hasAllPermissions = useCallback((...perms: Permission[]): boolean => {
return perms.every(perm => hasPermission(perm));
}, [hasPermission]);
useEffect(() => {
const currentCanControl = hasPermission(Permission.KEYBOARD_INPUT) && hasPermission(Permission.MOUSE_INPUT);
const hadControl = previousCanControl.current;
if (currentCanControl && !hadControl && rpcHidChannel?.readyState === "open") {
console.info("Gained control permissions, re-initializing HID");
setRpcHidProtocolVersion(null);
import("@/hooks/hidRpc").then(({ HID_RPC_VERSION, HandshakeMessage }) => {
setTimeout(() => {
if (rpcHidChannel?.readyState === "open") {
const handshakeMessage = new HandshakeMessage(HID_RPC_VERSION);
try {
const data = handshakeMessage.marshal();
rpcHidChannel.send(data as unknown as ArrayBuffer);
console.info("Sent HID handshake after permission change");
} catch (e) {
console.error("Failed to send HID handshake", e);
}
}
}, 100);
});
}
previousCanControl.current = currentCanControl;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [permissions, rpcHidChannel, setRpcHidProtocolVersion]);
const isPrimary = useCallback(() => currentMode === "primary", [currentMode]);
const isObserver = useCallback(() => currentMode === "observer", [currentMode]);
const isPending = useCallback(() => currentMode === "pending", [currentMode]);
const value: PermissionsContextValue = {
permissions,
isLoading,
hasPermission,
hasAnyPermission,
hasAllPermissions,
isPrimary,
isObserver,
isPending,
};
return (
<PermissionsContext.Provider value={value}>
{children}
</PermissionsContext.Provider>
);
}

View File

@ -6,7 +6,8 @@ import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import { usePermissions, Permission } from "@/hooks/usePermissions";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
import notifications from "../notifications";
import { UsbInfoSetting } from "../components/UsbInfoSetting";

View File

@ -4,7 +4,8 @@ import {
} from "@heroicons/react/16/solid";
import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc";
import { usePermissions, Permission } from "@/hooks/usePermissions";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
import { useSettingsStore } from "@/hooks/stores";
import { notify } from "@/notifications";
import Card from "@/components/Card";

View File

@ -22,7 +22,8 @@ import { LinkButton } from "@components/Button";
import { FeatureFlag } from "@components/FeatureFlag";
import { useUiStore } from "@/hooks/stores";
import { useSessionStore } from "@/stores/sessionStore";
import { usePermissions, Permission } from "@/hooks/usePermissions";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
export default function SettingsRoute() {
@ -34,7 +35,7 @@ export default function SettingsRoute() {
useEffect(() => {
if (!isLoading && !permissions[Permission.SETTINGS_ACCESS] && currentMode !== null) {
navigate("/devices/local", { replace: true });
navigate("/", { replace: true });
}
}, [permissions, isLoading, currentMode, navigate]);

View File

@ -18,7 +18,6 @@ import useWebSocket from "react-use-websocket";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
import api from "@/api";
import { checkAuth, isInCloud, isOnDevice } from "@/main";
import { usePermissions, Permission } from "@/hooks/usePermissions";
import { cx } from "@/cva.config";
import {
KeyboardLedState,
@ -54,6 +53,9 @@ import {
} from "@/components/VideoOverlay";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
import { PermissionsProvider } from "@/providers/PermissionsProvider";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
import { DeviceStatus } from "@routes/welcome-local";
import { useVersion } from "@/hooks/useVersion";
import { useSessionManagement } from "@/hooks/useSessionManagement";
@ -159,7 +161,6 @@ export default function KvmIdRoute() {
const { nickname, setNickname } = useSharedSessionStore();
const { setRequireSessionApproval, setRequireSessionNickname } = useSettingsStore();
const [globalSessionSettings, setGlobalSessionSettings] = useState<{requireApproval: boolean, requireNickname: boolean} | null>(null);
const { hasPermission } = usePermissions();
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
const cleanupAndStopReconnecting = useCallback(
@ -549,44 +550,6 @@ export default function KvmIdRoute() {
const rpcDataChannel = pc.createDataChannel("rpc");
rpcDataChannel.onopen = () => {
setRpcDataChannel(rpcDataChannel);
// Fetch global session settings
const fetchSettings = () => {
// Only fetch settings if user has permission to read settings
if (!hasPermission(Permission.SETTINGS_READ)) {
return;
}
const id = Math.random().toString(36).substring(2);
const message = JSON.stringify({ jsonrpc: "2.0", method: "getSessionSettings", params: {}, id });
const handler = (event: MessageEvent) => {
try {
const response = JSON.parse(event.data);
if (response.id === id) {
rpcDataChannel.removeEventListener("message", handler);
if (response.result) {
setGlobalSessionSettings(response.result);
// Also update the settings store for approval handling
setRequireSessionApproval(response.result.requireApproval);
setRequireSessionNickname(response.result.requireNickname);
}
}
} catch {
// Ignore parse errors
}
};
rpcDataChannel.addEventListener("message", handler);
rpcDataChannel.send(message);
// Clean up after timeout
setTimeout(() => {
rpcDataChannel.removeEventListener("message", handler);
}, 5000);
};
fetchSettings();
};
const rpcHidChannel = pc.createDataChannel("hidrpc");
@ -627,9 +590,6 @@ export default function KvmIdRoute() {
setRpcHidUnreliableNonOrderedChannel,
setRpcHidUnreliableChannel,
setTransceiver,
hasPermission,
setRequireSessionApproval,
setRequireSessionNickname,
]);
useEffect(() => {
@ -722,6 +682,7 @@ export default function KvmIdRoute() {
// Handle session-related events
if (resp.method === "sessionsUpdated" ||
resp.method === "modeChanged" ||
resp.method === "connectionModeChanged" ||
resp.method === "otherSessionConnected" ||
resp.method === "primaryControlRequested" ||
resp.method === "primaryControlApproved" ||
@ -735,7 +696,6 @@ export default function KvmIdRoute() {
setAccessDenied(true);
}
// Keep legacy behavior for otherSessionConnected
if (resp.method === "otherSessionConnected") {
navigateTo("/other-session");
}
@ -805,21 +765,25 @@ export default function KvmIdRoute() {
closeNewSessionRequest
} = useSessionManagement(send);
const { hasPermission, isLoading: isLoadingPermissions } = usePermissions();
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
if (isLoadingPermissions || !hasPermission(Permission.VIDEO_VIEW)) return;
send("getVideoState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
const hdmiState = resp.result as Parameters<VideoState["setHdmiState"]>[0];
setHdmiState(hdmiState);
});
}, [rpcDataChannel?.readyState, send, setHdmiState]);
}, [rpcDataChannel?.readyState, hasPermission, isLoadingPermissions, send, setHdmiState]);
const [needLedState, setNeedLedState] = useState(true);
// request keyboard led state from the device
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
if (!needLedState) return;
if (isLoadingPermissions || !hasPermission(Permission.KEYBOARD_INPUT)) return;
send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
@ -831,20 +795,18 @@ export default function KvmIdRoute() {
}
setNeedLedState(false);
});
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState]);
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState, hasPermission, isLoadingPermissions]);
const [needKeyDownState, setNeedKeyDownState] = useState(true);
// request keyboard key down state from the device
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
if (!needKeyDownState) return;
if (isLoadingPermissions || !hasPermission(Permission.KEYBOARD_INPUT)) return;
send("getKeyDownState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
// -32601 means the method is not supported
if (resp.error.code === RpcMethodNotFound) {
// if we don't support key down state, we know key press is also not available
console.warn("Failed to get key down state, switching to old-school", resp.error);
setHidRpcDisabled(true);
} else {
@ -856,7 +818,7 @@ export default function KvmIdRoute() {
}
setNeedKeyDownState(false);
});
}, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeysDownState, setHidRpcDisabled]);
}, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeysDownState, setHidRpcDisabled, hasPermission, isLoadingPermissions]);
// When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => {
@ -889,10 +851,10 @@ export default function KvmIdRoute() {
useEffect(() => {
if (appVersion) return;
if (!hasPermission(Permission.VIDEO_VIEW)) return;
getLocalVersion();
}, [appVersion, getLocalVersion, hasPermission]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appVersion]);
const ConnectionStatusElement = useMemo(() => {
const hasConnectionFailed =
@ -932,8 +894,9 @@ export default function KvmIdRoute() {
]);
return (
<FeatureFlagProvider appVersion={appVersion}>
{!outlet && otaState.updating && (
<PermissionsProvider>
<FeatureFlagProvider appVersion={appVersion}>
{!outlet && otaState.updating && (
<AnimatePresence>
<motion.div
className="pointer-events-none fixed inset-0 top-16 z-10 mx-auto flex h-full w-full max-w-xl translate-y-8 items-start justify-center"
@ -1106,7 +1069,8 @@ export default function KvmIdRoute() {
<PendingApprovalOverlay
show={currentMode === "pending"}
/>
</FeatureFlagProvider>
</FeatureFlagProvider>
</PermissionsProvider>
);
}

View File

@ -0,0 +1,30 @@
export enum Permission {
VIDEO_VIEW = "video.view",
KEYBOARD_INPUT = "keyboard.input",
MOUSE_INPUT = "mouse.input",
PASTE = "clipboard.paste",
SESSION_TRANSFER = "session.transfer",
SESSION_APPROVE = "session.approve",
SESSION_KICK = "session.kick",
SESSION_REQUEST_PRIMARY = "session.request_primary",
SESSION_RELEASE_PRIMARY = "session.release_primary",
SESSION_MANAGE = "session.manage",
MOUNT_MEDIA = "mount.media",
UNMOUNT_MEDIA = "mount.unmedia",
MOUNT_LIST = "mount.list",
EXTENSION_MANAGE = "extension.manage",
EXTENSION_ATX = "extension.atx",
EXTENSION_DC = "extension.dc",
EXTENSION_SERIAL = "extension.serial",
EXTENSION_WOL = "extension.wol",
SETTINGS_READ = "settings.read",
SETTINGS_WRITE = "settings.write",
SETTINGS_ACCESS = "settings.access",
SYSTEM_REBOOT = "system.reboot",
SYSTEM_UPDATE = "system.update",
SYSTEM_NETWORK = "system.network",
POWER_CONTROL = "power.control",
USB_CONTROL = "usb.control",
TERMINAL_ACCESS = "terminal.access",
SERIAL_ACCESS = "serial.access",
}

View File

@ -78,14 +78,6 @@ func incrActiveSessions() int {
return actionSessions
}
func decrActiveSessions() int {
activeSessionsMutex.Lock()
defer activeSessionsMutex.Unlock()
actionSessions--
return actionSessions
}
func getActiveSessions() int {
activeSessionsMutex.Lock()
defer activeSessionsMutex.Unlock()
@ -96,7 +88,7 @@ func getActiveSessions() int {
// CheckRPCRateLimit checks if the session has exceeded RPC rate limits (DoS protection)
func (s *Session) CheckRPCRateLimit() bool {
const (
maxRPCPerSecond = 100 // Increased from 20 to accommodate multi-session polling and reconnections
maxRPCPerSecond = 500 // Increased to support 10+ concurrent sessions with broadcasts and state updates
rateLimitWindow = time.Second
)