mirror of https://github.com/jetkvm/kvm.git
Compare commits
No commits in common. "16509188b0becc255bd9a00de9795f76337e8a42" and "8dbd98b4f034f6cd9c705f436e6fc7395feebd6c" have entirely different histories.
16509188b0
...
8dbd98b4f0
7
cloud.go
7
cloud.go
|
|
@ -512,8 +512,12 @@ 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 {
|
||||||
|
|
@ -523,6 +527,7 @@ func handleSessionRequest(
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
scopedLogger.Debug().Msg("AddSession completed successfully, continuing")
|
||||||
|
|
||||||
if session.HasPermission(PermissionPaste) {
|
if session.HasPermission(PermissionPaste) {
|
||||||
cancelKeyboardMacro()
|
cancelKeyboardMacro()
|
||||||
|
|
|
||||||
29
jsonrpc.go
29
jsonrpc.go
|
|
@ -192,10 +192,12 @@ 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 {
|
||||||
handlerErr = sessionManager.ApproveSession(sessionID)
|
if targetSession := sessionManager.GetSession(sessionID); targetSession != nil && targetSession.Mode == SessionModePending {
|
||||||
if handlerErr == nil {
|
targetSession.Mode = SessionModeObserver
|
||||||
go sessionManager.broadcastSessionListUpdate()
|
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")
|
||||||
|
|
@ -204,18 +206,14 @@ 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 {
|
||||||
handlerErr = sessionManager.DenySession(sessionID)
|
if targetSession := sessionManager.GetSession(sessionID); targetSession != nil && targetSession.Mode == SessionModePending {
|
||||||
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")
|
||||||
|
|
@ -253,16 +251,6 @@ 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
|
||||||
|
|
@ -281,7 +269,6 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
main.go
3
main.go
|
|
@ -29,9 +29,6 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,9 @@ 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 {
|
||||||
|
|
@ -149,15 +152,6 @@ 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
|
||||||
|
|
@ -169,17 +163,25 @@ 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)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close old connection to prevent multiple active connections for same session ID
|
// CRITICAL: 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,24 +205,42 @@ 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)
|
||||||
// SECURITY: Prevent dual-primary window - only restore if no other primary exists
|
if sm.lastPrimaryID == session.ID && !isBlacklisted {
|
||||||
primaryExists := sm.primarySessionID != "" && sm.sessions[sm.primarySessionID] != nil
|
// This is the rightful primary reconnecting within grace period
|
||||||
if sm.lastPrimaryID == session.ID && !isBlacklisted && !primaryExists {
|
|
||||||
sm.primarySessionID = session.ID
|
sm.primarySessionID = session.ID
|
||||||
sm.lastPrimaryID = ""
|
sm.lastPrimaryID = "" // Clear since primary successfully reconnected
|
||||||
delete(sm.reconnectGrace, session.ID)
|
delete(sm.reconnectGrace, session.ID) // Clear grace period
|
||||||
|
sm.logger.Debug().
|
||||||
|
Str("sessionID", session.ID).
|
||||||
|
Msg("Primary session successfully reconnected within grace period")
|
||||||
} else {
|
} else {
|
||||||
// Grace period expired, another session took over, or primary already exists
|
// This session was primary but grace period expired, another took over, or is blacklisted
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,6 +249,15 @@ 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
|
||||||
|
|
@ -237,6 +266,9 @@ 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)
|
||||||
|
|
@ -253,28 +285,65 @@ 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)
|
||||||
|
|
||||||
// Determine if this session should become primary
|
sm.logger.Debug().
|
||||||
// If there's no primary AND this is the ONLY session, ALWAYS promote regardless of blacklist
|
Str("newSessionID", session.ID).
|
||||||
isOnlySession := len(sm.sessions) == 0
|
Str("nickname", session.Nickname).
|
||||||
shouldBecomePrimary := (wasWithinGracePeriod && wasPreviouslyPrimary && !primaryExists && !hasActivePrimaryGracePeriod) ||
|
Str("currentPrimarySessionID", sm.primarySessionID).
|
||||||
(!wasWithinGracePeriod && !hasActivePrimaryGracePeriod && !primaryExists && (!isBlacklisted || isOnlySession))
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
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 = ""
|
sm.lastPrimaryID = "" // Clear since we have a new primary
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -312,6 +381,8 @@ 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().
|
||||||
|
|
@ -323,14 +394,9 @@ 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()
|
||||||
|
|
||||||
|
|
@ -344,6 +410,9 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -362,8 +431,12 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
@ -377,9 +450,12 @@ 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 {
|
||||||
// Limit grace period entries to prevent memory exhaustion
|
// Add a grace period for reconnection for all sessions
|
||||||
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 {
|
||||||
|
|
@ -392,7 +468,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
|
break // Safety check to prevent infinite loop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -424,8 +500,14 @@ 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 promotion after grace period expires
|
// 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
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -438,6 +520,11 @@ 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -792,23 +879,6 @@ 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)
|
||||||
|
|
||||||
|
|
@ -866,51 +936,6 @@ 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()
|
||||||
|
|
@ -942,6 +967,17 @@ 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 {
|
||||||
|
|
@ -949,18 +985,26 @@ func (sm *SessionManager) validateSinglePrimary() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have multiple primaries, fix it
|
// If we have multiple primaries, this is a critical bug - 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("Multiple primary sessions detected, fixing")
|
Msg("CRITICAL BUG: 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -975,14 +1019,25 @@ 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) {
|
||||||
return // Keep primary slot reserved during grace period
|
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No grace period, safe to clear orphaned primary
|
||||||
|
sm.logger.Warn().
|
||||||
|
Str("orphanedPrimaryID", sm.primarySessionID).
|
||||||
|
Msg("Cleared orphaned primary ID")
|
||||||
sm.primarySessionID = ""
|
sm.primarySessionID = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -993,12 +1048,30 @@ 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
|
||||||
|
|
@ -1020,6 +1093,13 @@ 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1054,9 +1134,6 @@ 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)
|
||||||
|
|
||||||
|
|
@ -1083,11 +1160,7 @@ 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()
|
||||||
|
|
@ -1098,14 +1171,10 @@ 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 {
|
||||||
|
|
@ -1125,21 +1194,16 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
|
||||||
blacklistedCount++
|
blacklistedCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// DON'T clear grace periods during transfers!
|
// Clear all grace periods to prevent conflicts
|
||||||
// Grace periods and blacklisting serve different purposes:
|
if len(sm.reconnectGrace) > 0 || len(sm.reconnectInfo) > 0 {
|
||||||
// - Grace periods: Allow disconnected sessions to reconnect and reclaim their role
|
for oldSessionID := range sm.reconnectGrace {
|
||||||
// - Blacklisting: Prevent recently demoted sessions from immediately taking primary again
|
delete(sm.reconnectGrace, oldSessionID)
|
||||||
//
|
}
|
||||||
// When a primary session is transferred to another session:
|
for oldSessionID := range sm.reconnectInfo {
|
||||||
// 1. The newly promoted session should be able to refresh its browser without losing primary
|
delete(sm.reconnectInfo, oldSessionID)
|
||||||
// 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).
|
||||||
|
|
@ -1150,9 +1214,8 @@ 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")
|
||||||
|
|
||||||
// DON'T validate here - causes recursive calls and map iteration issues
|
// Validate session consistency after role transfer
|
||||||
// The caller (AddSession, RemoveSession, etc.) will validate after we return
|
sm.validateSinglePrimary()
|
||||||
// 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
|
||||||
|
|
@ -1566,15 +1629,7 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
|
||||||
if currentSessionSettings != nil && currentSessionSettings.RequireApproval {
|
if currentSessionSettings != nil && currentSessionSettings.RequireApproval {
|
||||||
isEmergencyPromotion = true
|
isEmergencyPromotion = true
|
||||||
|
|
||||||
// CRITICAL: Ensure we ALWAYS have a primary session
|
// Rate limiting for emergency promotions
|
||||||
// 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).
|
||||||
|
|
@ -1591,7 +1646,6 @@ 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 {
|
||||||
|
|
@ -1691,16 +1745,7 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
|
||||||
if currentSessionSettings != nil && currentSessionSettings.RequireApproval {
|
if currentSessionSettings != nil && currentSessionSettings.RequireApproval {
|
||||||
isEmergencyPromotion = true
|
isEmergencyPromotion = true
|
||||||
|
|
||||||
// CRITICAL: Ensure we ALWAYS have a primary session
|
// Rate limiting for emergency promotions
|
||||||
// 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).
|
||||||
|
|
@ -1708,7 +1753,6 @@ 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 := ""
|
||||||
|
|
@ -1776,12 +1820,14 @@ 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1797,16 +1843,7 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global session manager instance
|
// Global session manager instance
|
||||||
var (
|
var sessionManager = NewSessionManager(websocketLogger)
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,7 @@ 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 } from "@/hooks/usePermissions";
|
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
||||||
import { Permission } from "@/types/permissions";
|
|
||||||
|
|
||||||
export default function Actionbar({
|
export default function Actionbar({
|
||||||
requestFullscreen,
|
requestFullscreen,
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,7 @@ 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 } from "@/hooks/usePermissions";
|
import { usePermissions, Permission } 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();
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,7 @@ 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 } from "@/hooks/usePermissions";
|
import { usePermissions, Permission } 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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ 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 } from "@/hooks/usePermissions";
|
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
||||||
import { Permission } from "@/types/permissions";
|
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,7 @@ import {
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useVideoStore,
|
useVideoStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { usePermissions } from "@/hooks/usePermissions";
|
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
||||||
import { Permission } from "@/types/permissions";
|
|
||||||
import useMouse from "@/hooks/useMouse";
|
import useMouse from "@/hooks/useMouse";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { createContext } from "react";
|
|
||||||
|
|
||||||
import { PermissionsContextValue } from "@/hooks/usePermissions";
|
|
||||||
|
|
||||||
export const PermissionsContext = createContext<PermissionsContextValue | undefined>(undefined);
|
|
||||||
|
|
@ -1,34 +1,169 @@
|
||||||
import { useContext } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
import { PermissionsContext } from "@/contexts/PermissionsContext";
|
import { useJsonRpc, JsonRpcRequest } from "@/hooks/useJsonRpc";
|
||||||
import { Permission } from "@/types/permissions";
|
import { useSessionStore } from "@/stores/sessionStore";
|
||||||
|
import { useRTCStore } from "@/hooks/stores";
|
||||||
|
|
||||||
export interface PermissionsContextValue {
|
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
|
||||||
permissions: Record<string, boolean>;
|
|
||||||
isLoading: boolean;
|
// Permission types matching backend
|
||||||
hasPermission: (permission: Permission) => boolean;
|
export enum Permission {
|
||||||
hasAnyPermission: (...perms: Permission[]) => boolean;
|
// Video/Display permissions
|
||||||
hasAllPermissions: (...perms: Permission[]) => boolean;
|
VIDEO_VIEW = "video.view",
|
||||||
isPrimary: () => boolean;
|
|
||||||
isObserver: () => boolean;
|
// Input permissions
|
||||||
isPending: () => boolean;
|
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",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePermissions(): PermissionsContextValue {
|
interface PermissionsResponse {
|
||||||
const context = useContext(PermissionsContext);
|
mode: string;
|
||||||
|
permissions: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
if (context === undefined) {
|
export function usePermissions() {
|
||||||
return {
|
const { currentMode } = useSessionStore();
|
||||||
permissions: {},
|
const { setRpcHidProtocolVersion, rpcHidChannel } = useRTCStore();
|
||||||
isLoading: true,
|
const [permissions, setPermissions] = useState<Record<string, boolean>>({});
|
||||||
hasPermission: () => false,
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
hasAnyPermission: () => false,
|
const previousCanControl = useRef<boolean>(false);
|
||||||
hasAllPermissions: () => false,
|
|
||||||
isPrimary: () => false,
|
// Function to poll permissions
|
||||||
isObserver: () => false,
|
const pollPermissions = useCallback((send: RpcSendFunction) => {
|
||||||
isPending: () => false,
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return context;
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -16,10 +16,6 @@ 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,
|
||||||
|
|
@ -31,6 +27,7 @@ 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":
|
||||||
|
|
@ -39,9 +36,6 @@ 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;
|
||||||
|
|
@ -109,25 +103,23 @@ 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;
|
||||||
|
|
||||||
|
|
@ -144,6 +136,7 @@ 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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@ 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 } from "@/hooks/usePermissions";
|
import { usePermissions, Permission } 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;
|
||||||
|
|
||||||
|
|
@ -33,19 +32,21 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
|
||||||
clearSession
|
clearSession
|
||||||
} = useSessionStore();
|
} = useSessionStore();
|
||||||
|
|
||||||
const { hasPermission, isLoading: isLoadingPermissions } = usePermissions();
|
const { hasPermission } = 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;
|
||||||
|
|
||||||
|
|
@ -62,6 +63,7 @@ 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;
|
||||||
|
|
||||||
|
|
@ -78,6 +80,7 @@ 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;
|
||||||
|
|
||||||
|
|
@ -94,6 +97,7 @@ 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;
|
||||||
|
|
||||||
|
|
@ -110,30 +114,34 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === "newSessionPending" && requireSessionApproval) {
|
// Handle new session approval request (only if approval is required and user has permission)
|
||||||
if (isLoadingPermissions || hasPermission(Permission.SESSION_APPROVE)) {
|
if (method === "newSessionPending" && requireSessionApproval && 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");
|
||||||
|
|
@ -144,14 +152,9 @@ 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, isLoadingPermissions, requireSessionApproval]);
|
}, [handleSessionEvent, hasPermission, requireSessionApproval]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoadingPermissions && newSessionRequest && !hasPermission(Permission.SESSION_APPROVE)) {
|
|
||||||
setNewSessionRequest(null);
|
|
||||||
}
|
|
||||||
}, [isLoadingPermissions, hasPermission, newSessionRequest]);
|
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
clearSession();
|
clearSession();
|
||||||
|
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -6,8 +6,7 @@ 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 } from "@/hooks/usePermissions";
|
import { usePermissions, Permission } 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";
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@ 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 } from "@/hooks/usePermissions";
|
import { usePermissions, Permission } 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";
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,7 @@ 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 } from "@/hooks/usePermissions";
|
import { usePermissions, Permission } 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() {
|
||||||
|
|
@ -35,7 +34,7 @@ export default function SettingsRoute() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !permissions[Permission.SETTINGS_ACCESS] && currentMode !== null) {
|
if (!isLoading && !permissions[Permission.SETTINGS_ACCESS] && currentMode !== null) {
|
||||||
navigate("/", { replace: true });
|
navigate("/devices/local", { replace: true });
|
||||||
}
|
}
|
||||||
}, [permissions, isLoading, currentMode, navigate]);
|
}, [permissions, isLoading, currentMode, navigate]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ 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,
|
||||||
|
|
@ -53,9 +54,6 @@ 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";
|
||||||
|
|
@ -161,6 +159,7 @@ 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(
|
||||||
|
|
@ -550,6 +549,44 @@ 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");
|
||||||
|
|
@ -590,6 +627,9 @@ export default function KvmIdRoute() {
|
||||||
setRpcHidUnreliableNonOrderedChannel,
|
setRpcHidUnreliableNonOrderedChannel,
|
||||||
setRpcHidUnreliableChannel,
|
setRpcHidUnreliableChannel,
|
||||||
setTransceiver,
|
setTransceiver,
|
||||||
|
hasPermission,
|
||||||
|
setRequireSessionApproval,
|
||||||
|
setRequireSessionNickname,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -682,7 +722,6 @@ 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" ||
|
||||||
|
|
@ -696,6 +735,7 @@ 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");
|
||||||
}
|
}
|
||||||
|
|
@ -765,25 +805,21 @@ 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, hasPermission, isLoadingPermissions, send, setHdmiState]);
|
}, [rpcDataChannel?.readyState, 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) {
|
||||||
|
|
@ -795,18 +831,20 @@ export default function KvmIdRoute() {
|
||||||
}
|
}
|
||||||
setNeedLedState(false);
|
setNeedLedState(false);
|
||||||
});
|
});
|
||||||
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState, hasPermission, isLoadingPermissions]);
|
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState]);
|
||||||
|
|
||||||
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 {
|
||||||
|
|
@ -818,7 +856,7 @@ export default function KvmIdRoute() {
|
||||||
}
|
}
|
||||||
setNeedKeyDownState(false);
|
setNeedKeyDownState(false);
|
||||||
});
|
});
|
||||||
}, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeysDownState, setHidRpcDisabled, hasPermission, isLoadingPermissions]);
|
}, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeysDownState, setHidRpcDisabled]);
|
||||||
|
|
||||||
// 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(() => {
|
||||||
|
|
@ -851,10 +889,10 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (appVersion) return;
|
if (appVersion) return;
|
||||||
|
if (!hasPermission(Permission.VIDEO_VIEW)) return;
|
||||||
|
|
||||||
getLocalVersion();
|
getLocalVersion();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [appVersion, getLocalVersion, hasPermission]);
|
||||||
}, [appVersion]);
|
|
||||||
|
|
||||||
const ConnectionStatusElement = useMemo(() => {
|
const ConnectionStatusElement = useMemo(() => {
|
||||||
const hasConnectionFailed =
|
const hasConnectionFailed =
|
||||||
|
|
@ -894,7 +932,6 @@ export default function KvmIdRoute() {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PermissionsProvider>
|
|
||||||
<FeatureFlagProvider appVersion={appVersion}>
|
<FeatureFlagProvider appVersion={appVersion}>
|
||||||
{!outlet && otaState.updating && (
|
{!outlet && otaState.updating && (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|
@ -1070,7 +1107,6 @@ export default function KvmIdRoute() {
|
||||||
show={currentMode === "pending"}
|
show={currentMode === "pending"}
|
||||||
/>
|
/>
|
||||||
</FeatureFlagProvider>
|
</FeatureFlagProvider>
|
||||||
</PermissionsProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
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",
|
|
||||||
}
|
|
||||||
10
webrtc.go
10
webrtc.go
|
|
@ -78,6 +78,14 @@ 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()
|
||||||
|
|
@ -88,7 +96,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 = 500 // Increased to support 10+ concurrent sessions with broadcasts and state updates
|
maxRPCPerSecond = 100 // Increased from 20 to accommodate multi-session polling and reconnections
|
||||||
rateLimitWindow = time.Second
|
rateLimitWindow = time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue