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