Compare commits

..

No commits in common. "16509188b0becc255bd9a00de9795f76337e8a42" and "8dbd98b4f034f6cd9c705f436e6fc7395feebd6c" have entirely different histories.

20 changed files with 525 additions and 473 deletions

View File

@ -512,8 +512,12 @@ 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 {
@ -523,6 +527,7 @@ func handleSessionRequest(
}
return err
}
scopedLogger.Debug().Msg("AddSession completed successfully, continuing")
if session.HasPermission(PermissionPaste) {
cancelKeyboardMacro()

View File

@ -192,10 +192,12 @@ 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 {
handlerErr = sessionManager.ApproveSession(sessionID)
if handlerErr == nil {
go sessionManager.broadcastSessionListUpdate()
if targetSession := sessionManager.GetSession(sessionID); targetSession != nil && targetSession.Mode == SessionModePending {
targetSession.Mode = SessionModeObserver
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")
@ -204,18 +206,14 @@ 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 {
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()
}()
}
if targetSession := sessionManager.GetSession(sessionID); targetSession != nil && targetSession.Mode == SessionModePending {
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")
@ -253,35 +251,24 @@ 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) {
// 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
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)
}()
}
}
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"}
}
sessionManager.broadcastSessionListUpdate()
result = map[string]interface{}{"status": "updated"}
} else {
handlerErr = errors.New("permission denied: can only update own nickname")
}

View File

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

View File

