mirror of https://github.com/jetkvm/kvm.git
Compare commits
11 Commits
98221d2476
...
3f65247a19
| Author | SHA1 | Date |
|---|---|---|
|
|
3f65247a19 | |
|
|
16509188b0 | |
|
|
554b43fae9 | |
|
|
f27c2f4eb2 | |
|
|
335c6ee35e | |
|
|
f9e190f8b9 | |
|
|
00e6edbfa8 | |
|
|
f90c255656 | |
|
|
821675cd21 | |
|
|
825299257d | |
|
|
309126bef6 |
7
cloud.go
7
cloud.go
|
|
@ -512,12 +512,8 @@ func handleSessionRequest(
|
||||||
_ = wsjson.Write(context.Background(), c, gin.H{"error": "session manager not initialized"})
|
_ = wsjson.Write(context.Background(), c, gin.H{"error": "session manager not initialized"})
|
||||||
return fmt.Errorf("session manager not initialized")
|
return fmt.Errorf("session manager not initialized")
|
||||||
}
|
}
|
||||||
scopedLogger.Debug().Msg("About to call AddSession")
|
|
||||||
err = sessionManager.AddSession(session, req.SessionSettings)
|
err = sessionManager.AddSession(session, req.SessionSettings)
|
||||||
scopedLogger.Debug().
|
|
||||||
Bool("addSessionSucceeded", err == nil).
|
|
||||||
Str("error", fmt.Sprintf("%v", err)).
|
|
||||||
Msg("AddSession returned")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
scopedLogger.Warn().Err(err).Msg("failed to add session to session manager")
|
scopedLogger.Warn().Err(err).Msg("failed to add session to session manager")
|
||||||
if err == ErrMaxSessionsReached {
|
if err == ErrMaxSessionsReached {
|
||||||
|
|
@ -527,7 +523,6 @@ func handleSessionRequest(
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
scopedLogger.Debug().Msg("AddSession completed successfully, continuing")
|
|
||||||
|
|
||||||
if session.HasPermission(PermissionPaste) {
|
if session.HasPermission(PermissionPaste) {
|
||||||
cancelKeyboardMacro()
|
cancelKeyboardMacro()
|
||||||
|
|
|
||||||
67
jsonrpc.go
67
jsonrpc.go
|
|
@ -192,12 +192,10 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
if err := RequirePermission(session, PermissionSessionApprove); err != nil {
|
if err := RequirePermission(session, PermissionSessionApprove); err != nil {
|
||||||
handlerErr = err
|
handlerErr = err
|
||||||
} else if sessionID, ok := request.Params["sessionId"].(string); ok {
|
} else if sessionID, ok := request.Params["sessionId"].(string); ok {
|
||||||
if targetSession := sessionManager.GetSession(sessionID); targetSession != nil && targetSession.Mode == SessionModePending {
|
handlerErr = sessionManager.ApproveSession(sessionID)
|
||||||
targetSession.Mode = SessionModeObserver
|
if handlerErr == nil {
|
||||||
sessionManager.broadcastSessionListUpdate()
|
go sessionManager.broadcastSessionListUpdate()
|
||||||
result = map[string]interface{}{"status": "approved"}
|
result = map[string]interface{}{"status": "approved"}
|
||||||
} else {
|
|
||||||
handlerErr = errors.New("session not found or not pending")
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
handlerErr = errors.New("invalid sessionId parameter")
|
handlerErr = errors.New("invalid sessionId parameter")
|
||||||
|
|
@ -206,14 +204,18 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
if err := RequirePermission(session, PermissionSessionApprove); err != nil {
|
if err := RequirePermission(session, PermissionSessionApprove); err != nil {
|
||||||
handlerErr = err
|
handlerErr = err
|
||||||
} else if sessionID, ok := request.Params["sessionId"].(string); ok {
|
} else if sessionID, ok := request.Params["sessionId"].(string); ok {
|
||||||
if targetSession := sessionManager.GetSession(sessionID); targetSession != nil && targetSession.Mode == SessionModePending {
|
handlerErr = sessionManager.DenySession(sessionID)
|
||||||
writeJSONRPCEvent("sessionAccessDenied", map[string]interface{}{
|
if handlerErr == nil {
|
||||||
"message": "Access denied by primary session",
|
// Notify the denied session
|
||||||
}, targetSession)
|
if targetSession := sessionManager.GetSession(sessionID); targetSession != nil {
|
||||||
sessionManager.broadcastSessionListUpdate()
|
go func() {
|
||||||
|
writeJSONRPCEvent("sessionAccessDenied", map[string]interface{}{
|
||||||
|
"message": "Access denied by primary session",
|
||||||
|
}, targetSession)
|
||||||
|
sessionManager.broadcastSessionListUpdate()
|
||||||
|
}()
|
||||||
|
}
|
||||||
result = map[string]interface{}{"status": "denied"}
|
result = map[string]interface{}{"status": "denied"}
|
||||||
} else {
|
|
||||||
handlerErr = errors.New("session not found or not pending")
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
handlerErr = errors.New("invalid sessionId parameter")
|
handlerErr = errors.New("invalid sessionId parameter")
|
||||||
|
|
@ -251,24 +253,35 @@ 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) {
|
||||||
targetSession.Nickname = nickname
|
// Check nickname uniqueness
|
||||||
|
allSessions := sessionManager.GetAllSessions()
|
||||||
// If session is pending and approval is required, send the approval request now that we have a nickname
|
for _, existingSession := range allSessions {
|
||||||
if targetSession.Mode == SessionModePending && currentSessionSettings != nil && currentSessionSettings.RequireApproval {
|
if existingSession.ID != sessionID && existingSession.Nickname == nickname {
|
||||||
if primary := sessionManager.GetPrimarySession(); primary != nil {
|
handlerErr = fmt.Errorf("nickname '%s' is already in use by another session", nickname)
|
||||||
go func() {
|
break
|
||||||
writeJSONRPCEvent("newSessionPending", map[string]interface{}{
|
|
||||||
"sessionId": targetSession.ID,
|
|
||||||
"source": targetSession.Source,
|
|
||||||
"identity": targetSession.Identity,
|
|
||||||
"nickname": targetSession.Nickname,
|
|
||||||
}, primary)
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionManager.broadcastSessionListUpdate()
|
if handlerErr == nil {
|
||||||
result = map[string]interface{}{"status": "updated"}
|
targetSession.Nickname = nickname
|
||||||
|
|
||||||
|
// If session is pending and approval is required, send the approval request now that we have a nickname
|
||||||
|
if targetSession.Mode == SessionModePending && currentSessionSettings != nil && currentSessionSettings.RequireApproval {
|
||||||
|
if primary := sessionManager.GetPrimarySession(); primary != nil {
|
||||||
|
go func() {
|
||||||
|
writeJSONRPCEvent("newSessionPending", map[string]interface{}{
|
||||||
|
"sessionId": targetSession.ID,
|
||||||
|
"source": targetSession.Source,
|
||||||
|
"identity": targetSession.Identity,
|
||||||
|
"nickname": targetSession.Nickname,
|
||||||
|
}, primary)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionManager.broadcastSessionListUpdate()
|
||||||
|
result = map[string]interface{}{"status": "updated"}
|
||||||
|
}
|
||||||
} else {
|
} 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,6 +29,9 @@ func Main() {
|
||||||
}
|
}
|
||||||
currentSessionSettings = config.SessionSettings
|
currentSessionSettings = config.SessionSettings
|
||||||
|
|
||||||
|
// Initialize global session manager (must be called after config and logger are ready)
|
||||||
|
initSessionManager()
|
||||||
|
|
||||||
var cancel context.CancelFunc
|
var cancel context.CancelFunc
|
||||||
appCtx, cancel = context.WithCancel(context.Background())
|
appCtx, cancel = context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
|
||||||
|
|
@ -132,9 +132,6 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
|
||||||
return errors.New("session cannot be nil")
|
return errors.New("session cannot be nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
sm.logger.Debug().
|
|
||||||
Str("sessionID", session.ID).
|
|
||||||
Msg("AddSession ENTRY")
|
|
||||||
// Validate nickname if provided (matching frontend validation)
|
// Validate nickname if provided (matching frontend validation)
|
||||||
if session.Nickname != "" {
|
if session.Nickname != "" {
|
||||||
if len(session.Nickname) < 2 {
|
if len(session.Nickname) < 2 {
|
||||||
|
|
@ -152,6 +149,15 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
defer sm.mu.Unlock()
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
// Check nickname uniqueness (only for non-empty nicknames)
|
||||||
|
if session.Nickname != "" {
|
||||||
|
for id, existingSession := range sm.sessions {
|
||||||
|
if id != session.ID && existingSession.Nickname == session.Nickname {
|
||||||
|
return fmt.Errorf("nickname '%s' is already in use by another session", session.Nickname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
wasWithinGracePeriod := false
|
wasWithinGracePeriod := false
|
||||||
wasPreviouslyPrimary := false
|
wasPreviouslyPrimary := false
|
||||||
wasPreviouslyPending := false
|
wasPreviouslyPending := false
|
||||||
|
|
@ -163,25 +169,17 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
|
||||||
wasPreviouslyPending = (reconnectInfo.Mode == SessionModePending)
|
wasPreviouslyPending = (reconnectInfo.Mode == SessionModePending)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
delete(sm.reconnectGrace, session.ID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a session with this ID already exists (reconnection)
|
// Check if a session with this ID already exists (reconnection)
|
||||||
if existing, exists := sm.sessions[session.ID]; exists {
|
if existing, exists := sm.sessions[session.ID]; exists {
|
||||||
sm.logger.Debug().
|
|
||||||
Str("sessionID", session.ID).
|
|
||||||
Msg("AddSession: session ID already exists - RECONNECTION PATH")
|
|
||||||
|
|
||||||
// SECURITY: Verify identity matches to prevent session hijacking
|
// SECURITY: Verify identity matches to prevent session hijacking
|
||||||
if existing.Identity != session.Identity || existing.Source != session.Source {
|
if existing.Identity != session.Identity || existing.Source != session.Source {
|
||||||
return fmt.Errorf("session ID already in use by different user (identity mismatch)")
|
return fmt.Errorf("session ID already in use by different user (identity mismatch)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRITICAL: Close old connection to prevent multiple active connections for same session ID
|
// Close old connection to prevent multiple active connections for same session ID
|
||||||
if existing.peerConnection != nil {
|
if existing.peerConnection != nil {
|
||||||
sm.logger.Info().
|
|
||||||
Str("sessionID", session.ID).
|
|
||||||
Msg("Closing old peer connection for session reconnection")
|
|
||||||
existing.peerConnection.Close()
|
existing.peerConnection.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,42 +203,24 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
|
||||||
|
|
||||||
// If this was the primary, try to restore primary status
|
// If this was the primary, try to restore primary status
|
||||||
if existing.Mode == SessionModePrimary {
|
if existing.Mode == SessionModePrimary {
|
||||||
// Check if this session is still the reserved primary AND not blacklisted
|
|
||||||
isBlacklisted := sm.isSessionBlacklisted(session.ID)
|
isBlacklisted := sm.isSessionBlacklisted(session.ID)
|
||||||
if sm.lastPrimaryID == session.ID && !isBlacklisted {
|
// SECURITY: Prevent dual-primary window - only restore if no other primary exists
|
||||||
// This is the rightful primary reconnecting within grace period
|
primaryExists := sm.primarySessionID != "" && sm.sessions[sm.primarySessionID] != nil
|
||||||
|
if sm.lastPrimaryID == session.ID && !isBlacklisted && !primaryExists {
|
||||||
sm.primarySessionID = session.ID
|
sm.primarySessionID = session.ID
|
||||||
sm.lastPrimaryID = "" // Clear since primary successfully reconnected
|
sm.lastPrimaryID = ""
|
||||||
delete(sm.reconnectGrace, session.ID) // Clear grace period
|
delete(sm.reconnectGrace, session.ID)
|
||||||
sm.logger.Debug().
|
|
||||||
Str("sessionID", session.ID).
|
|
||||||
Msg("Primary session successfully reconnected within grace period")
|
|
||||||
} else {
|
} else {
|
||||||
// This session was primary but grace period expired, another took over, or is blacklisted
|
// Grace period expired, another session took over, or primary already exists
|
||||||
session.Mode = SessionModeObserver
|
session.Mode = SessionModeObserver
|
||||||
sm.logger.Debug().
|
|
||||||
Str("sessionID", session.ID).
|
|
||||||
Str("currentPrimaryID", sm.primarySessionID).
|
|
||||||
Bool("isBlacklisted", isBlacklisted).
|
|
||||||
Msg("Former primary session reconnected but grace period expired, another took over, or session is blacklisted - demoting to observer")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: Skip validation during reconnection to preserve grace period
|
|
||||||
// validateSinglePrimary() would clear primary slot during reconnection window
|
|
||||||
|
|
||||||
sm.logger.Debug().
|
|
||||||
Str("sessionID", session.ID).
|
|
||||||
Msg("AddSession: RETURNING from reconnection path")
|
|
||||||
go sm.broadcastSessionListUpdate()
|
go sm.broadcastSessionListUpdate()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(sm.sessions) >= sm.maxSessions {
|
if len(sm.sessions) >= sm.maxSessions {
|
||||||
sm.logger.Warn().
|
|
||||||
Int("currentSessions", len(sm.sessions)).
|
|
||||||
Int("maxSessions", sm.maxSessions).
|
|
||||||
Msg("AddSession: MAX SESSIONS REACHED")
|
|
||||||
return ErrMaxSessionsReached
|
return ErrMaxSessionsReached
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,15 +229,6 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
|
||||||
session.ID = uuid.New().String()
|
session.ID = uuid.New().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up any grace period entries for this session since it's reconnecting
|
|
||||||
if wasWithinGracePeriod {
|
|
||||||
delete(sm.reconnectGrace, session.ID)
|
|
||||||
delete(sm.reconnectInfo, session.ID)
|
|
||||||
sm.logger.Info().
|
|
||||||
Str("sessionID", session.ID).
|
|
||||||
Msg("Session reconnected within grace period - cleaned up grace period entries")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set nickname from client settings if provided
|
// Set nickname from client settings if provided
|
||||||
if clientSettings != nil && clientSettings.Nickname != "" {
|
if clientSettings != nil && clientSettings.Nickname != "" {
|
||||||
session.Nickname = clientSettings.Nickname
|
session.Nickname = clientSettings.Nickname
|
||||||
|
|
@ -266,9 +237,6 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
|
||||||
// Use global settings for requirements (not client-provided)
|
// Use global settings for requirements (not client-provided)
|
||||||
globalSettings := currentSessionSettings
|
globalSettings := currentSessionSettings
|
||||||
|
|
||||||
// Set mode based on current state and global settings
|
|
||||||
// ATOMIC CHECK AND ASSIGN: Check if there's currently no primary session
|
|
||||||
// and assign primary status atomically to prevent race conditions
|
|
||||||
primaryExists := sm.primarySessionID != "" && sm.sessions[sm.primarySessionID] != nil
|
primaryExists := sm.primarySessionID != "" && sm.sessions[sm.primarySessionID] != nil
|
||||||
|
|
||||||
// Check if there's an active grace period for a primary session (different from this session)
|
// Check if there's an active grace period for a primary session (different from this session)
|
||||||
|
|
@ -285,65 +253,28 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this session was recently demoted via transfer
|
|
||||||
isBlacklisted := sm.isSessionBlacklisted(session.ID)
|
isBlacklisted := sm.isSessionBlacklisted(session.ID)
|
||||||
|
|
||||||
sm.logger.Debug().
|
// Determine if this session should become primary
|
||||||
Str("newSessionID", session.ID).
|
// If there's no primary AND this is the ONLY session, ALWAYS promote regardless of blacklist
|
||||||
Str("nickname", session.Nickname).
|
isOnlySession := len(sm.sessions) == 0
|
||||||
Str("currentPrimarySessionID", sm.primarySessionID).
|
shouldBecomePrimary := (wasWithinGracePeriod && wasPreviouslyPrimary && !primaryExists && !hasActivePrimaryGracePeriod) ||
|
||||||
Bool("primaryExists", primaryExists).
|
(!wasWithinGracePeriod && !hasActivePrimaryGracePeriod && !primaryExists && (!isBlacklisted || isOnlySession))
|
||||||
Bool("hasActivePrimaryGracePeriod", hasActivePrimaryGracePeriod).
|
|
||||||
Int("totalSessions", len(sm.sessions)).
|
|
||||||
Bool("wasWithinGracePeriod", wasWithinGracePeriod).
|
|
||||||
Bool("wasPreviouslyPrimary", wasPreviouslyPrimary).
|
|
||||||
Bool("wasPreviouslyPending", wasPreviouslyPending).
|
|
||||||
Bool("isBlacklisted", isBlacklisted).
|
|
||||||
Msg("AddSession state analysis")
|
|
||||||
|
|
||||||
// Become primary only if:
|
|
||||||
// 1. Was previously primary (within grace) AND no current primary AND no other session has grace period, OR
|
|
||||||
// 2. There's no primary at all AND not recently transferred away AND no active grace period
|
|
||||||
// Never allow primary promotion if already restored within grace period or another session has grace period
|
|
||||||
shouldBecomePrimary := !wasWithinGracePeriod && !hasActivePrimaryGracePeriod && ((wasPreviouslyPrimary && !primaryExists) || (!primaryExists && !isBlacklisted))
|
|
||||||
|
|
||||||
if wasWithinGracePeriod {
|
|
||||||
sm.logger.Debug().
|
|
||||||
Str("sessionID", session.ID).
|
|
||||||
Bool("wasPreviouslyPrimary", wasPreviouslyPrimary).
|
|
||||||
Bool("primaryExists", primaryExists).
|
|
||||||
Str("currentPrimarySessionID", sm.primarySessionID).
|
|
||||||
Msg("Session within grace period - skipping primary promotion logic")
|
|
||||||
}
|
|
||||||
|
|
||||||
if shouldBecomePrimary {
|
if shouldBecomePrimary {
|
||||||
// Double-check primary doesn't exist (race condition prevention)
|
|
||||||
if sm.primarySessionID == "" || sm.sessions[sm.primarySessionID] == nil {
|
if sm.primarySessionID == "" || sm.sessions[sm.primarySessionID] == nil {
|
||||||
// Since we now generate nicknames automatically when required,
|
|
||||||
// we can always promote to primary when no primary exists
|
|
||||||
session.Mode = SessionModePrimary
|
session.Mode = SessionModePrimary
|
||||||
sm.primarySessionID = session.ID
|
sm.primarySessionID = session.ID
|
||||||
sm.lastPrimaryID = "" // Clear since we have a new primary
|
sm.lastPrimaryID = ""
|
||||||
|
|
||||||
// Clear all existing grace periods when a new primary is established
|
// Clear all existing grace periods when a new primary is established
|
||||||
// This prevents multiple sessions from fighting for primary status via grace period
|
for oldSessionID := range sm.reconnectGrace {
|
||||||
if len(sm.reconnectGrace) > 0 || len(sm.reconnectInfo) > 0 {
|
delete(sm.reconnectGrace, oldSessionID)
|
||||||
sm.logger.Debug().
|
}
|
||||||
Int("clearedGracePeriods", len(sm.reconnectGrace)).
|
for oldSessionID := range sm.reconnectInfo {
|
||||||
Int("clearedReconnectInfo", len(sm.reconnectInfo)).
|
delete(sm.reconnectInfo, oldSessionID)
|
||||||
Str("newPrimarySessionID", session.ID).
|
|
||||||
Msg("Clearing all existing grace periods for new primary session in AddSession")
|
|
||||||
|
|
||||||
// Clear all existing grace periods and reconnect info
|
|
||||||
for oldSessionID := range sm.reconnectGrace {
|
|
||||||
delete(sm.reconnectGrace, oldSessionID)
|
|
||||||
}
|
|
||||||
for oldSessionID := range sm.reconnectInfo {
|
|
||||||
delete(sm.reconnectInfo, oldSessionID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset HID availability to force re-handshake for input functionality
|
|
||||||
session.hidRPCAvailable = false
|
session.hidRPCAvailable = false
|
||||||
} else {
|
} else {
|
||||||
session.Mode = SessionModeObserver
|
session.Mode = SessionModeObserver
|
||||||
|
|
@ -381,8 +312,6 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
|
||||||
session.CreatedAt = time.Now()
|
session.CreatedAt = time.Now()
|
||||||
session.LastActive = time.Now()
|
session.LastActive = time.Now()
|
||||||
|
|
||||||
// Add session to sessions map BEFORE primary checks
|
|
||||||
// This ensures that primary existence checks work correctly during restoration
|
|
||||||
sm.sessions[session.ID] = session
|
sm.sessions[session.ID] = session
|
||||||
|
|
||||||
sm.logger.Info().
|
sm.logger.Info().
|
||||||
|
|
@ -394,9 +323,14 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
|
||||||
// Ensure session has auto-generated nickname if needed
|
// Ensure session has auto-generated nickname if needed
|
||||||
sm.ensureNickname(session)
|
sm.ensureNickname(session)
|
||||||
|
|
||||||
// Validate sessions but respect grace periods
|
|
||||||
sm.validateSinglePrimary()
|
sm.validateSinglePrimary()
|
||||||
|
|
||||||
|
// Clean up grace period after validation completes
|
||||||
|
if wasWithinGracePeriod {
|
||||||
|
delete(sm.reconnectGrace, session.ID)
|
||||||
|
delete(sm.reconnectInfo, session.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// Notify all sessions about the new connection
|
// Notify all sessions about the new connection
|
||||||
go sm.broadcastSessionListUpdate()
|
go sm.broadcastSessionListUpdate()
|
||||||
|
|
||||||
|
|
@ -410,9 +344,6 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
|
||||||
|
|
||||||
session, exists := sm.sessions[sessionID]
|
session, exists := sm.sessions[sessionID]
|
||||||
if !exists {
|
if !exists {
|
||||||
sm.logger.Debug().
|
|
||||||
Str("sessionID", sessionID).
|
|
||||||
Msg("RemoveSession called but session not found in map")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -431,12 +362,8 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
|
||||||
// Check if this session was marked for immediate removal (intentional logout)
|
// Check if this session was marked for immediate removal (intentional logout)
|
||||||
isIntentionalLogout := false
|
isIntentionalLogout := false
|
||||||
if graceTime, exists := sm.reconnectGrace[sessionID]; exists {
|
if graceTime, exists := sm.reconnectGrace[sessionID]; exists {
|
||||||
// If grace period is already expired, this was intentional logout
|
|
||||||
if time.Now().After(graceTime) {
|
if time.Now().After(graceTime) {
|
||||||
isIntentionalLogout = true
|
isIntentionalLogout = true
|
||||||
sm.logger.Info().
|
|
||||||
Str("sessionID", sessionID).
|
|
||||||
Msg("Detected intentional logout - skipping grace period")
|
|
||||||
delete(sm.reconnectGrace, sessionID)
|
delete(sm.reconnectGrace, sessionID)
|
||||||
delete(sm.reconnectInfo, sessionID)
|
delete(sm.reconnectInfo, sessionID)
|
||||||
}
|
}
|
||||||
|
|
@ -450,12 +377,9 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
|
||||||
|
|
||||||
// Only add grace period if this is NOT an intentional logout
|
// Only add grace period if this is NOT an intentional logout
|
||||||
if !isIntentionalLogout {
|
if !isIntentionalLogout {
|
||||||
// Add a grace period for reconnection for all sessions
|
// Limit grace period entries to prevent memory exhaustion
|
||||||
|
const maxGraceEntries = 10
|
||||||
// Limit grace period entries to prevent memory exhaustion (DoS protection)
|
|
||||||
const maxGraceEntries = 10 // Reduced from 20 to limit memory usage
|
|
||||||
for len(sm.reconnectGrace) >= maxGraceEntries {
|
for len(sm.reconnectGrace) >= maxGraceEntries {
|
||||||
// Find and remove the oldest grace period entry
|
|
||||||
var oldestID string
|
var oldestID string
|
||||||
var oldestTime time.Time
|
var oldestTime time.Time
|
||||||
for id, graceTime := range sm.reconnectGrace {
|
for id, graceTime := range sm.reconnectGrace {
|
||||||
|
|
@ -468,7 +392,7 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
|
||||||
delete(sm.reconnectGrace, oldestID)
|
delete(sm.reconnectGrace, oldestID)
|
||||||
delete(sm.reconnectInfo, oldestID)
|
delete(sm.reconnectInfo, oldestID)
|
||||||
} else {
|
} else {
|
||||||
break // Safety check to prevent infinite loop
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -500,14 +424,8 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
|
||||||
sm.lastPrimaryID = sessionID // Remember this was the primary for grace period
|
sm.lastPrimaryID = sessionID // Remember this was the primary for grace period
|
||||||
sm.primarySessionID = "" // Clear primary slot so other sessions can be promoted
|
sm.primarySessionID = "" // Clear primary slot so other sessions can be promoted
|
||||||
|
|
||||||
// Clear all blacklists to allow emergency promotion after grace period expires
|
// Clear all blacklists to allow promotion after grace period expires
|
||||||
// The blacklist is meant to prevent immediate re-promotion during manual transfers,
|
|
||||||
// but should not block emergency promotion after accidental disconnects
|
|
||||||
if len(sm.transferBlacklist) > 0 {
|
if len(sm.transferBlacklist) > 0 {
|
||||||
sm.logger.Info().
|
|
||||||
Int("clearedBlacklistEntries", len(sm.transferBlacklist)).
|
|
||||||
Str("disconnectedPrimaryID", sessionID).
|
|
||||||
Msg("Clearing transfer blacklist to allow grace period promotion")
|
|
||||||
sm.transferBlacklist = make([]TransferBlacklistEntry, 0)
|
sm.transferBlacklist = make([]TransferBlacklistEntry, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -520,11 +438,6 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
|
||||||
|
|
||||||
// Trigger validation for potential promotion
|
// Trigger validation for potential promotion
|
||||||
if len(sm.sessions) > 0 {
|
if len(sm.sessions) > 0 {
|
||||||
sm.logger.Debug().
|
|
||||||
Str("removedPrimaryID", sessionID).
|
|
||||||
Bool("intentionalLogout", isIntentionalLogout).
|
|
||||||
Int("remainingSessions", len(sm.sessions)).
|
|
||||||
Msg("Triggering immediate validation for potential promotion")
|
|
||||||
sm.validateSinglePrimary()
|
sm.validateSinglePrimary()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -879,6 +792,23 @@ func (sm *SessionManager) ApprovePrimaryRequest(currentPrimaryID, requesterID st
|
||||||
return errors.New("not the primary session")
|
return errors.New("not the primary session")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SECURITY: Verify requester session exists and is in Queued mode
|
||||||
|
requesterSession, exists := sm.sessions[requesterID]
|
||||||
|
if !exists {
|
||||||
|
sm.logger.Error().
|
||||||
|
Str("requesterID", requesterID).
|
||||||
|
Msg("Requester session not found")
|
||||||
|
return errors.New("requester session not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if requesterSession.Mode != SessionModeQueued {
|
||||||
|
sm.logger.Error().
|
||||||
|
Str("requesterID", requesterID).
|
||||||
|
Str("actualMode", string(requesterSession.Mode)).
|
||||||
|
Msg("Requester session is not in queued mode")
|
||||||
|
return fmt.Errorf("requester session is not in queued mode (current mode: %s)", requesterSession.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
// Remove requester from queue
|
// Remove requester from queue
|
||||||
sm.removeFromQueue(requesterID)
|
sm.removeFromQueue(requesterID)
|
||||||
|
|
||||||
|
|
@ -936,6 +866,51 @@ func (sm *SessionManager) DenyPrimaryRequest(currentPrimaryID, requesterID strin
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApproveSession approves a pending session (thread-safe)
|
||||||
|
func (sm *SessionManager) ApproveSession(sessionID string) error {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
session, exists := sm.sessions[sessionID]
|
||||||
|
if !exists {
|
||||||
|
return ErrSessionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.Mode != SessionModePending {
|
||||||
|
return errors.New("session is not in pending mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promote session to observer
|
||||||
|
session.Mode = SessionModeObserver
|
||||||
|
|
||||||
|
sm.logger.Info().
|
||||||
|
Str("sessionID", sessionID).
|
||||||
|
Msg("Session approved and promoted to observer")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DenySession denies a pending session (thread-safe)
|
||||||
|
func (sm *SessionManager) DenySession(sessionID string) error {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
session, exists := sm.sessions[sessionID]
|
||||||
|
if !exists {
|
||||||
|
return ErrSessionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.Mode != SessionModePending {
|
||||||
|
return errors.New("session is not in pending mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.logger.Info().
|
||||||
|
Str("sessionID", sessionID).
|
||||||
|
Msg("Session denied - notifying session")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ForEachSession executes a function for each active session
|
// ForEachSession executes a function for each active session
|
||||||
func (sm *SessionManager) ForEachSession(fn func(*Session)) {
|
func (sm *SessionManager) ForEachSession(fn func(*Session)) {
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
|
|
@ -967,17 +942,6 @@ func (sm *SessionManager) UpdateLastActive(sessionID string) {
|
||||||
func (sm *SessionManager) validateSinglePrimary() {
|
func (sm *SessionManager) validateSinglePrimary() {
|
||||||
primarySessions := make([]*Session, 0)
|
primarySessions := make([]*Session, 0)
|
||||||
|
|
||||||
sm.logger.Debug().
|
|
||||||
Int("sm.sessions_len", len(sm.sessions)).
|
|
||||||
Interface("sm.sessions_keys", func() []string {
|
|
||||||
keys := make([]string, 0, len(sm.sessions))
|
|
||||||
for k := range sm.sessions {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}()).
|
|
||||||
Msg("validateSinglePrimary: checking sm.sessions map")
|
|
||||||
|
|
||||||
// Find all sessions that think they're primary
|
// Find all sessions that think they're primary
|
||||||
for _, session := range sm.sessions {
|
for _, session := range sm.sessions {
|
||||||
if session.Mode == SessionModePrimary {
|
if session.Mode == SessionModePrimary {
|
||||||
|
|
@ -985,26 +949,18 @@ func (sm *SessionManager) validateSinglePrimary() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have multiple primaries, this is a critical bug - fix it
|
// If we have multiple primaries, fix it
|
||||||
if len(primarySessions) > 1 {
|
if len(primarySessions) > 1 {
|
||||||
sm.logger.Error().
|
sm.logger.Error().
|
||||||
Int("primaryCount", len(primarySessions)).
|
Int("primaryCount", len(primarySessions)).
|
||||||
Msg("CRITICAL BUG: Multiple primary sessions detected, fixing...")
|
Msg("Multiple primary sessions detected, fixing")
|
||||||
|
|
||||||
// Keep the first one as primary, demote the rest
|
// Keep the first one as primary, demote the rest
|
||||||
for i, session := range primarySessions {
|
for i, session := range primarySessions {
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
// Keep this as primary and update manager state
|
|
||||||
sm.primarySessionID = session.ID
|
sm.primarySessionID = session.ID
|
||||||
sm.logger.Info().
|
|
||||||
Str("keptPrimaryID", session.ID).
|
|
||||||
Msg("Kept session as primary")
|
|
||||||
} else {
|
} else {
|
||||||
// Demote all others
|
|
||||||
session.Mode = SessionModeObserver
|
session.Mode = SessionModeObserver
|
||||||
sm.logger.Info().
|
|
||||||
Str("demotedSessionID", session.ID).
|
|
||||||
Msg("Demoted duplicate primary session")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1019,25 +975,14 @@ func (sm *SessionManager) validateSinglePrimary() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't clear primary slot if there's a grace period active
|
// Don't clear primary slot if there's a grace period active
|
||||||
// This prevents instant promotion during primary session reconnection
|
|
||||||
if len(primarySessions) == 0 && sm.primarySessionID != "" {
|
if len(primarySessions) == 0 && sm.primarySessionID != "" {
|
||||||
// Check if the current primary is in grace period waiting to reconnect
|
|
||||||
if sm.lastPrimaryID == sm.primarySessionID {
|
if sm.lastPrimaryID == sm.primarySessionID {
|
||||||
if graceTime, exists := sm.reconnectGrace[sm.primarySessionID]; exists {
|
if graceTime, exists := sm.reconnectGrace[sm.primarySessionID]; exists {
|
||||||
if time.Now().Before(graceTime) {
|
if time.Now().Before(graceTime) {
|
||||||
// Primary is in grace period, DON'T clear the slot yet
|
return // Keep primary slot reserved during grace period
|
||||||
sm.logger.Info().
|
|
||||||
Str("gracePrimaryID", sm.primarySessionID).
|
|
||||||
Msg("Primary slot preserved - session in grace period")
|
|
||||||
return // Exit validation, keep primary slot reserved
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No grace period, safe to clear orphaned primary
|
|
||||||
sm.logger.Warn().
|
|
||||||
Str("orphanedPrimaryID", sm.primarySessionID).
|
|
||||||
Msg("Cleared orphaned primary ID")
|
|
||||||
sm.primarySessionID = ""
|
sm.primarySessionID = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1048,30 +993,12 @@ func (sm *SessionManager) validateSinglePrimary() {
|
||||||
if reconnectInfo, hasInfo := sm.reconnectInfo[sessionID]; hasInfo {
|
if reconnectInfo, hasInfo := sm.reconnectInfo[sessionID]; hasInfo {
|
||||||
if reconnectInfo.Mode == SessionModePrimary {
|
if reconnectInfo.Mode == SessionModePrimary {
|
||||||
hasActivePrimaryGracePeriod = true
|
hasActivePrimaryGracePeriod = true
|
||||||
sm.logger.Debug().
|
|
||||||
Str("gracePrimaryID", sessionID).
|
|
||||||
Dur("remainingGrace", time.Until(graceTime)).
|
|
||||||
Msg("Active grace period detected for primary session - blocking auto-promotion")
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build session IDs list for debugging
|
|
||||||
sessionIDs := make([]string, 0, len(sm.sessions))
|
|
||||||
for id := range sm.sessions {
|
|
||||||
sessionIDs = append(sessionIDs, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
sm.logger.Debug().
|
|
||||||
Int("primarySessionCount", len(primarySessions)).
|
|
||||||
Str("primarySessionID", sm.primarySessionID).
|
|
||||||
Int("totalSessions", len(sm.sessions)).
|
|
||||||
Strs("sessionIDs", sessionIDs).
|
|
||||||
Bool("hasActivePrimaryGracePeriod", hasActivePrimaryGracePeriod).
|
|
||||||
Msg("validateSinglePrimary state check")
|
|
||||||
|
|
||||||
// Auto-promote if there are NO primary sessions at all AND no active grace period
|
// Auto-promote if there are NO primary sessions at all AND no active grace period
|
||||||
if len(primarySessions) == 0 && sm.primarySessionID == "" && len(sm.sessions) > 0 && !hasActivePrimaryGracePeriod {
|
if len(primarySessions) == 0 && sm.primarySessionID == "" && len(sm.sessions) > 0 && !hasActivePrimaryGracePeriod {
|
||||||
// Find a session to promote to primary
|
// Find a session to promote to primary
|
||||||
|
|
@ -1093,13 +1020,6 @@ func (sm *SessionManager) validateSinglePrimary() {
|
||||||
sm.logger.Warn().
|
sm.logger.Warn().
|
||||||
Msg("No eligible session found for emergency auto-promotion")
|
Msg("No eligible session found for emergency auto-promotion")
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
sm.logger.Debug().
|
|
||||||
Int("primarySessions", len(primarySessions)).
|
|
||||||
Str("primarySessionID", sm.primarySessionID).
|
|
||||||
Bool("hasSessions", len(sm.sessions) > 0).
|
|
||||||
Bool("hasActivePrimaryGracePeriod", hasActivePrimaryGracePeriod).
|
|
||||||
Msg("Emergency auto-promotion conditions not met")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1134,6 +1054,9 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
|
||||||
if fromExists && fromSession.Mode == SessionModePrimary {
|
if fromExists && fromSession.Mode == SessionModePrimary {
|
||||||
fromSession.Mode = SessionModeObserver
|
fromSession.Mode = SessionModeObserver
|
||||||
fromSession.hidRPCAvailable = false
|
fromSession.hidRPCAvailable = false
|
||||||
|
|
||||||
|
// Always delete grace period when demoting - no exceptions
|
||||||
|
// If a session times out or is manually transferred, it should not auto-reclaim primary
|
||||||
delete(sm.reconnectGrace, fromSessionID)
|
delete(sm.reconnectGrace, fromSessionID)
|
||||||
delete(sm.reconnectInfo, fromSessionID)
|
delete(sm.reconnectInfo, fromSessionID)
|
||||||
|
|
||||||
|
|
@ -1160,7 +1083,11 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
|
||||||
toSession.Mode = SessionModePrimary
|
toSession.Mode = SessionModePrimary
|
||||||
toSession.hidRPCAvailable = false // Force re-handshake
|
toSession.hidRPCAvailable = false // Force re-handshake
|
||||||
sm.primarySessionID = toSessionID
|
sm.primarySessionID = toSessionID
|
||||||
sm.lastPrimaryID = toSessionID // Set to new primary so grace period works on refresh
|
|
||||||
|
// ALWAYS set lastPrimaryID to the new primary to support WebRTC reconnections
|
||||||
|
// This allows the newly promoted session to handle page refreshes correctly
|
||||||
|
// The blacklist system prevents unwanted takeovers during manual transfers
|
||||||
|
sm.lastPrimaryID = toSessionID
|
||||||
|
|
||||||
// Clear input state
|
// Clear input state
|
||||||
sm.clearInputState()
|
sm.clearInputState()
|
||||||
|
|
@ -1171,39 +1098,48 @@ 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
|
||||||
|
|
||||||
// First, clear any existing blacklist entries for the newly promoted session
|
if isManualTransfer {
|
||||||
cleanedBlacklist := make([]TransferBlacklistEntry, 0)
|
// First, clear any existing blacklist entries for the newly promoted session
|
||||||
for _, entry := range sm.transferBlacklist {
|
cleanedBlacklist := make([]TransferBlacklistEntry, 0)
|
||||||
if entry.SessionID != toSessionID { // Remove any old blacklist entries for the new primary
|
for _, entry := range sm.transferBlacklist {
|
||||||
cleanedBlacklist = append(cleanedBlacklist, entry)
|
if entry.SessionID != toSessionID { // Remove any old blacklist entries for the new primary
|
||||||
|
cleanedBlacklist = append(cleanedBlacklist, entry)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
sm.transferBlacklist = cleanedBlacklist
|
||||||
sm.transferBlacklist = cleanedBlacklist
|
|
||||||
|
|
||||||
// Then blacklist all other sessions
|
// Then blacklist all other sessions
|
||||||
for sessionID := range sm.sessions {
|
for sessionID := range sm.sessions {
|
||||||
if sessionID != toSessionID { // Don't blacklist the newly promoted session
|
if sessionID != toSessionID { // Don't blacklist the newly promoted session
|
||||||
sm.transferBlacklist = append(sm.transferBlacklist, TransferBlacklistEntry{
|
sm.transferBlacklist = append(sm.transferBlacklist, TransferBlacklistEntry{
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
ExpiresAt: now.Add(blacklistDuration),
|
ExpiresAt: now.Add(blacklistDuration),
|
||||||
})
|
})
|
||||||
blacklistedCount++
|
blacklistedCount++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear all grace periods to prevent conflicts
|
// DON'T clear grace periods during transfers!
|
||||||
if len(sm.reconnectGrace) > 0 || len(sm.reconnectInfo) > 0 {
|
// Grace periods and blacklisting serve different purposes:
|
||||||
for oldSessionID := range sm.reconnectGrace {
|
// - Grace periods: Allow disconnected sessions to reconnect and reclaim their role
|
||||||
delete(sm.reconnectGrace, oldSessionID)
|
// - Blacklisting: Prevent recently demoted sessions from immediately taking primary again
|
||||||
}
|
//
|
||||||
for oldSessionID := range sm.reconnectInfo {
|
// When a primary session is transferred to another session:
|
||||||
delete(sm.reconnectInfo, oldSessionID)
|
// 1. The newly promoted session should be able to refresh its browser without losing primary
|
||||||
}
|
// 2. When it refreshes, RemoveSession is called, which adds a grace period
|
||||||
}
|
// 3. When it reconnects, it should find itself in lastPrimaryID and reclaim primary
|
||||||
|
//
|
||||||
|
// The blacklist prevents the OLD primary from immediately reclaiming control,
|
||||||
|
// while the grace period allows the NEW primary to safely refresh its browser.
|
||||||
|
// These mechanisms complement each other and should not interfere.
|
||||||
|
|
||||||
sm.logger.Info().
|
sm.logger.Info().
|
||||||
Str("fromSessionID", fromSessionID).
|
Str("fromSessionID", fromSessionID).
|
||||||
|
|
@ -1214,8 +1150,9 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
|
||||||
Dur("blacklistDuration", blacklistDuration).
|
Dur("blacklistDuration", blacklistDuration).
|
||||||
Msg("Primary role transferred with bidirectional protection")
|
Msg("Primary role transferred with bidirectional protection")
|
||||||
|
|
||||||
// Validate session consistency after role transfer
|
// DON'T validate here - causes recursive calls and map iteration issues
|
||||||
sm.validateSinglePrimary()
|
// The caller (AddSession, RemoveSession, etc.) will validate after we return
|
||||||
|
// sm.validateSinglePrimary() // REMOVED to prevent recursion
|
||||||
|
|
||||||
// Handle WebRTC connection state for promoted sessions
|
// Handle WebRTC connection state for promoted sessions
|
||||||
// When a session changes from observer to primary, the existing WebRTC connection
|
// When a session changes from observer to primary, the existing WebRTC connection
|
||||||
|
|
@ -1629,22 +1566,31 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
|
||||||
if currentSessionSettings != nil && currentSessionSettings.RequireApproval {
|
if currentSessionSettings != nil && currentSessionSettings.RequireApproval {
|
||||||
isEmergencyPromotion = true
|
isEmergencyPromotion = true
|
||||||
|
|
||||||
// Rate limiting for emergency promotions
|
// CRITICAL: Ensure we ALWAYS have a primary session
|
||||||
if now.Sub(sm.lastEmergencyPromotion) < 30*time.Second {
|
// If there's NO primary, bypass rate limits entirely
|
||||||
sm.logger.Warn().
|
hasPrimary := sm.primarySessionID != ""
|
||||||
Str("expiredSessionID", sessionID).
|
if !hasPrimary {
|
||||||
Dur("timeSinceLastEmergency", now.Sub(sm.lastEmergencyPromotion)).
|
|
||||||
Msg("Emergency promotion rate limit exceeded - potential attack")
|
|
||||||
continue // Skip this grace period expiration
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit consecutive emergency promotions
|
|
||||||
if sm.consecutiveEmergencyPromotions >= 3 {
|
|
||||||
sm.logger.Error().
|
sm.logger.Error().
|
||||||
Str("expiredSessionID", sessionID).
|
Str("expiredSessionID", sessionID).
|
||||||
Int("consecutiveCount", sm.consecutiveEmergencyPromotions).
|
Msg("CRITICAL: No primary session exists - bypassing all rate limits")
|
||||||
Msg("Too many consecutive emergency promotions - blocking for security")
|
} else {
|
||||||
continue // Skip this grace period expiration
|
// Rate limiting for emergency promotions (only when we have a primary)
|
||||||
|
if now.Sub(sm.lastEmergencyPromotion) < 30*time.Second {
|
||||||
|
sm.logger.Warn().
|
||||||
|
Str("expiredSessionID", sessionID).
|
||||||
|
Dur("timeSinceLastEmergency", now.Sub(sm.lastEmergencyPromotion)).
|
||||||
|
Msg("Emergency promotion rate limit exceeded - potential attack")
|
||||||
|
continue // Skip this grace period expiration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit consecutive emergency promotions
|
||||||
|
if sm.consecutiveEmergencyPromotions >= 3 {
|
||||||
|
sm.logger.Error().
|
||||||
|
Str("expiredSessionID", sessionID).
|
||||||
|
Int("consecutiveCount", sm.consecutiveEmergencyPromotions).
|
||||||
|
Msg("Too many consecutive emergency promotions - blocking for security")
|
||||||
|
continue // Skip this grace period expiration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
promotedSessionID = sm.findMostTrustedSessionForEmergency()
|
promotedSessionID = sm.findMostTrustedSessionForEmergency()
|
||||||
|
|
@ -1745,13 +1691,23 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
|
||||||
if currentSessionSettings != nil && currentSessionSettings.RequireApproval {
|
if currentSessionSettings != nil && currentSessionSettings.RequireApproval {
|
||||||
isEmergencyPromotion = true
|
isEmergencyPromotion = true
|
||||||
|
|
||||||
// Rate limiting for emergency promotions
|
// CRITICAL: Ensure we ALWAYS have a primary session
|
||||||
if now.Sub(sm.lastEmergencyPromotion) < 30*time.Second {
|
// primarySessionID was just cleared above, so this will always be empty
|
||||||
sm.logger.Warn().
|
// But check anyway for completeness
|
||||||
|
hasPrimary := sm.primarySessionID != ""
|
||||||
|
if !hasPrimary {
|
||||||
|
sm.logger.Error().
|
||||||
Str("timedOutSessionID", timedOutSessionID).
|
Str("timedOutSessionID", timedOutSessionID).
|
||||||
Dur("timeSinceLastEmergency", now.Sub(sm.lastEmergencyPromotion)).
|
Msg("CRITICAL: No primary session after timeout - bypassing all rate limits")
|
||||||
Msg("Emergency promotion rate limit exceeded during timeout - potential attack")
|
} else {
|
||||||
continue // Skip this timeout
|
// Rate limiting for emergency promotions (only when we have a primary)
|
||||||
|
if now.Sub(sm.lastEmergencyPromotion) < 30*time.Second {
|
||||||
|
sm.logger.Warn().
|
||||||
|
Str("timedOutSessionID", timedOutSessionID).
|
||||||
|
Dur("timeSinceLastEmergency", now.Sub(sm.lastEmergencyPromotion)).
|
||||||
|
Msg("Emergency promotion rate limit exceeded during timeout - potential attack")
|
||||||
|
continue // Skip this timeout
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use trust-based selection but exclude the timed-out session
|
// Use trust-based selection but exclude the timed-out session
|
||||||
|
|
@ -1820,14 +1776,12 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
|
||||||
|
|
||||||
// Run validation immediately if a grace period expired, otherwise run periodically
|
// Run validation immediately if a grace period expired, otherwise run periodically
|
||||||
if gracePeriodExpired {
|
if gracePeriodExpired {
|
||||||
sm.logger.Debug().Msg("Running immediate validation after grace period expiration")
|
|
||||||
sm.validateSinglePrimary()
|
sm.validateSinglePrimary()
|
||||||
} else {
|
} else {
|
||||||
// Periodic validateSinglePrimary to catch deadlock states
|
// Periodic validateSinglePrimary to catch deadlock states
|
||||||
validationCounter++
|
validationCounter++
|
||||||
if validationCounter >= 10 { // Every 10 seconds
|
if validationCounter >= 10 { // Every 10 seconds
|
||||||
validationCounter = 0
|
validationCounter = 0
|
||||||
sm.logger.Debug().Msg("Running periodic session validation to catch deadlock states")
|
|
||||||
sm.validateSinglePrimary()
|
sm.validateSinglePrimary()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1843,7 +1797,16 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global session manager instance
|
// Global session manager instance
|
||||||
var sessionManager = NewSessionManager(websocketLogger)
|
var (
|
||||||
|
sessionManager *SessionManager
|
||||||
|
sessionManagerOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func initSessionManager() {
|
||||||
|
sessionManagerOnce.Do(func() {
|
||||||
|
sessionManager = NewSessionManager(websocketLogger)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Global session settings - references config.SessionSettings for persistence
|
// Global session settings - references config.SessionSettings for persistence
|
||||||
var currentSessionSettings *SessionSettings
|
var currentSessionSettings *SessionSettings
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ import ExtensionPopover from "@/components/popovers/ExtensionPopover";
|
||||||
import SessionPopover from "@/components/popovers/SessionPopover";
|
import SessionPopover from "@/components/popovers/SessionPopover";
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
import { useSessionStore } from "@/stores/sessionStore";
|
import { useSessionStore } from "@/stores/sessionStore";
|
||||||
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
|
|
||||||
export default function Actionbar({
|
export default function Actionbar({
|
||||||
requestFullscreen,
|
requestFullscreen,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ import Container from "@components/Container";
|
||||||
import { useMacrosStore } from "@/hooks/stores";
|
import { useMacrosStore } from "@/hooks/stores";
|
||||||
import useKeyboard from "@/hooks/useKeyboard";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
|
|
||||||
export default function MacroBar() {
|
export default function MacroBar() {
|
||||||
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
|
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import clsx from "clsx";
|
||||||
import { useSessionStore } from "@/stores/sessionStore";
|
import { useSessionStore } from "@/stores/sessionStore";
|
||||||
import { sessionApi } from "@/api/sessionApi";
|
import { sessionApi } from "@/api/sessionApi";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
|
|
||||||
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
|
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import { formatters } from "@/utils";
|
import { formatters } from "@/utils";
|
||||||
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ import {
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useVideoStore,
|
useVideoStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
import useMouse from "@/hooks/useMouse";
|
import useMouse from "@/hooks/useMouse";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
import { PermissionsContextValue } from "@/hooks/usePermissions";
|
||||||
|
|
||||||
|
export const PermissionsContext = createContext<PermissionsContextValue | undefined>(undefined);
|
||||||
|
|
@ -1,169 +1,34 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useContext } from "react";
|
||||||
|
|
||||||
import { useJsonRpc, JsonRpcRequest } from "@/hooks/useJsonRpc";
|
import { PermissionsContext } from "@/contexts/PermissionsContext";
|
||||||
import { useSessionStore } from "@/stores/sessionStore";
|
import { Permission } from "@/types/permissions";
|
||||||
import { useRTCStore } from "@/hooks/stores";
|
|
||||||
|
|
||||||
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
|
export interface PermissionsContextValue {
|
||||||
|
|
||||||
// Permission types matching backend
|
|
||||||
export enum Permission {
|
|
||||||
// Video/Display permissions
|
|
||||||
VIDEO_VIEW = "video.view",
|
|
||||||
|
|
||||||
// Input permissions
|
|
||||||
KEYBOARD_INPUT = "keyboard.input",
|
|
||||||
MOUSE_INPUT = "mouse.input",
|
|
||||||
PASTE = "clipboard.paste",
|
|
||||||
|
|
||||||
// Session management permissions
|
|
||||||
SESSION_TRANSFER = "session.transfer",
|
|
||||||
SESSION_APPROVE = "session.approve",
|
|
||||||
SESSION_KICK = "session.kick",
|
|
||||||
SESSION_REQUEST_PRIMARY = "session.request_primary",
|
|
||||||
SESSION_RELEASE_PRIMARY = "session.release_primary",
|
|
||||||
SESSION_MANAGE = "session.manage",
|
|
||||||
|
|
||||||
// Mount/Media permissions
|
|
||||||
MOUNT_MEDIA = "mount.media",
|
|
||||||
UNMOUNT_MEDIA = "mount.unmedia",
|
|
||||||
MOUNT_LIST = "mount.list",
|
|
||||||
|
|
||||||
// Extension permissions
|
|
||||||
EXTENSION_MANAGE = "extension.manage",
|
|
||||||
EXTENSION_ATX = "extension.atx",
|
|
||||||
EXTENSION_DC = "extension.dc",
|
|
||||||
EXTENSION_SERIAL = "extension.serial",
|
|
||||||
EXTENSION_WOL = "extension.wol",
|
|
||||||
|
|
||||||
// Settings permissions
|
|
||||||
SETTINGS_READ = "settings.read",
|
|
||||||
SETTINGS_WRITE = "settings.write",
|
|
||||||
SETTINGS_ACCESS = "settings.access",
|
|
||||||
|
|
||||||
// System permissions
|
|
||||||
SYSTEM_REBOOT = "system.reboot",
|
|
||||||
SYSTEM_UPDATE = "system.update",
|
|
||||||
SYSTEM_NETWORK = "system.network",
|
|
||||||
|
|
||||||
// Power/USB control permissions
|
|
||||||
POWER_CONTROL = "power.control",
|
|
||||||
USB_CONTROL = "usb.control",
|
|
||||||
|
|
||||||
// Terminal/Serial permissions
|
|
||||||
TERMINAL_ACCESS = "terminal.access",
|
|
||||||
SERIAL_ACCESS = "serial.access",
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PermissionsResponse {
|
|
||||||
mode: string;
|
|
||||||
permissions: Record<string, boolean>;
|
permissions: Record<string, boolean>;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasPermission: (permission: Permission) => boolean;
|
||||||
|
hasAnyPermission: (...perms: Permission[]) => boolean;
|
||||||
|
hasAllPermissions: (...perms: Permission[]) => boolean;
|
||||||
|
isPrimary: () => boolean;
|
||||||
|
isObserver: () => boolean;
|
||||||
|
isPending: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePermissions() {
|
export function usePermissions(): PermissionsContextValue {
|
||||||
const { currentMode } = useSessionStore();
|
const context = useContext(PermissionsContext);
|
||||||
const { setRpcHidProtocolVersion, rpcHidChannel } = useRTCStore();
|
|
||||||
const [permissions, setPermissions] = useState<Record<string, boolean>>({});
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const previousCanControl = useRef<boolean>(false);
|
|
||||||
|
|
||||||
// Function to poll permissions
|
if (context === undefined) {
|
||||||
const pollPermissions = useCallback((send: RpcSendFunction) => {
|
return {
|
||||||
if (!send) return;
|
permissions: {},
|
||||||
|
isLoading: true,
|
||||||
|
hasPermission: () => false,
|
||||||
|
hasAnyPermission: () => false,
|
||||||
|
hasAllPermissions: () => false,
|
||||||
|
isPrimary: () => false,
|
||||||
|
isObserver: () => false,
|
||||||
|
isPending: () => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
return context;
|
||||||
send("getPermissions", {}, (response: { result?: unknown; error?: { message: string } }) => {
|
|
||||||
if (!response.error && response.result) {
|
|
||||||
const result = response.result as PermissionsResponse;
|
|
||||||
setPermissions(result.permissions);
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle connectionModeChanged events that require WebRTC reconnection
|
|
||||||
const handleRpcRequest = useCallback((request: JsonRpcRequest) => {
|
|
||||||
if (request.method === "connectionModeChanged") {
|
|
||||||
console.info("Connection mode changed, WebRTC reconnection required", request.params);
|
|
||||||
|
|
||||||
// For session promotion that requires reconnection, refresh the page
|
|
||||||
// This ensures WebRTC connection is re-established with proper mode
|
|
||||||
const params = request.params as { action?: string; reason?: string };
|
|
||||||
if (params.action === "reconnect_required" && params.reason === "session_promotion") {
|
|
||||||
console.info("Session promoted, refreshing page to re-establish WebRTC connection");
|
|
||||||
// Small delay to ensure all state updates are processed
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { send } = useJsonRpc(handleRpcRequest);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
pollPermissions(send);
|
|
||||||
}, [send, currentMode, pollPermissions]);
|
|
||||||
|
|
||||||
// Monitor permission changes and re-initialize HID when gaining control
|
|
||||||
useEffect(() => {
|
|
||||||
const currentCanControl = hasPermission(Permission.KEYBOARD_INPUT) && hasPermission(Permission.MOUSE_INPUT);
|
|
||||||
const hadControl = previousCanControl.current;
|
|
||||||
|
|
||||||
// If we just gained control permissions, re-initialize HID
|
|
||||||
if (currentCanControl && !hadControl && rpcHidChannel?.readyState === "open") {
|
|
||||||
console.info("Gained control permissions, re-initializing HID");
|
|
||||||
|
|
||||||
// Reset protocol version to force re-handshake
|
|
||||||
setRpcHidProtocolVersion(null);
|
|
||||||
|
|
||||||
// Import handshake functionality dynamically
|
|
||||||
import("./hidRpc").then(({ HID_RPC_VERSION, HandshakeMessage }) => {
|
|
||||||
// Send handshake after a small delay
|
|
||||||
setTimeout(() => {
|
|
||||||
if (rpcHidChannel?.readyState === "open") {
|
|
||||||
const handshakeMessage = new HandshakeMessage(HID_RPC_VERSION);
|
|
||||||
try {
|
|
||||||
const data = handshakeMessage.marshal();
|
|
||||||
rpcHidChannel.send(data as unknown as ArrayBuffer);
|
|
||||||
console.info("Sent HID handshake after permission change");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to send HID handshake", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
previousCanControl.current = currentCanControl;
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [permissions, rpcHidChannel, setRpcHidProtocolVersion]); // hasPermission depends on permissions which is already in deps
|
|
||||||
|
|
||||||
const hasPermission = (permission: Permission): boolean => {
|
|
||||||
return permissions[permission] === true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasAnyPermission = (...perms: Permission[]): boolean => {
|
|
||||||
return perms.some(perm => hasPermission(perm));
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasAllPermissions = (...perms: Permission[]): boolean => {
|
|
||||||
return perms.every(perm => hasPermission(perm));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Session mode helpers
|
|
||||||
const isPrimary = () => currentMode === "primary";
|
|
||||||
const isObserver = () => currentMode === "observer";
|
|
||||||
const isPending = () => currentMode === "pending";
|
|
||||||
|
|
||||||
return {
|
|
||||||
permissions,
|
|
||||||
isLoading,
|
|
||||||
hasPermission,
|
|
||||||
hasAnyPermission,
|
|
||||||
hasAllPermissions,
|
|
||||||
isPrimary,
|
|
||||||
isObserver,
|
|
||||||
isPending,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
@ -16,6 +16,10 @@ interface ModeChangedData {
|
||||||
mode: string;
|
mode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ConnectionModeChangedData {
|
||||||
|
newMode: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function useSessionEvents(sendFn: RpcSendFunction | null) {
|
export function useSessionEvents(sendFn: RpcSendFunction | null) {
|
||||||
const {
|
const {
|
||||||
currentMode,
|
currentMode,
|
||||||
|
|
@ -27,7 +31,6 @@ export function useSessionEvents(sendFn: RpcSendFunction | null) {
|
||||||
const sendFnRef = useRef(sendFn);
|
const sendFnRef = useRef(sendFn);
|
||||||
sendFnRef.current = sendFn;
|
sendFnRef.current = sendFn;
|
||||||
|
|
||||||
// Handle session-related RPC events
|
|
||||||
const handleSessionEvent = (method: string, params: unknown) => {
|
const handleSessionEvent = (method: string, params: unknown) => {
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case "sessionsUpdated":
|
case "sessionsUpdated":
|
||||||
|
|
@ -36,6 +39,9 @@ export function useSessionEvents(sendFn: RpcSendFunction | null) {
|
||||||
case "modeChanged":
|
case "modeChanged":
|
||||||
handleModeChanged(params as ModeChangedData);
|
handleModeChanged(params as ModeChangedData);
|
||||||
break;
|
break;
|
||||||
|
case "connectionModeChanged":
|
||||||
|
handleConnectionModeChanged(params as ConnectionModeChangedData);
|
||||||
|
break;
|
||||||
case "hidReadyForPrimary":
|
case "hidReadyForPrimary":
|
||||||
handleHidReadyForPrimary();
|
handleHidReadyForPrimary();
|
||||||
break;
|
break;
|
||||||
|
|
@ -103,23 +109,25 @@ export function useSessionEvents(sendFn: RpcSendFunction | null) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConnectionModeChanged = (data: ConnectionModeChangedData) => {
|
||||||
|
if (data.newMode) {
|
||||||
|
handleModeChanged({ mode: data.newMode });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleHidReadyForPrimary = () => {
|
const handleHidReadyForPrimary = () => {
|
||||||
// Backend signals that HID system is ready for primary session re-initialization
|
|
||||||
const { rpcHidChannel } = useRTCStore.getState();
|
const { rpcHidChannel } = useRTCStore.getState();
|
||||||
if (rpcHidChannel?.readyState === "open") {
|
if (rpcHidChannel?.readyState === "open") {
|
||||||
// Trigger HID re-handshake
|
|
||||||
rpcHidChannel.dispatchEvent(new Event("open"));
|
rpcHidChannel.dispatchEvent(new Event("open"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOtherSessionConnected = () => {
|
const handleOtherSessionConnected = () => {
|
||||||
// Another session is trying to connect
|
|
||||||
notify.warning("Another session is connecting", {
|
notify.warning("Another session is connecting", {
|
||||||
duration: 5000
|
duration: 5000
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch initial sessions when component mounts
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sendFnRef.current) return;
|
if (!sendFnRef.current) return;
|
||||||
|
|
||||||
|
|
@ -136,7 +144,6 @@ export function useSessionEvents(sendFn: RpcSendFunction | null) {
|
||||||
fetchSessions();
|
fetchSessions();
|
||||||
}, [setSessions, setSessionError]);
|
}, [setSessions, setSessionError]);
|
||||||
|
|
||||||
// Set up periodic session refresh
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sendFnRef.current) return;
|
if (!sendFnRef.current) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ import { useEffect, useCallback, useState } from "react";
|
||||||
import { useSessionStore } from "@/stores/sessionStore";
|
import { useSessionStore } from "@/stores/sessionStore";
|
||||||
import { useSessionEvents } from "@/hooks/useSessionEvents";
|
import { useSessionEvents } from "@/hooks/useSessionEvents";
|
||||||
import { useSettingsStore } from "@/hooks/stores";
|
import { useSettingsStore } from "@/hooks/stores";
|
||||||
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
|
|
||||||
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
|
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
|
||||||
|
|
||||||
|
|
@ -32,21 +33,19 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
|
||||||
clearSession
|
clearSession
|
||||||
} = useSessionStore();
|
} = useSessionStore();
|
||||||
|
|
||||||
const { hasPermission } = usePermissions();
|
const { hasPermission, isLoading: isLoadingPermissions } = usePermissions();
|
||||||
|
|
||||||
const { requireSessionApproval } = useSettingsStore();
|
const { requireSessionApproval } = useSettingsStore();
|
||||||
const { handleSessionEvent } = useSessionEvents(sendFn);
|
const { handleSessionEvent } = useSessionEvents(sendFn);
|
||||||
const [primaryControlRequest, setPrimaryControlRequest] = useState<PrimaryControlRequest | null>(null);
|
const [primaryControlRequest, setPrimaryControlRequest] = useState<PrimaryControlRequest | null>(null);
|
||||||
const [newSessionRequest, setNewSessionRequest] = useState<NewSessionRequest | null>(null);
|
const [newSessionRequest, setNewSessionRequest] = useState<NewSessionRequest | null>(null);
|
||||||
|
|
||||||
// Handle session info from WebRTC answer
|
|
||||||
const handleSessionResponse = useCallback((response: SessionResponse) => {
|
const handleSessionResponse = useCallback((response: SessionResponse) => {
|
||||||
if (response.sessionId && response.mode) {
|
if (response.sessionId && response.mode) {
|
||||||
setCurrentSession(response.sessionId, response.mode as "primary" | "observer" | "queued" | "pending");
|
setCurrentSession(response.sessionId, response.mode as "primary" | "observer" | "queued" | "pending");
|
||||||
}
|
}
|
||||||
}, [setCurrentSession]);
|
}, [setCurrentSession]);
|
||||||
|
|
||||||
// Handle approval of primary control request
|
|
||||||
const handleApprovePrimaryRequest = useCallback(async (requestId: string) => {
|
const handleApprovePrimaryRequest = useCallback(async (requestId: string) => {
|
||||||
if (!sendFn) return;
|
if (!sendFn) return;
|
||||||
|
|
||||||
|
|
@ -63,7 +62,6 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
|
||||||
});
|
});
|
||||||
}, [sendFn]);
|
}, [sendFn]);
|
||||||
|
|
||||||
// Handle denial of primary control request
|
|
||||||
const handleDenyPrimaryRequest = useCallback(async (requestId: string) => {
|
const handleDenyPrimaryRequest = useCallback(async (requestId: string) => {
|
||||||
if (!sendFn) return;
|
if (!sendFn) return;
|
||||||
|
|
||||||
|
|
@ -80,7 +78,6 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
|
||||||
});
|
});
|
||||||
}, [sendFn]);
|
}, [sendFn]);
|
||||||
|
|
||||||
// Handle approval of new session
|
|
||||||
const handleApproveNewSession = useCallback(async (sessionId: string) => {
|
const handleApproveNewSession = useCallback(async (sessionId: string) => {
|
||||||
if (!sendFn) return;
|
if (!sendFn) return;
|
||||||
|
|
||||||
|
|
@ -97,7 +94,6 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
|
||||||
});
|
});
|
||||||
}, [sendFn]);
|
}, [sendFn]);
|
||||||
|
|
||||||
// Handle denial of new session
|
|
||||||
const handleDenyNewSession = useCallback(async (sessionId: string) => {
|
const handleDenyNewSession = useCallback(async (sessionId: string) => {
|
||||||
if (!sendFn) return;
|
if (!sendFn) return;
|
||||||
|
|
||||||
|
|
@ -114,34 +110,30 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
|
||||||
});
|
});
|
||||||
}, [sendFn]);
|
}, [sendFn]);
|
||||||
|
|
||||||
// Handle RPC events
|
|
||||||
const handleRpcEvent = useCallback((method: string, params: unknown) => {
|
const handleRpcEvent = useCallback((method: string, params: unknown) => {
|
||||||
// Pass session events to the session event handler
|
|
||||||
if (method === "sessionsUpdated" ||
|
if (method === "sessionsUpdated" ||
|
||||||
method === "modeChanged" ||
|
method === "modeChanged" ||
|
||||||
|
method === "connectionModeChanged" ||
|
||||||
method === "otherSessionConnected") {
|
method === "otherSessionConnected") {
|
||||||
handleSessionEvent(method, params);
|
handleSessionEvent(method, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle new session approval request (only if approval is required and user has permission)
|
if (method === "newSessionPending" && requireSessionApproval) {
|
||||||
if (method === "newSessionPending" && requireSessionApproval && hasPermission(Permission.SESSION_APPROVE)) {
|
if (isLoadingPermissions || hasPermission(Permission.SESSION_APPROVE)) {
|
||||||
setNewSessionRequest(params as NewSessionRequest);
|
setNewSessionRequest(params as NewSessionRequest);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle primary control request
|
|
||||||
if (method === "primaryControlRequested") {
|
if (method === "primaryControlRequested") {
|
||||||
setPrimaryControlRequest(params as PrimaryControlRequest);
|
setPrimaryControlRequest(params as PrimaryControlRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle approval/denial responses
|
|
||||||
if (method === "primaryControlApproved") {
|
if (method === "primaryControlApproved") {
|
||||||
// Clear requesting state in store
|
|
||||||
const { setRequestingPrimary } = useSessionStore.getState();
|
const { setRequestingPrimary } = useSessionStore.getState();
|
||||||
setRequestingPrimary(false);
|
setRequestingPrimary(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === "primaryControlDenied") {
|
if (method === "primaryControlDenied") {
|
||||||
// Clear requesting state and show error
|
|
||||||
const { setRequestingPrimary, setSessionError } = useSessionStore.getState();
|
const { setRequestingPrimary, setSessionError } = useSessionStore.getState();
|
||||||
setRequestingPrimary(false);
|
setRequestingPrimary(false);
|
||||||
setSessionError("Your primary control request was denied");
|
setSessionError("Your primary control request was denied");
|
||||||
|
|
@ -152,9 +144,14 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
|
||||||
const errorParams = params as { message?: string };
|
const errorParams = params as { message?: string };
|
||||||
setSessionError(errorParams.message || "Session access was denied by the primary session");
|
setSessionError(errorParams.message || "Session access was denied by the primary session");
|
||||||
}
|
}
|
||||||
}, [handleSessionEvent, hasPermission, requireSessionApproval]);
|
}, [handleSessionEvent, hasPermission, isLoadingPermissions, requireSessionApproval]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoadingPermissions && newSessionRequest && !hasPermission(Permission.SESSION_APPROVE)) {
|
||||||
|
setNewSessionRequest(null);
|
||||||
|
}
|
||||||
|
}, [isLoadingPermissions, hasPermission, newSessionRequest]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
clearSession();
|
clearSession();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { useState, useEffect, useRef, useCallback, ReactNode } from "react";
|
||||||
|
|
||||||
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import { useSessionStore } from "@/stores/sessionStore";
|
||||||
|
import { useRTCStore } from "@/hooks/stores";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
|
import { PermissionsContextValue } from "@/hooks/usePermissions";
|
||||||
|
import { PermissionsContext } from "@/contexts/PermissionsContext";
|
||||||
|
|
||||||
|
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
|
||||||
|
|
||||||
|
interface PermissionsResponse {
|
||||||
|
mode: string;
|
||||||
|
permissions: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermissionsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { currentMode } = useSessionStore();
|
||||||
|
const { setRpcHidProtocolVersion, rpcHidChannel, rpcDataChannel } = useRTCStore();
|
||||||
|
const [permissions, setPermissions] = useState<Record<string, boolean>>({});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const previousCanControl = useRef<boolean>(false);
|
||||||
|
|
||||||
|
const pollPermissions = useCallback((send: RpcSendFunction) => {
|
||||||
|
if (!send) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
send("getPermissions", {}, (response: { result?: unknown; error?: { message: string } }) => {
|
||||||
|
if (!response.error && response.result) {
|
||||||
|
const result = response.result as PermissionsResponse;
|
||||||
|
setPermissions(result.permissions);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rpcDataChannel?.readyState !== "open") return;
|
||||||
|
pollPermissions(send);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentMode, rpcDataChannel?.readyState]);
|
||||||
|
|
||||||
|
const hasPermission = useCallback((permission: Permission): boolean => {
|
||||||
|
return permissions[permission] === true;
|
||||||
|
}, [permissions]);
|
||||||
|
|
||||||
|
const hasAnyPermission = useCallback((...perms: Permission[]): boolean => {
|
||||||
|
return perms.some(perm => hasPermission(perm));
|
||||||
|
}, [hasPermission]);
|
||||||
|
|
||||||
|
const hasAllPermissions = useCallback((...perms: Permission[]): boolean => {
|
||||||
|
return perms.every(perm => hasPermission(perm));
|
||||||
|
}, [hasPermission]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentCanControl = hasPermission(Permission.KEYBOARD_INPUT) && hasPermission(Permission.MOUSE_INPUT);
|
||||||
|
const hadControl = previousCanControl.current;
|
||||||
|
|
||||||
|
if (currentCanControl && !hadControl && rpcHidChannel?.readyState === "open") {
|
||||||
|
console.info("Gained control permissions, re-initializing HID");
|
||||||
|
|
||||||
|
setRpcHidProtocolVersion(null);
|
||||||
|
|
||||||
|
import("@/hooks/hidRpc").then(({ HID_RPC_VERSION, HandshakeMessage }) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (rpcHidChannel?.readyState === "open") {
|
||||||
|
const handshakeMessage = new HandshakeMessage(HID_RPC_VERSION);
|
||||||
|
try {
|
||||||
|
const data = handshakeMessage.marshal();
|
||||||
|
rpcHidChannel.send(data as unknown as ArrayBuffer);
|
||||||
|
console.info("Sent HID handshake after permission change");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to send HID handshake", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
previousCanControl.current = currentCanControl;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [permissions, rpcHidChannel, setRpcHidProtocolVersion]);
|
||||||
|
|
||||||
|
const isPrimary = useCallback(() => currentMode === "primary", [currentMode]);
|
||||||
|
const isObserver = useCallback(() => currentMode === "observer", [currentMode]);
|
||||||
|
const isPending = useCallback(() => currentMode === "pending", [currentMode]);
|
||||||
|
|
||||||
|
const value: PermissionsContextValue = {
|
||||||
|
permissions,
|
||||||
|
isLoading,
|
||||||
|
hasPermission,
|
||||||
|
hasAnyPermission,
|
||||||
|
hasAllPermissions,
|
||||||
|
isPrimary,
|
||||||
|
isObserver,
|
||||||
|
isPending,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PermissionsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</PermissionsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,8 @@ import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
|
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
|
||||||
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
|
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
import { UsbInfoSetting } from "../components/UsbInfoSetting";
|
import { UsbInfoSetting } from "../components/UsbInfoSetting";
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import {
|
||||||
} from "@heroicons/react/16/solid";
|
} from "@heroicons/react/16/solid";
|
||||||
|
|
||||||
import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc";
|
import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc";
|
||||||
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
import { useSettingsStore } from "@/hooks/stores";
|
import { useSettingsStore } from "@/hooks/stores";
|
||||||
import { notify } from "@/notifications";
|
import { notify } from "@/notifications";
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ import { LinkButton } from "@components/Button";
|
||||||
import { FeatureFlag } from "@components/FeatureFlag";
|
import { FeatureFlag } from "@components/FeatureFlag";
|
||||||
import { useUiStore } from "@/hooks/stores";
|
import { useUiStore } from "@/hooks/stores";
|
||||||
import { useSessionStore } from "@/stores/sessionStore";
|
import { useSessionStore } from "@/stores/sessionStore";
|
||||||
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
|
|
||||||
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
||||||
export default function SettingsRoute() {
|
export default function SettingsRoute() {
|
||||||
|
|
@ -34,7 +35,7 @@ export default function SettingsRoute() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !permissions[Permission.SETTINGS_ACCESS] && currentMode !== null) {
|
if (!isLoading && !permissions[Permission.SETTINGS_ACCESS] && currentMode !== null) {
|
||||||
navigate("/devices/local", { replace: true });
|
navigate("/", { replace: true });
|
||||||
}
|
}
|
||||||
}, [permissions, isLoading, currentMode, navigate]);
|
}, [permissions, isLoading, currentMode, navigate]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import useWebSocket from "react-use-websocket";
|
||||||
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
||||||
import api from "@/api";
|
import api from "@/api";
|
||||||
import { checkAuth, isInCloud, isOnDevice } from "@/main";
|
import { checkAuth, isInCloud, isOnDevice } from "@/main";
|
||||||
import { usePermissions, Permission } from "@/hooks/usePermissions";
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import {
|
import {
|
||||||
KeyboardLedState,
|
KeyboardLedState,
|
||||||
|
|
@ -54,6 +53,9 @@ import {
|
||||||
} from "@/components/VideoOverlay";
|
} from "@/components/VideoOverlay";
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
|
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
|
||||||
|
import { PermissionsProvider } from "@/providers/PermissionsProvider";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Permission } from "@/types/permissions";
|
||||||
import { DeviceStatus } from "@routes/welcome-local";
|
import { DeviceStatus } from "@routes/welcome-local";
|
||||||
import { useVersion } from "@/hooks/useVersion";
|
import { useVersion } from "@/hooks/useVersion";
|
||||||
import { useSessionManagement } from "@/hooks/useSessionManagement";
|
import { useSessionManagement } from "@/hooks/useSessionManagement";
|
||||||
|
|
@ -159,7 +161,6 @@ export default function KvmIdRoute() {
|
||||||
const { nickname, setNickname } = useSharedSessionStore();
|
const { nickname, setNickname } = useSharedSessionStore();
|
||||||
const { setRequireSessionApproval, setRequireSessionNickname } = useSettingsStore();
|
const { setRequireSessionApproval, setRequireSessionNickname } = useSettingsStore();
|
||||||
const [globalSessionSettings, setGlobalSessionSettings] = useState<{requireApproval: boolean, requireNickname: boolean} | null>(null);
|
const [globalSessionSettings, setGlobalSessionSettings] = useState<{requireApproval: boolean, requireNickname: boolean} | null>(null);
|
||||||
const { hasPermission } = usePermissions();
|
|
||||||
|
|
||||||
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
|
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
|
||||||
const cleanupAndStopReconnecting = useCallback(
|
const cleanupAndStopReconnecting = useCallback(
|
||||||
|
|
@ -549,44 +550,6 @@ export default function KvmIdRoute() {
|
||||||
const rpcDataChannel = pc.createDataChannel("rpc");
|
const rpcDataChannel = pc.createDataChannel("rpc");
|
||||||
rpcDataChannel.onopen = () => {
|
rpcDataChannel.onopen = () => {
|
||||||
setRpcDataChannel(rpcDataChannel);
|
setRpcDataChannel(rpcDataChannel);
|
||||||
|
|
||||||
// Fetch global session settings
|
|
||||||
const fetchSettings = () => {
|
|
||||||
// Only fetch settings if user has permission to read settings
|
|
||||||
if (!hasPermission(Permission.SETTINGS_READ)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = Math.random().toString(36).substring(2);
|
|
||||||
const message = JSON.stringify({ jsonrpc: "2.0", method: "getSessionSettings", params: {}, id });
|
|
||||||
|
|
||||||
const handler = (event: MessageEvent) => {
|
|
||||||
try {
|
|
||||||
const response = JSON.parse(event.data);
|
|
||||||
if (response.id === id) {
|
|
||||||
rpcDataChannel.removeEventListener("message", handler);
|
|
||||||
if (response.result) {
|
|
||||||
setGlobalSessionSettings(response.result);
|
|
||||||
// Also update the settings store for approval handling
|
|
||||||
setRequireSessionApproval(response.result.requireApproval);
|
|
||||||
setRequireSessionNickname(response.result.requireNickname);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore parse errors
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
rpcDataChannel.addEventListener("message", handler);
|
|
||||||
rpcDataChannel.send(message);
|
|
||||||
|
|
||||||
// Clean up after timeout
|
|
||||||
setTimeout(() => {
|
|
||||||
rpcDataChannel.removeEventListener("message", handler);
|
|
||||||
}, 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchSettings();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const rpcHidChannel = pc.createDataChannel("hidrpc");
|
const rpcHidChannel = pc.createDataChannel("hidrpc");
|
||||||
|
|
@ -627,9 +590,6 @@ export default function KvmIdRoute() {
|
||||||
setRpcHidUnreliableNonOrderedChannel,
|
setRpcHidUnreliableNonOrderedChannel,
|
||||||
setRpcHidUnreliableChannel,
|
setRpcHidUnreliableChannel,
|
||||||
setTransceiver,
|
setTransceiver,
|
||||||
hasPermission,
|
|
||||||
setRequireSessionApproval,
|
|
||||||
setRequireSessionNickname,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -722,6 +682,7 @@ export default function KvmIdRoute() {
|
||||||
// Handle session-related events
|
// Handle session-related events
|
||||||
if (resp.method === "sessionsUpdated" ||
|
if (resp.method === "sessionsUpdated" ||
|
||||||
resp.method === "modeChanged" ||
|
resp.method === "modeChanged" ||
|
||||||
|
resp.method === "connectionModeChanged" ||
|
||||||
resp.method === "otherSessionConnected" ||
|
resp.method === "otherSessionConnected" ||
|
||||||
resp.method === "primaryControlRequested" ||
|
resp.method === "primaryControlRequested" ||
|
||||||
resp.method === "primaryControlApproved" ||
|
resp.method === "primaryControlApproved" ||
|
||||||
|
|
@ -735,7 +696,6 @@ export default function KvmIdRoute() {
|
||||||
setAccessDenied(true);
|
setAccessDenied(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep legacy behavior for otherSessionConnected
|
|
||||||
if (resp.method === "otherSessionConnected") {
|
if (resp.method === "otherSessionConnected") {
|
||||||
navigateTo("/other-session");
|
navigateTo("/other-session");
|
||||||
}
|
}
|
||||||
|
|
@ -805,21 +765,25 @@ export default function KvmIdRoute() {
|
||||||
closeNewSessionRequest
|
closeNewSessionRequest
|
||||||
} = useSessionManagement(send);
|
} = useSessionManagement(send);
|
||||||
|
|
||||||
|
const { hasPermission, isLoading: isLoadingPermissions } = usePermissions();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rpcDataChannel?.readyState !== "open") return;
|
if (rpcDataChannel?.readyState !== "open") return;
|
||||||
|
if (isLoadingPermissions || !hasPermission(Permission.VIDEO_VIEW)) return;
|
||||||
|
|
||||||
send("getVideoState", {}, (resp: JsonRpcResponse) => {
|
send("getVideoState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) return;
|
if ("error" in resp) return;
|
||||||
const hdmiState = resp.result as Parameters<VideoState["setHdmiState"]>[0];
|
const hdmiState = resp.result as Parameters<VideoState["setHdmiState"]>[0];
|
||||||
setHdmiState(hdmiState);
|
setHdmiState(hdmiState);
|
||||||
});
|
});
|
||||||
}, [rpcDataChannel?.readyState, send, setHdmiState]);
|
}, [rpcDataChannel?.readyState, hasPermission, isLoadingPermissions, send, setHdmiState]);
|
||||||
|
|
||||||
const [needLedState, setNeedLedState] = useState(true);
|
const [needLedState, setNeedLedState] = useState(true);
|
||||||
|
|
||||||
// request keyboard led state from the device
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rpcDataChannel?.readyState !== "open") return;
|
if (rpcDataChannel?.readyState !== "open") return;
|
||||||
if (!needLedState) return;
|
if (!needLedState) return;
|
||||||
|
if (isLoadingPermissions || !hasPermission(Permission.KEYBOARD_INPUT)) return;
|
||||||
|
|
||||||
send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => {
|
send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
|
|
@ -831,20 +795,18 @@ export default function KvmIdRoute() {
|
||||||
}
|
}
|
||||||
setNeedLedState(false);
|
setNeedLedState(false);
|
||||||
});
|
});
|
||||||
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState]);
|
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState, hasPermission, isLoadingPermissions]);
|
||||||
|
|
||||||
const [needKeyDownState, setNeedKeyDownState] = useState(true);
|
const [needKeyDownState, setNeedKeyDownState] = useState(true);
|
||||||
|
|
||||||
// request keyboard key down state from the device
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rpcDataChannel?.readyState !== "open") return;
|
if (rpcDataChannel?.readyState !== "open") return;
|
||||||
if (!needKeyDownState) return;
|
if (!needKeyDownState) return;
|
||||||
|
if (isLoadingPermissions || !hasPermission(Permission.KEYBOARD_INPUT)) return;
|
||||||
|
|
||||||
send("getKeyDownState", {}, (resp: JsonRpcResponse) => {
|
send("getKeyDownState", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
// -32601 means the method is not supported
|
|
||||||
if (resp.error.code === RpcMethodNotFound) {
|
if (resp.error.code === RpcMethodNotFound) {
|
||||||
// if we don't support key down state, we know key press is also not available
|
|
||||||
console.warn("Failed to get key down state, switching to old-school", resp.error);
|
console.warn("Failed to get key down state, switching to old-school", resp.error);
|
||||||
setHidRpcDisabled(true);
|
setHidRpcDisabled(true);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -856,7 +818,7 @@ export default function KvmIdRoute() {
|
||||||
}
|
}
|
||||||
setNeedKeyDownState(false);
|
setNeedKeyDownState(false);
|
||||||
});
|
});
|
||||||
}, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeysDownState, setHidRpcDisabled]);
|
}, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeysDownState, setHidRpcDisabled, hasPermission, isLoadingPermissions]);
|
||||||
|
|
||||||
// When the update is successful, we need to refresh the client javascript and show a success modal
|
// When the update is successful, we need to refresh the client javascript and show a success modal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -889,10 +851,10 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (appVersion) return;
|
if (appVersion) return;
|
||||||
if (!hasPermission(Permission.VIDEO_VIEW)) return;
|
|
||||||
|
|
||||||
getLocalVersion();
|
getLocalVersion();
|
||||||
}, [appVersion, getLocalVersion, hasPermission]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [appVersion]);
|
||||||
|
|
||||||
const ConnectionStatusElement = useMemo(() => {
|
const ConnectionStatusElement = useMemo(() => {
|
||||||
const hasConnectionFailed =
|
const hasConnectionFailed =
|
||||||
|
|
@ -932,8 +894,9 @@ export default function KvmIdRoute() {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FeatureFlagProvider appVersion={appVersion}>
|
<PermissionsProvider>
|
||||||
{!outlet && otaState.updating && (
|
<FeatureFlagProvider appVersion={appVersion}>
|
||||||
|
{!outlet && otaState.updating && (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="pointer-events-none fixed inset-0 top-16 z-10 mx-auto flex h-full w-full max-w-xl translate-y-8 items-start justify-center"
|
className="pointer-events-none fixed inset-0 top-16 z-10 mx-auto flex h-full w-full max-w-xl translate-y-8 items-start justify-center"
|
||||||
|
|
@ -1106,7 +1069,8 @@ export default function KvmIdRoute() {
|
||||||
<PendingApprovalOverlay
|
<PendingApprovalOverlay
|
||||||
show={currentMode === "pending"}
|
show={currentMode === "pending"}
|
||||||
/>
|
/>
|
||||||
</FeatureFlagProvider>
|
</FeatureFlagProvider>
|
||||||
|
</PermissionsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
export enum Permission {
|
||||||
|
VIDEO_VIEW = "video.view",
|
||||||
|
KEYBOARD_INPUT = "keyboard.input",
|
||||||
|
MOUSE_INPUT = "mouse.input",
|
||||||
|
PASTE = "clipboard.paste",
|
||||||
|
SESSION_TRANSFER = "session.transfer",
|
||||||
|
SESSION_APPROVE = "session.approve",
|
||||||
|
SESSION_KICK = "session.kick",
|
||||||
|
SESSION_REQUEST_PRIMARY = "session.request_primary",
|
||||||
|
SESSION_RELEASE_PRIMARY = "session.release_primary",
|
||||||
|
SESSION_MANAGE = "session.manage",
|
||||||
|
MOUNT_MEDIA = "mount.media",
|
||||||
|
UNMOUNT_MEDIA = "mount.unmedia",
|
||||||
|
MOUNT_LIST = "mount.list",
|
||||||
|
EXTENSION_MANAGE = "extension.manage",
|
||||||
|
EXTENSION_ATX = "extension.atx",
|
||||||
|
EXTENSION_DC = "extension.dc",
|
||||||
|
EXTENSION_SERIAL = "extension.serial",
|
||||||
|
EXTENSION_WOL = "extension.wol",
|
||||||
|
SETTINGS_READ = "settings.read",
|
||||||
|
SETTINGS_WRITE = "settings.write",
|
||||||
|
SETTINGS_ACCESS = "settings.access",
|
||||||
|
SYSTEM_REBOOT = "system.reboot",
|
||||||
|
SYSTEM_UPDATE = "system.update",
|
||||||
|
SYSTEM_NETWORK = "system.network",
|
||||||
|
POWER_CONTROL = "power.control",
|
||||||
|
USB_CONTROL = "usb.control",
|
||||||
|
TERMINAL_ACCESS = "terminal.access",
|
||||||
|
SERIAL_ACCESS = "serial.access",
|
||||||
|
}
|
||||||
10
webrtc.go
10
webrtc.go
|
|
@ -78,14 +78,6 @@ func incrActiveSessions() int {
|
||||||
return actionSessions
|
return actionSessions
|
||||||
}
|
}
|
||||||
|
|
||||||
func decrActiveSessions() int {
|
|
||||||
activeSessionsMutex.Lock()
|
|
||||||
defer activeSessionsMutex.Unlock()
|
|
||||||
|
|
||||||
actionSessions--
|
|
||||||
return actionSessions
|
|
||||||
}
|
|
||||||
|
|
||||||
func getActiveSessions() int {
|
func getActiveSessions() int {
|
||||||
activeSessionsMutex.Lock()
|
activeSessionsMutex.Lock()
|
||||||
defer activeSessionsMutex.Unlock()
|
defer activeSessionsMutex.Unlock()
|
||||||
|
|
@ -96,7 +88,7 @@ func getActiveSessions() int {
|
||||||
// CheckRPCRateLimit checks if the session has exceeded RPC rate limits (DoS protection)
|
// CheckRPCRateLimit checks if the session has exceeded RPC rate limits (DoS protection)
|
||||||
func (s *Session) CheckRPCRateLimit() bool {
|
func (s *Session) CheckRPCRateLimit() bool {
|
||||||
const (
|
const (
|
||||||
maxRPCPerSecond = 100 // Increased from 20 to accommodate multi-session polling and reconnections
|
maxRPCPerSecond = 500 // Increased to support 10+ concurrent sessions with broadcasts and state updates
|
||||||
rateLimitWindow = time.Second
|
rateLimitWindow = time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue