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

View File

@ -192,12 +192,10 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
if err := RequirePermission(session, PermissionSessionApprove); err != nil { if err := RequirePermission(session, PermissionSessionApprove); err != nil {
handlerErr = err handlerErr = err
} else if sessionID, ok := request.Params["sessionId"].(string); ok { } else if sessionID, ok := request.Params["sessionId"].(string); ok {
if targetSession := sessionManager.GetSession(sessionID); targetSession != nil && targetSession.Mode == SessionModePending { handlerErr = sessionManager.ApproveSession(sessionID)
targetSession.Mode = SessionModeObserver if handlerErr == nil {
sessionManager.broadcastSessionListUpdate() go sessionManager.broadcastSessionListUpdate()
result = map[string]interface{}{"status": "approved"} result = map[string]interface{}{"status": "approved"}
} else {
handlerErr = errors.New("session not found or not pending")
} }
} else { } else {
handlerErr = errors.New("invalid sessionId parameter") handlerErr = errors.New("invalid sessionId parameter")
@ -206,14 +204,18 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
if err := RequirePermission(session, PermissionSessionApprove); err != nil { if err := RequirePermission(session, PermissionSessionApprove); err != nil {
handlerErr = err handlerErr = err
} else if sessionID, ok := request.Params["sessionId"].(string); ok { } else if sessionID, ok := request.Params["sessionId"].(string); ok {
if targetSession := sessionManager.GetSession(sessionID); targetSession != nil && targetSession.Mode == SessionModePending { 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{}{ writeJSONRPCEvent("sessionAccessDenied", map[string]interface{}{
"message": "Access denied by primary session", "message": "Access denied by primary session",
}, targetSession) }, targetSession)
sessionManager.broadcastSessionListUpdate() sessionManager.broadcastSessionListUpdate()
}()
}
result = map[string]interface{}{"status": "denied"} result = map[string]interface{}{"status": "denied"}
} else {
handlerErr = errors.New("session not found or not pending")
} }
} else { } else {
handlerErr = errors.New("invalid sessionId parameter") handlerErr = errors.New("invalid sessionId parameter")
@ -251,6 +253,16 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
} else if targetSession := sessionManager.GetSession(sessionID); targetSession != nil { } else if targetSession := sessionManager.GetSession(sessionID); targetSession != nil {
// Users can update their own nickname, or admins can update any // Users can update their own nickname, or admins can update any
if targetSession.ID == session.ID || session.HasPermission(PermissionSessionManage) { if targetSession.ID == session.ID || session.HasPermission(PermissionSessionManage) {
// 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
}
}
if handlerErr == nil {
targetSession.Nickname = nickname targetSession.Nickname = nickname
// If session is pending and approval is required, send the approval request now that we have a nickname // If session is pending and approval is required, send the approval request now that we have a nickname
@ -269,6 +281,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
sessionManager.broadcastSessionListUpdate() sessionManager.broadcastSessionListUpdate()
result = map[string]interface{}{"status": "updated"} result = map[string]interface{}{"status": "updated"}
}
} else { } else {
handlerErr = errors.New("permission denied: can only update own nickname") handlerErr = errors.New("permission denied: can only update own nickname")
} }

View File

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

View File