@ -132,6 +132,9 @@ 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 {
@ -149,15 +152,6 @@ 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
@ -169,17 +163,25 @@ 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)")
}
// Close old connection to prevent multiple active connections for same session ID
// CRITICAL: Close old connection to prevent multiple active connections for same session ID
if existing.peerConnection != nil {
sm.logger.Info().
Str("sessionID", session.ID).
Msg("Closing old peer connection for session reconnection")
existing.peerConnection.Close()
}
@ -203,24 +205,42 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
// If this was the primary, try to restore primary status
if existing.Mode == SessionModePrimary {
// Check if this session is still the reserved primary AND not blacklisted
isBlacklisted := sm.isSessionBlacklisted(session.ID)
// 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 {
if sm.lastPrimaryID == session.ID && !isBlacklisted {
// This is the rightful primary reconnecting within grace period
sm.primarySessionID = session.ID
sm.lastPrimaryID = ""
delete(sm.reconnectGrace, 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")
} else {
// Grace period expired, another session took over, or primary already exists
// This session was primary but grace period expired, another took over, or is blacklisted
session.Mode = SessionModeObserver
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
}
@ -229,6 +249,15 @@ 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
@ -237,6 +266,9 @@ 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)
@ -253,28 +285,65 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
}
}
// Check if this session was recently demoted via transfer
isBlacklisted := sm.isSessionBlacklisted(session.ID)
// 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))
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")
}
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 = ""
sm.lastPrimaryID = "" // Clear since we have a new primary
// Clear all existing grace periods when a new primary is established
for oldSessionID := range sm.reconnectGrace {
delete(sm.reconnectGrace, oldSessionID)
}
for oldSessionID := range sm.reconnectInfo {
delete(sm.reconnectInfo, oldSessionID)
// 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)
}
}
// Reset HID availability to force re-handshake for input functionality
session.hidRPCAvailable = false
} else {
session.Mode = SessionModeObserver
@ -312,6 +381,8 @@ 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().
@ -323,14 +394,9 @@ 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()
@ -344,6 +410,9 @@ 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
}
@ -362,8 +431,12 @@ 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)
}
@ -377,9 +450,12 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
// Only add grace period if this is NOT an intentional logout
if !isIntentionalLogout {
// Limit grace period entries to prevent memory exhaustion
const maxGraceEntries = 10
// 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
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 {
@ -392,7 +468,7 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
delete(sm.reconnectGrace, oldestID)
delete(sm.reconnectInfo, oldestID)
} else {
break
break // Safety check to prevent infinite loop
}
}
@ -424,8 +500,14 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
sm.lastPrimaryID = sessionID // Remember this was the primary for grace period
sm.primarySessionID = "" // Clear primary slot so other sessions can be promoted
// Clear all blacklists to allow promotion after grace period expires
// Clear all blacklists to allow emergency promotion after grace period expires
// The blacklist is meant to prevent immediate re-promotion during manual transfers,
// but should not block emergency promotion after accidental disconnects
if len(sm.transferBlacklist) > 0 {
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)
}
@ -438,6 +520,11 @@ 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()
}
}
@ -792,23 +879,6 @@ 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)
@ -866,51 +936,6 @@ 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()
@ -942,6 +967,17 @@ 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 {
@ -949,18 +985,26 @@ func (sm *SessionManager) validateSinglePrimary() {
}
}
// If we have multiple primaries, fix it
// If we have multiple primaries, this is a critical bug - fix it
if len(primarySessions) > 1 {
sm.logger.Error().
Int("primaryCount", len(primarySessions)).
Msg("Multiple primary sessions detected, fixing")
Msg("CRITICAL BUG: Multiple primary sessions detected, fixing...")
// Keep the first one as primary, demote the rest
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")
}
}
}
@ -975,14 +1019,25 @@ 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) {
return // Keep primary slot reserved during grace period
// Primary is in grace period, DON'T clear the slot yet
sm.logger.Info().
Str("gracePrimaryID", sm.primarySessionID).
Msg("Primary slot preserved - session in grace period")
return // Exit validation, keep primary slot reserved
}
}
}
// No grace period, safe to clear orphaned primary
sm.logger.Warn().
Str("orphanedPrimaryID", sm.primarySessionID).
Msg("Cleared orphaned primary ID")
sm.primarySessionID = ""
}
@ -993,12 +1048,30 @@ 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
@ -1020,6 +1093,13 @@ 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")
}
}
@ -1054,9 +1134,6 @@ 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)
@ -1083,11 +1160,7 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
toSession.Mode = SessionModePrimary
toSession.hidRPCAvailable = false // Force re-handshake
sm.primarySessionID = toSessionID
// 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
sm.lastPrimaryID = toSessionID // Set to new primary so grace period works on refresh
// Clear input state
sm.clearInputState()
@ -1098,48 +1171,39 @@ 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
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)
}
// 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++
}
}
// 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.
// 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)
}
}
sm.logger.Info().
Str("fromSessionID", fromSessionID).
@ -1150,9 +1214,8 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
Dur("blacklistDuration", blacklistDuration).
Msg("Primary role transferred with bidirectional protection")
// 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
// Validate session consistency after role transfer
sm.validateSinglePrimary()
// Handle WebRTC connection state for promoted sessions
// When a session changes from observer to primary, the existing WebRTC connection
@ -1566,31 +1629,22 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
if currentSessionSettings != nil && currentSessionSettings.RequireApproval {
isEmergencyPromotion = true
// CRITICAL: Ensure we ALWAYS have a primary session
// If there's NO primary, bypass rate limits entirely
hasPrimary := sm.primarySessionID != ""
if !hasPrimary {
// 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 {
sm.logger.Error().
Str("expiredSessionID", sessionID).
Msg("CRITICAL: No primary session exists - bypassing all rate limits")
} else {
// Rate limiting for emergency promotions (only when we have a primary)
if now.Sub(sm.lastEmergencyPromotion) < 30*time.Second {
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
}
Int("consecutiveCount", sm.consecutiveEmergencyPromotions).
Msg("Too many consecutive emergency promotions - blocking for security")
continue // Skip this grace period expiration
}
promotedSessionID = sm.findMostTrustedSessionForEmergency()
@ -1691,23 +1745,13 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
if currentSessionSettings != nil && currentSessionSettings.RequireApproval {
isEmergencyPromotion = true
// 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().
// Rate limiting for emergency promotions
if now.Sub(sm.lastEmergencyPromotion) < 30*time.Second {
sm.logger.Warn().
Str("timedOutSessionID", timedOutSessionID).
Msg("CRITICAL: No primary session after timeout - bypassing all rate limits")
} else {
// Rate limiting for emergency promotions (only when we have a primary)
if now.Sub(sm.lastEmergencyPromotion) < 30*time.Second {
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
}
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
@ -1776,12 +1820,14 @@ 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()
}
}
@ -1797,16 +1843,7 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
}
// Global session manager instance
var (
sessionManager *SessionManager
sessionManagerOnce sync.Once
)
func initSessionManager() {
sessionManagerOnce.Do(func() {
sessionManager = NewSessionManager(websocketLogger)
})
}
var sessionManager = NewSessionManager(websocketLogger)
// Global session settings - references config.SessionSettings for persistence
var currentSessionSettings *SessionSettings

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,34 +1,169 @@
import { useContext } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { PermissionsContext } from "@/contexts/PermissionsContext";
import { Permission } from "@/types/permissions";
import { useJsonRpc, JsonRpcRequest } from "@/hooks/useJsonRpc";
import { useSessionStore } from "@/stores/sessionStore";
import { useRTCStore } from "@/hooks/stores";
export interface PermissionsContextValue {
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
// 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>;
isLoading: boolean;
hasPermission: (permission: Permission) => boolean;
hasAnyPermission: (...perms: Permission[]) => boolean;
hasAllPermissions: (...perms: Permission[]) => boolean;
isPrimary: () => boolean;
isObserver: () => boolean;
isPending: () => boolean;
}
export function usePermissions(): PermissionsContextValue {
const context = useContext(PermissionsContext);
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);
if (context === undefined) {
return {
permissions: {},
isLoading: true,
hasPermission: () => false,
hasAnyPermission: () => false,
hasAllPermissions: () => false,
isPrimary: () => false,
isObserver: () => false,
isPending: () => false,
};
}
// Function to poll permissions
const pollPermissions = useCallback((send: RpcSendFunction) => {
if (!send) return;
return context;
}
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,
};
}

View File

@ -16,10 +16,6 @@ interface ModeChangedData {
mode: string;
}
interface ConnectionModeChangedData {
newMode: string;
}
export function useSessionEvents(sendFn: RpcSendFunction | null) {
const {
currentMode,
@ -31,6 +27,7 @@ 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":
@ -39,9 +36,6 @@ export function useSessionEvents(sendFn: RpcSendFunction | null) {
case "modeChanged":
handleModeChanged(params as ModeChangedData);
break;
case "connectionModeChanged":
handleConnectionModeChanged(params as ConnectionModeChangedData);
break;
case "hidReadyForPrimary":
handleHidReadyForPrimary();
break;
@ -109,25 +103,23 @@ 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;
@ -144,6 +136,7 @@ export function useSessionEvents(sendFn: RpcSendFunction | null) {
fetchSessions();
}, [setSessions, setSessionError]);
// Set up periodic session refresh
useEffect(() => {
if (!sendFnRef.current) return;

View File

@ -3,8 +3,7 @@ import { useEffect, useCallback, useState } from "react";
import { useSessionStore } from "@/stores/sessionStore";
import { useSessionEvents } from "@/hooks/useSessionEvents";
import { useSettingsStore } from "@/hooks/stores";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
import { usePermissions, Permission } from "@/hooks/usePermissions";
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
@ -33,19 +32,21 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
clearSession
} = useSessionStore();
const { hasPermission, isLoading: isLoadingPermissions } = usePermissions();
const { hasPermission } = 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;
@ -62,6 +63,7 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
});
}, [sendFn]);
// Handle denial of primary control request
const handleDenyPrimaryRequest = useCallback(async (requestId: string) => {
if (!sendFn) return;
@ -78,6 +80,7 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
});
}, [sendFn]);
// Handle approval of new session
const handleApproveNewSession = useCallback(async (sessionId: string) => {
if (!sendFn) return;
@ -94,6 +97,7 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
});
}, [sendFn]);
// Handle denial of new session
const handleDenyNewSession = useCallback(async (sessionId: string) => {
if (!sendFn) return;
@ -110,30 +114,34 @@ 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);
}
if (method === "newSessionPending" && requireSessionApproval) {
if (isLoadingPermissions || hasPermission(Permission.SESSION_APPROVE)) {
setNewSessionRequest(params as NewSessionRequest);
}
// 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);
}
// 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");
@ -144,14 +152,9 @@ 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, isLoadingPermissions, requireSessionApproval]);
useEffect(() => {
if (!isLoadingPermissions && newSessionRequest && !hasPermission(Permission.SESSION_APPROVE)) {
setNewSessionRequest(null);
}
}, [isLoadingPermissions, hasPermission, newSessionRequest]);
}, [handleSessionEvent, hasPermission, requireSessionApproval]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearSession();