@ -132,9 +132,6 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
return errors.New("session cannot be nil") return errors.New("session cannot be nil")
} }
sm.logger.Debug().
Str("sessionID", session.ID).
Msg("AddSession ENTRY")
// Validate nickname if provided (matching frontend validation) // Validate nickname if provided (matching frontend validation)
if session.Nickname != "" { if session.Nickname != "" {
if len(session.Nickname) < 2 { if len(session.Nickname) < 2 {
@ -152,6 +149,15 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() 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 wasWithinGracePeriod := false
wasPreviouslyPrimary := false wasPreviouslyPrimary := false
wasPreviouslyPending := false wasPreviouslyPending := false
@ -163,25 +169,17 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
wasPreviouslyPending = (reconnectInfo.Mode == SessionModePending) wasPreviouslyPending = (reconnectInfo.Mode == SessionModePending)
} }
} }
delete(sm.reconnectGrace, session.ID)
} }
// Check if a session with this ID already exists (reconnection) // Check if a session with this ID already exists (reconnection)
if existing, exists := sm.sessions[session.ID]; exists { 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 // SECURITY: Verify identity matches to prevent session hijacking
if existing.Identity != session.Identity || existing.Source != session.Source { if existing.Identity != session.Identity || existing.Source != session.Source {
return fmt.Errorf("session ID already in use by different user (identity mismatch)") 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 { if existing.peerConnection != nil {
sm.logger.Info().
Str("sessionID", session.ID).
Msg("Closing old peer connection for session reconnection")
existing.peerConnection.Close() 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 this was the primary, try to restore primary status
if existing.Mode == SessionModePrimary { if existing.Mode == SessionModePrimary {
// Check if this session is still the reserved primary AND not blacklisted
isBlacklisted := sm.isSessionBlacklisted(session.ID) isBlacklisted := sm.isSessionBlacklisted(session.ID)
if sm.lastPrimaryID == session.ID && !isBlacklisted { // SECURITY: Prevent dual-primary window - only restore if no other primary exists
// This is the rightful primary reconnecting within grace period primaryExists := sm.primarySessionID != "" && sm.sessions[sm.primarySessionID] != nil
if sm.lastPrimaryID == session.ID && !isBlacklisted && !primaryExists {
sm.primarySessionID = session.ID sm.primarySessionID = session.ID
sm.lastPrimaryID = "" // Clear since primary successfully reconnected sm.lastPrimaryID = ""
delete(sm.reconnectGrace, session.ID) // Clear grace period delete(sm.reconnectGrace, session.ID)
sm.logger.Debug().
Str("sessionID", session.ID).
Msg("Primary session successfully reconnected within grace period")
} else { } 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 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() go sm.broadcastSessionListUpdate()
return nil return nil
} }
if len(sm.sessions) >= sm.maxSessions { 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 return ErrMaxSessionsReached
} }
@ -249,15 +229,6 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
session.ID = uuid.New().String() 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 // Set nickname from client settings if provided
if clientSettings != nil && clientSettings.Nickname != "" { if clientSettings != nil && clientSettings.Nickname != "" {
session.Nickname = 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) // Use global settings for requirements (not client-provided)
globalSettings := currentSessionSettings 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 primaryExists := sm.primarySessionID != "" && sm.sessions[sm.primarySessionID] != nil
// Check if there's an active grace period for a primary session (different from this session) // 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) isBlacklisted := sm.isSessionBlacklisted(session.ID)
sm.logger.Debug(). // Determine if this session should become primary
Str("newSessionID", session.ID). // If there's no primary AND this is the ONLY session, ALWAYS promote regardless of blacklist
Str("nickname", session.Nickname). isOnlySession := len(sm.sessions) == 0
Str("currentPrimarySessionID", sm.primarySessionID). shouldBecomePrimary := (wasWithinGracePeriod && wasPreviouslyPrimary && !primaryExists && !hasActivePrimaryGracePeriod) ||
Bool("primaryExists", primaryExists). (!wasWithinGracePeriod && !hasActivePrimaryGracePeriod && !primaryExists && (!isBlacklisted || isOnlySession))
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")
}
if shouldBecomePrimary { if shouldBecomePrimary {
// Double-check primary doesn't exist (race condition prevention)
if sm.primarySessionID == "" || sm.sessions[sm.primarySessionID] == nil { 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 session.Mode = SessionModePrimary
sm.primarySessionID = session.ID 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 // 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 { for oldSessionID := range sm.reconnectGrace {
delete(sm.reconnectGrace, oldSessionID) delete(sm.reconnectGrace, oldSessionID)
} }
for oldSessionID := range sm.reconnectInfo { for oldSessionID := range sm.reconnectInfo {
delete(sm.reconnectInfo, oldSessionID) delete(sm.reconnectInfo, oldSessionID)
} }
}
// Reset HID availability to force re-handshake for input functionality
session.hidRPCAvailable = false session.hidRPCAvailable = false
} else { } else {
session.Mode = SessionModeObserver session.Mode = SessionModeObserver
@ -381,8 +312,6 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
session.CreatedAt = time.Now() session.CreatedAt = time.Now()
session.LastActive = 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.sessions[session.ID] = session
sm.logger.Info(). sm.logger.Info().
@ -394,9 +323,14 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
// Ensure session has auto-generated nickname if needed // Ensure session has auto-generated nickname if needed
sm.ensureNickname(session) sm.ensureNickname(session)
// Validate sessions but respect grace periods
sm.validateSinglePrimary() 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 // Notify all sessions about the new connection
go sm.broadcastSessionListUpdate() go sm.broadcastSessionListUpdate()
@ -410,9 +344,6 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
session, exists := sm.sessions[sessionID] session, exists := sm.sessions[sessionID]
if !exists { if !exists {
sm.logger.Debug().
Str("sessionID", sessionID).
Msg("RemoveSession called but session not found in map")
return return
} }
@ -431,12 +362,8 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
// Check if this session was marked for immediate removal (intentional logout) // Check if this session was marked for immediate removal (intentional logout)
isIntentionalLogout := false isIntentionalLogout := false
if graceTime, exists := sm.reconnectGrace[sessionID]; exists { if graceTime, exists := sm.reconnectGrace[sessionID]; exists {
// If grace period is already expired, this was intentional logout
if time.Now().After(graceTime) { if time.Now().After(graceTime) {
isIntentionalLogout = true isIntentionalLogout = true
sm.logger.Info().
Str("sessionID", sessionID).
Msg("Detected intentional logout - skipping grace period")
delete(sm.reconnectGrace, sessionID) delete(sm.reconnectGrace, sessionID)
delete(sm.reconnectInfo, 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 // Only add grace period if this is NOT an intentional logout
if !isIntentionalLogout { if !isIntentionalLogout {
// Add a grace period for reconnection for all sessions // Limit grace period entries to prevent memory exhaustion
const maxGraceEntries = 10
// Limit grace period entries to prevent memory exhaustion (DoS protection)
const maxGraceEntries = 10 // Reduced from 20 to limit memory usage
for len(sm.reconnectGrace) >= maxGraceEntries { for len(sm.reconnectGrace) >= maxGraceEntries {
// Find and remove the oldest grace period entry
var oldestID string var oldestID string
var oldestTime time.Time var oldestTime time.Time
for id, graceTime := range sm.reconnectGrace { for id, graceTime := range sm.reconnectGrace {
@ -468,7 +392,7 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
delete(sm.reconnectGrace, oldestID) delete(sm.reconnectGrace, oldestID)
delete(sm.reconnectInfo, oldestID) delete(sm.reconnectInfo, oldestID)
} else { } 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.lastPrimaryID = sessionID // Remember this was the primary for grace period
sm.primarySessionID = "" // Clear primary slot so other sessions can be promoted sm.primarySessionID = "" // Clear primary slot so other sessions can be promoted
// Clear all blacklists to allow emergency promotion after grace period expires // Clear all blacklists to allow 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
if len(sm.transferBlacklist) > 0 { 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) sm.transferBlacklist = make([]TransferBlacklistEntry, 0)
} }
@ -520,11 +438,6 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
// Trigger validation for potential promotion // Trigger validation for potential promotion
if len(sm.sessions) > 0 { 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() sm.validateSinglePrimary()
} }
} }
@ -879,6 +792,23 @@ func (sm *SessionManager) ApprovePrimaryRequest(currentPrimaryID, requesterID st
return errors.New("not the primary session") 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 // Remove requester from queue
sm.removeFromQueue(requesterID) sm.removeFromQueue(requesterID)
@ -936,6 +866,51 @@ func (sm *SessionManager) DenyPrimaryRequest(currentPrimaryID, requesterID strin
return nil 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 // ForEachSession executes a function for each active session
func (sm *SessionManager) ForEachSession(fn func(*Session)) { func (sm *SessionManager) ForEachSession(fn func(*Session)) {
sm.mu.RLock() sm.mu.RLock()
@ -967,17 +942,6 @@ func (sm *SessionManager) UpdateLastActive(sessionID string) {
func (sm *SessionManager) validateSinglePrimary() { func (sm *SessionManager) validateSinglePrimary() {
primarySessions := make([]*Session, 0) 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 // Find all sessions that think they're primary
for _, session := range sm.sessions { for _, session := range sm.sessions {
if session.Mode == SessionModePrimary { 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 { if len(primarySessions) > 1 {
sm.logger.Error(). sm.logger.Error().
Int("primaryCount", len(primarySessions)). 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 // Keep the first one as primary, demote the rest
for i, session := range primarySessions { for i, session := range primarySessions {
if i == 0 { if i == 0 {
// Keep this as primary and update manager state
sm.primarySessionID = session.ID sm.primarySessionID = session.ID
sm.logger.Info().
Str("keptPrimaryID", session.ID).
Msg("Kept session as primary")
} else { } else {
// Demote all others
session.Mode = SessionModeObserver 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 // 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 != "" { if len(primarySessions) == 0 && sm.primarySessionID != "" {
// Check if the current primary is in grace period waiting to reconnect
if sm.lastPrimaryID == sm.primarySessionID { if sm.lastPrimaryID == sm.primarySessionID {
if graceTime, exists := sm.reconnectGrace[sm.primarySessionID]; exists { if graceTime, exists := sm.reconnectGrace[sm.primarySessionID]; exists {
if time.Now().Before(graceTime) { if time.Now().Before(graceTime) {
// Primary is in grace period, DON'T clear the slot yet return // Keep primary slot reserved during grace period
sm.logger.Info().
Str("gracePrimaryID", sm.primarySessionID).
Msg("Primary slot preserved - session in grace period")
return // Exit validation, keep primary slot reserved
} }
} }
} }
// No grace period, safe to clear orphaned primary
sm.logger.Warn().
Str("orphanedPrimaryID", sm.primarySessionID).
Msg("Cleared orphaned primary ID")
sm.primarySessionID = "" sm.primarySessionID = ""
} }
@ -1048,30 +993,12 @@ func (sm *SessionManager) validateSinglePrimary() {
if reconnectInfo, hasInfo := sm.reconnectInfo[sessionID]; hasInfo { if reconnectInfo, hasInfo := sm.reconnectInfo[sessionID]; hasInfo {
if reconnectInfo.Mode == SessionModePrimary { if reconnectInfo.Mode == SessionModePrimary {
hasActivePrimaryGracePeriod = true 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 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 // 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 { if len(primarySessions) == 0 && sm.primarySessionID == "" && len(sm.sessions) > 0 && !hasActivePrimaryGracePeriod {
// Find a session to promote to primary // Find a session to promote to primary
@ -1093,13 +1020,6 @@ func (sm *SessionManager) validateSinglePrimary() {
sm.logger.Warn(). sm.logger.Warn().
Msg("No eligible session found for emergency auto-promotion") 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 { if fromExists && fromSession.Mode == SessionModePrimary {
fromSession.Mode = SessionModeObserver fromSession.Mode = SessionModeObserver
fromSession.hidRPCAvailable = false 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.reconnectGrace, fromSessionID)
delete(sm.reconnectInfo, fromSessionID) delete(sm.reconnectInfo, fromSessionID)
@ -1160,7 +1083,11 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
toSession.Mode = SessionModePrimary toSession.Mode = SessionModePrimary
toSession.hidRPCAvailable = false // Force re-handshake toSession.hidRPCAvailable = false // Force re-handshake
sm.primarySessionID = toSessionID 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 // Clear input state
sm.clearInputState() sm.clearInputState()
@ -1171,10 +1098,14 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
} }
// Apply bidirectional blacklisting - protect newly promoted session // 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() now := time.Now()
blacklistDuration := 60 * time.Second blacklistDuration := 60 * time.Second
blacklistedCount := 0 blacklistedCount := 0
if isManualTransfer {
// First, clear any existing blacklist entries for the newly promoted session // First, clear any existing blacklist entries for the newly promoted session
cleanedBlacklist := make([]TransferBlacklistEntry, 0) cleanedBlacklist := make([]TransferBlacklistEntry, 0)
for _, entry := range sm.transferBlacklist { for _, entry := range sm.transferBlacklist {
@ -1194,16 +1125,21 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
blacklistedCount++ blacklistedCount++
} }
} }
}
// Clear all grace periods to prevent conflicts // DON'T clear grace periods during transfers!
if len(sm.reconnectGrace) > 0 || len(sm.reconnectInfo) > 0 { // Grace periods and blacklisting serve different purposes:
for oldSessionID := range sm.reconnectGrace { // - Grace periods: Allow disconnected sessions to reconnect and reclaim their role
delete(sm.reconnectGrace, oldSessionID) // - Blacklisting: Prevent recently demoted sessions from immediately taking primary again
} //
for oldSessionID := range sm.reconnectInfo { // When a primary session is transferred to another session:
delete(sm.reconnectInfo, oldSessionID) // 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(). sm.logger.Info().
Str("fromSessionID", fromSessionID). Str("fromSessionID", fromSessionID).
@ -1214,8 +1150,9 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
Dur("blacklistDuration", blacklistDuration). Dur("blacklistDuration", blacklistDuration).
Msg("Primary role transferred with bidirectional protection") Msg("Primary role transferred with bidirectional protection")
// Validate session consistency after role transfer // DON'T validate here - causes recursive calls and map iteration issues
sm.validateSinglePrimary() // The caller (AddSession, RemoveSession, etc.) will validate after we return
// sm.validateSinglePrimary() // REMOVED to prevent recursion
// Handle WebRTC connection state for promoted sessions // Handle WebRTC connection state for promoted sessions
// When a session changes from observer to primary, the existing WebRTC connection // When a session changes from observer to primary, the existing WebRTC connection
@ -1629,7 +1566,15 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
if currentSessionSettings != nil && currentSessionSettings.RequireApproval { if currentSessionSettings != nil && currentSessionSettings.RequireApproval {
isEmergencyPromotion = true isEmergencyPromotion = true
// Rate limiting for emergency promotions // 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).
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 { if now.Sub(sm.lastEmergencyPromotion) < 30*time.Second {
sm.logger.Warn(). sm.logger.Warn().
Str("expiredSessionID", sessionID). Str("expiredSessionID", sessionID).
@ -1646,6 +1591,7 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
Msg("Too many consecutive emergency promotions - blocking for security") Msg("Too many consecutive emergency promotions - blocking for security")
continue // Skip this grace period expiration continue // Skip this grace period expiration
} }
}
promotedSessionID = sm.findMostTrustedSessionForEmergency() promotedSessionID = sm.findMostTrustedSessionForEmergency()
} else { } else {
@ -1745,7 +1691,16 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
if currentSessionSettings != nil && currentSessionSettings.RequireApproval { if currentSessionSettings != nil && currentSessionSettings.RequireApproval {
isEmergencyPromotion = true isEmergencyPromotion = true
// Rate limiting for emergency promotions // 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).
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 { if now.Sub(sm.lastEmergencyPromotion) < 30*time.Second {
sm.logger.Warn(). sm.logger.Warn().
Str("timedOutSessionID", timedOutSessionID). Str("timedOutSessionID", timedOutSessionID).
@ -1753,6 +1708,7 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
Msg("Emergency promotion rate limit exceeded during timeout - potential attack") Msg("Emergency promotion rate limit exceeded during timeout - potential attack")
continue // Skip this timeout continue // Skip this timeout
} }
}
// Use trust-based selection but exclude the timed-out session // Use trust-based selection but exclude the timed-out session
bestSessionID := "" bestSessionID := ""
@ -1820,14 +1776,12 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
// Run validation immediately if a grace period expired, otherwise run periodically // Run validation immediately if a grace period expired, otherwise run periodically
if gracePeriodExpired { if gracePeriodExpired {
sm.logger.Debug().Msg("Running immediate validation after grace period expiration")
sm.validateSinglePrimary() sm.validateSinglePrimary()
} else { } else {
// Periodic validateSinglePrimary to catch deadlock states // Periodic validateSinglePrimary to catch deadlock states
validationCounter++ validationCounter++
if validationCounter >= 10 { // Every 10 seconds if validationCounter >= 10 { // Every 10 seconds
validationCounter = 0 validationCounter = 0
sm.logger.Debug().Msg("Running periodic session validation to catch deadlock states")
sm.validateSinglePrimary() sm.validateSinglePrimary()
} }
} }
@ -1843,7 +1797,16 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
} }
// Global session manager instance // 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 // Global session settings - references config.SessionSettings for persistence
var currentSessionSettings *SessionSettings var currentSessionSettings *SessionSettings

View File

@ -21,7 +21,8 @@ import ExtensionPopover from "@/components/popovers/ExtensionPopover";
import SessionPopover from "@/components/popovers/SessionPopover"; import SessionPopover from "@/components/popovers/SessionPopover";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { useSessionStore } from "@/stores/sessionStore"; 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({ export default function Actionbar({
requestFullscreen, requestFullscreen,

View File

@ -6,7 +6,8 @@ import Container from "@components/Container";
import { useMacrosStore } from "@/hooks/stores"; import { useMacrosStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc"; 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() { export default function MacroBar() {
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore(); const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();

View File

@ -8,7 +8,8 @@ import clsx from "clsx";
import { useSessionStore } from "@/stores/sessionStore"; import { useSessionStore } from "@/stores/sessionStore";
import { sessionApi } from "@/api/sessionApi"; import { sessionApi } from "@/api/sessionApi";
import { Button } from "@/components/Button"; 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; 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 clsx from "clsx";
import { formatters } from "@/utils"; import { formatters } from "@/utils";
import { usePermissions, Permission } from "@/hooks/usePermissions"; import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
interface Session { interface Session {
id: string; id: string;

View File

@ -14,7 +14,8 @@ import {
useSettingsStore, useSettingsStore,
useVideoStore, useVideoStore,
} from "@/hooks/stores"; } 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 useMouse from "@/hooks/useMouse";
import { 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 { PermissionsContext } from "@/contexts/PermissionsContext";
import { useSessionStore } from "@/stores/sessionStore"; import { Permission } from "@/types/permissions";
import { useRTCStore } from "@/hooks/stores";
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void; export interface PermissionsContextValue {
// 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;
permissions: Record<string, boolean>; 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() { export function usePermissions(): PermissionsContextValue {
const { currentMode } = useSessionStore(); const context = useContext(PermissionsContext);
const { setRpcHidProtocolVersion, rpcHidChannel } = useRTCStore();
const [permissions, setPermissions] = useState<Record<string, boolean>>({});
const [isLoading, setIsLoading] = useState(true);
const previousCanControl = useRef<boolean>(false);
// Function to poll permissions
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);
});
}, []);
// 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";
if (context === undefined) {
return { return {
permissions, permissions: {},
isLoading, isLoading: true,
hasPermission, hasPermission: () => false,
hasAnyPermission, hasAnyPermission: () => false,
hasAllPermissions, hasAllPermissions: () => false,
isPrimary, isPrimary: () => false,
isObserver, isObserver: () => false,
isPending, isPending: () => false,
}; };
} }
return context;
}

View File

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

View File

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

View File

@ -4,7 +4,8 @@ import {
} from "@heroicons/react/16/solid"; } from "@heroicons/react/16/solid";
import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc"; 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 { useSettingsStore } from "@/hooks/stores";
import { notify } from "@/notifications"; import { notify } from "@/notifications";
import Card from "@/components/Card"; import Card from "@/components/Card";

View File

@ -22,7 +22,8 @@ import { LinkButton } from "@components/Button";
import { FeatureFlag } from "@components/FeatureFlag"; import { FeatureFlag } from "@components/FeatureFlag";
import { useUiStore } from "@/hooks/stores"; import { useUiStore } from "@/hooks/stores";
import { useSessionStore } from "@/stores/sessionStore"; 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. */ /* 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() { export default function SettingsRoute() {
@ -34,7 +35,7 @@ export default function SettingsRoute() {
useEffect(() => { useEffect(() => {
if (!isLoading && !permissions[Permission.SETTINGS_ACCESS] && currentMode !== null) { if (!isLoading && !permissions[Permission.SETTINGS_ACCESS] && currentMode !== null) {
navigate("/devices/local", { replace: true }); navigate("/", { replace: true });
} }
}, [permissions, isLoading, currentMode, navigate]); }, [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 { CLOUD_API, DEVICE_API } from "@/ui.config";
import api from "@/api"; import api from "@/api";
import { checkAuth, isInCloud, isOnDevice } from "@/main"; import { checkAuth, isInCloud, isOnDevice } from "@/main";
import { usePermissions, Permission } from "@/hooks/usePermissions";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { import {
KeyboardLedState, KeyboardLedState,
@ -54,6 +53,9 @@ import {
} from "@/components/VideoOverlay"; } from "@/components/VideoOverlay";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider"; 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 { DeviceStatus } from "@routes/welcome-local";
import { useVersion } from "@/hooks/useVersion"; import { useVersion } from "@/hooks/useVersion";
import { useSessionManagement } from "@/hooks/useSessionManagement"; import { useSessionManagement } from "@/hooks/useSessionManagement";
@ -159,7 +161,6 @@ export default function KvmIdRoute() {
const { nickname, setNickname } = useSharedSessionStore(); const { nickname, setNickname } = useSharedSessionStore();
const { setRequireSessionApproval, setRequireSessionNickname } = useSettingsStore(); const { setRequireSessionApproval, setRequireSessionNickname } = useSettingsStore();
const [globalSessionSettings, setGlobalSessionSettings] = useState<{requireApproval: boolean, requireNickname: boolean} | null>(null); const [globalSessionSettings, setGlobalSessionSettings] = useState<{requireApproval: boolean, requireNickname: boolean} | null>(null);
const { hasPermission } = usePermissions();
const [loadingMessage, setLoadingMessage] = useState("Connecting to device..."); const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
const cleanupAndStopReconnecting = useCallback( const cleanupAndStopReconnecting = useCallback(
@ -549,44 +550,6 @@ export default function KvmIdRoute() {
const rpcDataChannel = pc.createDataChannel("rpc"); const rpcDataChannel = pc.createDataChannel("rpc");
rpcDataChannel.onopen = () => { rpcDataChannel.onopen = () => {
setRpcDataChannel(rpcDataChannel); 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"); const rpcHidChannel = pc.createDataChannel("hidrpc");
@ -627,9 +590,6 @@ export default function KvmIdRoute() {
setRpcHidUnreliableNonOrderedChannel, setRpcHidUnreliableNonOrderedChannel,
setRpcHidUnreliableChannel, setRpcHidUnreliableChannel,
setTransceiver, setTransceiver,
hasPermission,
setRequireSessionApproval,
setRequireSessionNickname,
]); ]);
useEffect(() => { useEffect(() => {
@ -722,6 +682,7 @@ export default function KvmIdRoute() {
// Handle session-related events // Handle session-related events
if (resp.method === "sessionsUpdated" || if (resp.method === "sessionsUpdated" ||
resp.method === "modeChanged" || resp.method === "modeChanged" ||
resp.method === "connectionModeChanged" ||
resp.method === "otherSessionConnected" || resp.method === "otherSessionConnected" ||
resp.method === "primaryControlRequested" || resp.method === "primaryControlRequested" ||
resp.method === "primaryControlApproved" || resp.method === "primaryControlApproved" ||
@ -735,7 +696,6 @@ export default function KvmIdRoute() {
setAccessDenied(true); setAccessDenied(true);
} }
// Keep legacy behavior for otherSessionConnected
if (resp.method === "otherSessionConnected") { if (resp.method === "otherSessionConnected") {
navigateTo("/other-session"); navigateTo("/other-session");
} }
@ -805,21 +765,25 @@ export default function KvmIdRoute() {
closeNewSessionRequest closeNewSessionRequest
} = useSessionManagement(send); } = useSessionManagement(send);
const { hasPermission, isLoading: isLoadingPermissions } = usePermissions();
useEffect(() => { useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return; if (rpcDataChannel?.readyState !== "open") return;
if (isLoadingPermissions || !hasPermission(Permission.VIDEO_VIEW)) return;
send("getVideoState", {}, (resp: JsonRpcResponse) => { send("getVideoState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return; if ("error" in resp) return;
const hdmiState = resp.result as Parameters<VideoState["setHdmiState"]>[0]; const hdmiState = resp.result as Parameters<VideoState["setHdmiState"]>[0];
setHdmiState(hdmiState); setHdmiState(hdmiState);
}); });
}, [rpcDataChannel?.readyState, send, setHdmiState]); }, [rpcDataChannel?.readyState, hasPermission, isLoadingPermissions, send, setHdmiState]);
const [needLedState, setNeedLedState] = useState(true); const [needLedState, setNeedLedState] = useState(true);
// request keyboard led state from the device
useEffect(() => { useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return; if (rpcDataChannel?.readyState !== "open") return;
if (!needLedState) return; if (!needLedState) return;
if (isLoadingPermissions || !hasPermission(Permission.KEYBOARD_INPUT)) return;
send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => { send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
@ -831,20 +795,18 @@ export default function KvmIdRoute() {
} }
setNeedLedState(false); setNeedLedState(false);
}); });
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState]); }, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState, hasPermission, isLoadingPermissions]);
const [needKeyDownState, setNeedKeyDownState] = useState(true); const [needKeyDownState, setNeedKeyDownState] = useState(true);
// request keyboard key down state from the device
useEffect(() => { useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return; if (rpcDataChannel?.readyState !== "open") return;
if (!needKeyDownState) return; if (!needKeyDownState) return;
if (isLoadingPermissions || !hasPermission(Permission.KEYBOARD_INPUT)) return;
send("getKeyDownState", {}, (resp: JsonRpcResponse) => { send("getKeyDownState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
// -32601 means the method is not supported
if (resp.error.code === RpcMethodNotFound) { 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); console.warn("Failed to get key down state, switching to old-school", resp.error);
setHidRpcDisabled(true); setHidRpcDisabled(true);
} else { } else {
@ -856,7 +818,7 @@ export default function KvmIdRoute() {
} }
setNeedKeyDownState(false); 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 // When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => { useEffect(() => {
@ -889,10 +851,10 @@ export default function KvmIdRoute() {
useEffect(() => { useEffect(() => {
if (appVersion) return; if (appVersion) return;
if (!hasPermission(Permission.VIDEO_VIEW)) return;
getLocalVersion(); getLocalVersion();
}, [appVersion, getLocalVersion, hasPermission]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [appVersion]);
const ConnectionStatusElement = useMemo(() => { const ConnectionStatusElement = useMemo(() => {
const hasConnectionFailed = const hasConnectionFailed =
@ -932,6 +894,7 @@ export default function KvmIdRoute() {
]); ]);
return ( return (
<PermissionsProvider>
<FeatureFlagProvider appVersion={appVersion}> <FeatureFlagProvider appVersion={appVersion}>
{!outlet && otaState.updating && ( {!outlet && otaState.updating && (
<AnimatePresence> <AnimatePresence>
@ -1107,6 +1070,7 @@ export default function KvmIdRoute() {
show={currentMode === "pending"} 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 return actionSessions
} }
func decrActiveSessions() int {
activeSessionsMutex.Lock()
defer activeSessionsMutex.Unlock()
actionSessions--
return actionSessions
}
func getActiveSessions() int { func getActiveSessions() int {
activeSessionsMutex.Lock() activeSessionsMutex.Lock()
defer activeSessionsMutex.Unlock() defer activeSessionsMutex.Unlock()
@ -96,7 +88,7 @@ func getActiveSessions() int {
// CheckRPCRateLimit checks if the session has exceeded RPC rate limits (DoS protection) // CheckRPCRateLimit checks if the session has exceeded RPC rate limits (DoS protection)
func (s *Session) CheckRPCRateLimit() bool { func (s *Session) CheckRPCRateLimit() bool {
const ( 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 rateLimitWindow = time.Second
) )