View File

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

View File

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

View File

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

View File

@ -22,8 +22,7 @@ import { LinkButton } from "@components/Button";
import { FeatureFlag } from "@components/FeatureFlag";
import { useUiStore } from "@/hooks/stores";
import { useSessionStore } from "@/stores/sessionStore";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
import { usePermissions, Permission } from "@/hooks/usePermissions";
/* 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() {
@ -35,7 +34,7 @@ export default function SettingsRoute() {
useEffect(() => {
if (!isLoading && !permissions[Permission.SETTINGS_ACCESS] && currentMode !== null) {
navigate("/", { replace: true });
navigate("/devices/local", { replace: true });
}
}, [permissions, isLoading, currentMode, navigate]);

View File

@ -18,6 +18,7 @@ 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,
@ -53,9 +54,6 @@ 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";
@ -161,6 +159,7 @@ 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(
@ -550,6 +549,44 @@ 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");
@ -590,6 +627,9 @@ export default function KvmIdRoute() {
setRpcHidUnreliableNonOrderedChannel,
setRpcHidUnreliableChannel,
setTransceiver,
hasPermission,
setRequireSessionApproval,
setRequireSessionNickname,
]);
useEffect(() => {
@ -682,7 +722,6 @@ 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" ||
@ -696,6 +735,7 @@ export default function KvmIdRoute() {
setAccessDenied(true);
}
// Keep legacy behavior for otherSessionConnected
if (resp.method === "otherSessionConnected") {
navigateTo("/other-session");
}
@ -765,25 +805,21 @@ 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, hasPermission, isLoadingPermissions, send, setHdmiState]);
}, [rpcDataChannel?.readyState, 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) {
@ -795,18 +831,20 @@ export default function KvmIdRoute() {
}
setNeedLedState(false);
});
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState, hasPermission, isLoadingPermissions]);
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState]);
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 {
@ -818,7 +856,7 @@ export default function KvmIdRoute() {
}
setNeedKeyDownState(false);
});
}, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeysDownState, setHidRpcDisabled, hasPermission, isLoadingPermissions]);
}, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeysDownState, setHidRpcDisabled]);
// When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => {
@ -851,10 +889,10 @@ export default function KvmIdRoute() {
useEffect(() => {
if (appVersion) return;
if (!hasPermission(Permission.VIDEO_VIEW)) return;
getLocalVersion();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appVersion]);
}, [appVersion, getLocalVersion, hasPermission]);
const ConnectionStatusElement = useMemo(() => {
const hasConnectionFailed =
@ -894,9 +932,8 @@ export default function KvmIdRoute() {
]);
return (
<PermissionsProvider>
<FeatureFlagProvider appVersion={appVersion}>
{!outlet && otaState.updating && (
<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"
@ -1069,8 +1106,7 @@ export default function KvmIdRoute() {
<PendingApprovalOverlay
show={currentMode === "pending"}
/>
</FeatureFlagProvider>
</PermissionsProvider>
</FeatureFlagProvider>
);
}

View File

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

View File

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