diff --git a/cloud.go b/cloud.go index a851d51f3..ea6d934b9 100644 --- a/cloud.go +++ b/cloud.go @@ -197,6 +197,20 @@ func wsResetMetrics(established bool, sourceType string, source string) { } func handleCloudRegister(c *gin.Context) { + sessionID, _ := c.Cookie("sessionId") + authToken, _ := c.Cookie("authToken") + + if sessionID != "" && authToken != "" && authToken == config.LocalAuthToken { + session := sessionManager.GetSession(sessionID) + if session != nil && !session.HasPermission(PermissionSettingsWrite) { + c.JSON(403, gin.H{"error": "Permission denied: settings modify permission required"}) + return + } + } else if sessionID != "" { + c.JSON(401, gin.H{"error": "Authentication required"}) + return + } + var req CloudRegisterRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -426,8 +440,15 @@ func handleSessionRequest( req WebRTCSessionRequest, isCloudConnection bool, source string, + connectionID string, scopedLogger *zerolog.Logger, -) error { +) (returnErr error) { + defer func() { + if r := recover(); r != nil { + websocketLogger.Error().Interface("panic", r).Msg("PANIC in handleSessionRequest") + returnErr = fmt.Errorf("panic: %v", r) + } + }() var sourceType string if isCloudConnection { sourceType = "cloud" @@ -453,6 +474,7 @@ func handleSessionRequest( IsCloud: isCloudConnection, LocalIP: req.IP, ICEServers: req.ICEServers, + UserAgent: req.UserAgent, Logger: scopedLogger, }) if err != nil { @@ -462,26 +484,73 @@ func handleSessionRequest( sd, err := session.ExchangeOffer(req.Sd) if err != nil { + scopedLogger.Warn().Err(err).Msg("failed to exchange offer") _ = wsjson.Write(context.Background(), c, gin.H{"error": err}) return err } - if currentSession != nil { - writeJSONRPCEvent("otherSessionConnected", nil, currentSession) - peerConn := currentSession.peerConnection - go func() { - time.Sleep(1 * time.Second) - _ = peerConn.Close() - }() + session.Source = source + + if isCloudConnection && req.OidcGoogle != "" { + session.Identity = config.GoogleIdentity + + // Use client-provided sessionId for reconnection, otherwise generate new one + // This enables multi-tab support while preserving reconnection on refresh + if req.SessionId != "" { + session.ID = req.SessionId + scopedLogger.Info().Str("sessionId", session.ID).Msg("Cloud session reconnecting with client-provided ID") + } else { + session.ID = connectionID + scopedLogger.Info().Str("sessionId", session.ID).Msg("New cloud session established") + } + } else { + session.ID = connectionID + scopedLogger.Info().Str("sessionId", session.ID).Msg("Local session established") } - cloudLogger.Info().Interface("session", session).Msg("new session accepted") - cloudLogger.Trace().Interface("session", session).Msg("new session accepted") + if sessionManager == nil { + scopedLogger.Error().Msg("sessionManager is nil") + _ = wsjson.Write(context.Background(), c, gin.H{"error": "session manager not initialized"}) + return fmt.Errorf("session manager not initialized") + } - // Cancel any ongoing keyboard macro when session changes - cancelKeyboardMacro() + err = sessionManager.AddSession(session, req.SessionSettings) + if err != nil { + scopedLogger.Warn().Err(err).Msg("failed to add session to session manager") + if err == ErrMaxSessionsReached { + _ = wsjson.Write(context.Background(), c, gin.H{"error": "maximum sessions reached"}) + } else { + _ = wsjson.Write(context.Background(), c, gin.H{"error": err.Error()}) + } + return err + } + + if session.HasPermission(PermissionPaste) { + cancelKeyboardMacro() + } + + requireNickname := false + requireApproval := false + if currentSessionSettings != nil { + requireNickname = currentSessionSettings.RequireNickname + requireApproval = currentSessionSettings.RequireApproval + } - currentSession = session - _ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd}) + err = wsjson.Write(context.Background(), c, gin.H{ + "type": "answer", + "data": sd, + "sessionId": session.ID, + "mode": session.Mode, + "nickname": session.Nickname, + "requireNickname": requireNickname, + "requireApproval": requireApproval, + }) + if err != nil { + return err + } + + if session.flushCandidates != nil { + session.flushCandidates() + } return nil } diff --git a/config.go b/config.go index 0b0089128..2a4f09c4e 100644 --- a/config.go +++ b/config.go @@ -77,11 +77,21 @@ func (m *KeyboardMacro) Validate() error { return nil } +// MultiSessionConfig defines settings for multi-session support +type MultiSessionConfig struct { + Enabled bool `json:"enabled"` + MaxSessions int `json:"max_sessions"` + PrimaryTimeout int `json:"primary_timeout_seconds"` + AllowCloudOverride bool `json:"allow_cloud_override"` + RequireAuthTransfer bool `json:"require_auth_transfer"` +} + type Config struct { CloudURL string `json:"cloud_url"` CloudAppURL string `json:"cloud_app_url"` CloudToken string `json:"cloud_token"` GoogleIdentity string `json:"google_identity"` + MultiSession *MultiSessionConfig `json:"multi_session"` JigglerEnabled bool `json:"jiggler_enabled"` JigglerConfig *JigglerConfig `json:"jiggler_config"` AutoUpdateEnabled bool `json:"auto_update_enabled"` @@ -104,6 +114,7 @@ type Config struct { UsbDevices *usbgadget.Devices `json:"usb_devices"` NetworkConfig *network.NetworkConfig `json:"network_config"` DefaultLogLevel string `json:"default_log_level"` + SessionSettings *SessionSettings `json:"session_settings"` VideoSleepAfterSec int `json:"video_sleep_after_sec"` } @@ -129,17 +140,31 @@ func (c *Config) SetDisplayRotation(rotation string) error { const configPath = "/userdata/kvm_config.json" var defaultConfig = &Config{ - CloudURL: "https://api.jetkvm.com", - CloudAppURL: "https://app.jetkvm.com", - AutoUpdateEnabled: true, // Set a default value - ActiveExtension: "", + CloudURL: "https://api.jetkvm.com", + CloudAppURL: "https://app.jetkvm.com", + AutoUpdateEnabled: true, // Set a default value + ActiveExtension: "", + MultiSession: &MultiSessionConfig{ + Enabled: true, // Enable by default for new features + MaxSessions: 10, // Reasonable default + PrimaryTimeout: 300, // 5 minutes + AllowCloudOverride: true, // Cloud sessions can take control + RequireAuthTransfer: false, // Don't require auth by default + }, KeyboardMacros: []KeyboardMacro{}, DisplayRotation: "270", KeyboardLayout: "en-US", DisplayMaxBrightness: 64, DisplayDimAfterSec: 120, // 2 minutes DisplayOffAfterSec: 1800, // 30 minutes - JigglerEnabled: false, + SessionSettings: &SessionSettings{ + RequireApproval: false, + RequireNickname: false, + ReconnectGrace: 10, + PrivateKeystrokes: false, + MaxRejectionAttempts: 3, + }, + JigglerEnabled: false, // This is the "Standard" jiggler option in the UI JigglerConfig: &JigglerConfig{ InactivityLimitSeconds: 60, diff --git a/datachannel_helpers.go b/datachannel_helpers.go new file mode 100644 index 000000000..8edfd0952 --- /dev/null +++ b/datachannel_helpers.go @@ -0,0 +1,11 @@ +package kvm + +import "github.com/pion/webrtc/v4" + +func handlePermissionDeniedChannel(d *webrtc.DataChannel, message string) { + d.OnOpen(func() { + _ = d.SendText(message + "\r\n") + d.Close() + }) + d.OnMessage(func(msg webrtc.DataChannelMessage) {}) +} diff --git a/errors.go b/errors.go new file mode 100644 index 000000000..b287f9382 --- /dev/null +++ b/errors.go @@ -0,0 +1,10 @@ +package kvm + +import "errors" + +var ( + ErrPermissionDeniedKeyboard = errors.New("permission denied: keyboard input") + ErrPermissionDeniedMouse = errors.New("permission denied: mouse input") + ErrNotPrimarySession = errors.New("operation requires primary session") + ErrSessionNotFound = errors.New("session not found") +) diff --git a/hidrpc.go b/hidrpc.go index ebe03daab..c4c8c8ae0 100644 --- a/hidrpc.go +++ b/hidrpc.go @@ -27,8 +27,14 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) { } session.hidRPCAvailable = true case hidrpc.TypeKeypressReport, hidrpc.TypeKeyboardReport: + if !session.HasPermission(PermissionKeyboardInput) { + return + } rpcErr = handleHidRPCKeyboardInput(message) case hidrpc.TypeKeyboardMacroReport: + if !session.HasPermission(PermissionPaste) { + return + } keyboardMacroReport, err := message.KeyboardMacroReport() if err != nil { logger.Warn().Err(err).Msg("failed to get keyboard macro report") @@ -36,18 +42,30 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) { } rpcErr = rpcExecuteKeyboardMacro(keyboardMacroReport.Steps) case hidrpc.TypeCancelKeyboardMacroReport: + if !session.HasPermission(PermissionPaste) { + return + } rpcCancelKeyboardMacro() return case hidrpc.TypeKeypressKeepAliveReport: + if !session.HasPermission(PermissionKeyboardInput) { + return + } rpcErr = handleHidRPCKeypressKeepAlive(session) case hidrpc.TypePointerReport: + if !session.HasPermission(PermissionMouseInput) { + return + } pointerReport, err := message.PointerReport() if err != nil { logger.Warn().Err(err).Msg("failed to get pointer report") return } - rpcErr = rpcAbsMouseReport(pointerReport.X, pointerReport.Y, pointerReport.Button) + rpcErr = rpcAbsMouseReport(int16(pointerReport.X), int16(pointerReport.Y), pointerReport.Button) case hidrpc.TypeMouseReport: + if !session.HasPermission(PermissionMouseInput) { + return + } mouseReport, err := message.MouseReport() if err != nil { logger.Warn().Err(err).Msg("failed to get mouse report") diff --git a/internal/session/permissions.go b/internal/session/permissions.go new file mode 100644 index 000000000..6db9316e8 --- /dev/null +++ b/internal/session/permissions.go @@ -0,0 +1,306 @@ +package session + +import "fmt" + +// Permission represents a specific action that can be performed +type Permission string + +const ( + // Video/Display permissions + PermissionVideoView Permission = "video.view" + + // Input permissions + PermissionKeyboardInput Permission = "keyboard.input" + PermissionMouseInput Permission = "mouse.input" + PermissionPaste Permission = "clipboard.paste" + + // Session management permissions + PermissionSessionTransfer Permission = "session.transfer" + PermissionSessionApprove Permission = "session.approve" + PermissionSessionKick Permission = "session.kick" + PermissionSessionRequestPrimary Permission = "session.request_primary" + PermissionSessionReleasePrimary Permission = "session.release_primary" + PermissionSessionManage Permission = "session.manage" + + // Power/USB control permissions + PermissionPowerControl Permission = "power.control" + PermissionUSBControl Permission = "usb.control" + + // Mount/Media permissions + PermissionMountMedia Permission = "mount.media" + PermissionUnmountMedia Permission = "mount.unmedia" + PermissionMountList Permission = "mount.list" + + // Extension permissions + PermissionExtensionManage Permission = "extension.manage" + + // Terminal/Serial permissions + PermissionTerminalAccess Permission = "terminal.access" + PermissionSerialAccess Permission = "serial.access" + PermissionExtensionATX Permission = "extension.atx" + PermissionExtensionDC Permission = "extension.dc" + PermissionExtensionSerial Permission = "extension.serial" + PermissionExtensionWOL Permission = "extension.wol" + + // Settings permissions + PermissionSettingsRead Permission = "settings.read" + PermissionSettingsWrite Permission = "settings.write" + PermissionSettingsAccess Permission = "settings.access" // Access control settings + + // System permissions + PermissionSystemReboot Permission = "system.reboot" + PermissionSystemUpdate Permission = "system.update" + PermissionSystemNetwork Permission = "system.network" +) + +// PermissionSet represents a set of permissions +type PermissionSet map[Permission]bool + +// RolePermissions defines permissions for each session mode +var RolePermissions = map[SessionMode]PermissionSet{ + SessionModePrimary: { + // Primary has all permissions + PermissionVideoView: true, + PermissionKeyboardInput: true, + PermissionMouseInput: true, + PermissionPaste: true, + PermissionSessionTransfer: true, + PermissionSessionApprove: true, + PermissionSessionKick: true, + PermissionSessionReleasePrimary: true, + PermissionMountMedia: true, + PermissionUnmountMedia: true, + PermissionMountList: true, + PermissionExtensionManage: true, + PermissionExtensionATX: true, + PermissionExtensionDC: true, + PermissionExtensionSerial: true, + PermissionExtensionWOL: true, + PermissionSettingsRead: true, + PermissionSettingsWrite: true, + PermissionSettingsAccess: true, // Only primary can access settings UI + PermissionSystemReboot: true, + PermissionSystemUpdate: true, + PermissionSystemNetwork: true, + PermissionTerminalAccess: true, + PermissionSerialAccess: true, + PermissionPowerControl: true, + PermissionUSBControl: true, + PermissionSessionManage: true, + PermissionSessionRequestPrimary: false, // Primary doesn't need to request + }, + SessionModeObserver: { + // Observers can only view + PermissionVideoView: true, + PermissionSessionRequestPrimary: true, + PermissionMountList: true, // Can see what's mounted but not mount/unmount + }, + SessionModeQueued: { + // Queued sessions can view and request primary + PermissionVideoView: true, + PermissionSessionRequestPrimary: true, + }, + SessionModePending: { + // Pending sessions have NO permissions until approved + // This prevents unauthorized video access + }, +} + +// CheckPermission checks if a session mode has a specific permission +func CheckPermission(mode SessionMode, perm Permission) bool { + permissions, exists := RolePermissions[mode] + if !exists { + return false + } + return permissions[perm] +} + +// GetPermissionsForMode returns all permissions for a session mode +func GetPermissionsForMode(mode SessionMode) PermissionSet { + permissions, exists := RolePermissions[mode] + if !exists { + return PermissionSet{} + } + + // Return a copy to prevent modification + result := make(PermissionSet) + for k, v := range permissions { + result[k] = v + } + return result +} + +// RequirePermissionForMode is a middleware-like function for RPC handlers +func RequirePermissionForMode(mode SessionMode, perm Permission) error { + if !CheckPermission(mode, perm) { + return fmt.Errorf("permission denied: %s", perm) + } + return nil +} + +// GetPermissionsResponse is the response structure for getPermissions RPC +type GetPermissionsResponse struct { + Mode string `json:"mode"` + Permissions map[string]bool `json:"permissions"` +} + +// MethodPermissions maps RPC methods to required permissions +var MethodPermissions = map[string]Permission{ + // Power/hardware control + "setATXPowerAction": PermissionPowerControl, + "setDCPowerState": PermissionPowerControl, + "setDCRestoreState": PermissionPowerControl, + + // USB device control + "setUsbDeviceState": PermissionUSBControl, + "setUsbDevices": PermissionUSBControl, + + // Mount operations + "mountUsb": PermissionMountMedia, + "unmountUsb": PermissionMountMedia, + "mountBuiltInImage": PermissionMountMedia, + "rpcMountBuiltInImage": PermissionMountMedia, + "unmountImage": PermissionMountMedia, + "mountWithHTTP": PermissionMountMedia, + "mountWithStorage": PermissionMountMedia, + "checkMountUrl": PermissionMountMedia, + "startStorageFileUpload": PermissionMountMedia, + "deleteStorageFile": PermissionMountMedia, + + // Settings operations + "setDevModeState": PermissionSettingsWrite, + "setDevChannelState": PermissionSettingsWrite, + "setAutoUpdateState": PermissionSettingsWrite, + "tryUpdate": PermissionSettingsWrite, + "reboot": PermissionSettingsWrite, + "resetConfig": PermissionSettingsWrite, + "setNetworkSettings": PermissionSettingsWrite, + "setLocalLoopbackOnly": PermissionSettingsWrite, + "renewDHCPLease": PermissionSettingsWrite, + "setSSHKeyState": PermissionSettingsWrite, + "setTLSState": PermissionSettingsWrite, + "setVideoBandwidth": PermissionSettingsWrite, + "setVideoFramerate": PermissionSettingsWrite, + "setVideoResolution": PermissionSettingsWrite, + "setVideoEncoderQuality": PermissionSettingsWrite, + "setVideoSignal": PermissionSettingsWrite, + "setSerialBitrate": PermissionSettingsWrite, + "setSerialSettings": PermissionSettingsWrite, + "setSessionSettings": PermissionSessionManage, + "updateSessionSettings": PermissionSessionManage, + + // Display settings + "setEDID": PermissionSettingsWrite, + "setStreamQualityFactor": PermissionSettingsWrite, + "setDisplayRotation": PermissionSettingsWrite, + "setBacklightSettings": PermissionSettingsWrite, + + // USB/HID settings + "setUsbEmulationState": PermissionSettingsWrite, + "setUsbConfig": PermissionSettingsWrite, + "setKeyboardLayout": PermissionSettingsWrite, + "setJigglerState": PermissionSettingsWrite, + "setJigglerConfig": PermissionSettingsWrite, + "setMassStorageMode": PermissionSettingsWrite, + "setKeyboardMacros": PermissionSettingsWrite, + "setWakeOnLanDevices": PermissionSettingsWrite, + + // Cloud settings + "setCloudUrl": PermissionSettingsWrite, + "deregisterDevice": PermissionSettingsWrite, + + // Active extension control + "setActiveExtension": PermissionExtensionManage, + + // Input operations (already handled in other places but for consistency) + "keyboardReport": PermissionKeyboardInput, + "keypressReport": PermissionKeyboardInput, + "absMouseReport": PermissionMouseInput, + "relMouseReport": PermissionMouseInput, + "wheelReport": PermissionMouseInput, + "executeKeyboardMacro": PermissionPaste, + "cancelKeyboardMacro": PermissionPaste, + + // Session operations + "approveNewSession": PermissionSessionApprove, + "denyNewSession": PermissionSessionApprove, + "transferSession": PermissionSessionTransfer, + "transferPrimary": PermissionSessionTransfer, + "requestPrimary": PermissionSessionRequestPrimary, + "releasePrimary": PermissionSessionReleasePrimary, + + // Extension operations + "activateExtension": PermissionExtensionManage, + "deactivateExtension": PermissionExtensionManage, + "sendWOLMagicPacket": PermissionExtensionWOL, + + // Read operations - require appropriate read permissions + "getSessionSettings": PermissionSettingsRead, + "getSessionConfig": PermissionSettingsRead, + "getSessionData": PermissionVideoView, + "getNetworkSettings": PermissionSettingsRead, + "getSerialSettings": PermissionSettingsRead, + "getBacklightSettings": PermissionSettingsRead, + "getDisplayRotation": PermissionSettingsRead, + "getEDID": PermissionSettingsRead, + "get_edid": PermissionSettingsRead, + "getKeyboardLayout": PermissionSettingsRead, + "getJigglerConfig": PermissionSettingsRead, + "getJigglerState": PermissionSettingsRead, + "getStreamQualityFactor": PermissionSettingsRead, + "getVideoSettings": PermissionSettingsRead, + "getVideoBandwidth": PermissionSettingsRead, + "getVideoFramerate": PermissionSettingsRead, + "getVideoResolution": PermissionSettingsRead, + "getVideoEncoderQuality": PermissionSettingsRead, + "getVideoSignal": PermissionSettingsRead, + "getSerialBitrate": PermissionSettingsRead, + "getDevModeState": PermissionSettingsRead, + "getDevChannelState": PermissionSettingsRead, + "getAutoUpdateState": PermissionSettingsRead, + "getLocalLoopbackOnly": PermissionSettingsRead, + "getSSHKeyState": PermissionSettingsRead, + "getTLSState": PermissionSettingsRead, + "getCloudUrl": PermissionSettingsRead, + "getCloudState": PermissionSettingsRead, + "getNetworkState": PermissionSettingsRead, + + // Mount/media read operations + "getMassStorageMode": PermissionMountList, + "getUsbState": PermissionMountList, + "getUSBState": PermissionMountList, + "listStorageFiles": PermissionMountList, + "getStorageSpace": PermissionMountList, + + // Extension read operations + "getActiveExtension": PermissionSettingsRead, + + // Power state reads + "getATXState": PermissionSettingsRead, + "getDCPowerState": PermissionSettingsRead, + "getDCRestoreState": PermissionSettingsRead, + + // Device info reads (these should be accessible to all) + "getDeviceID": PermissionVideoView, + "getLocalVersion": PermissionVideoView, + "getVideoState": PermissionVideoView, + "getKeyboardLedState": PermissionVideoView, + "getKeyDownState": PermissionVideoView, + "ping": PermissionVideoView, + "getTimezones": PermissionVideoView, + "getSessions": PermissionVideoView, + "getUpdateStatus": PermissionSettingsRead, + "isUpdatePending": PermissionSettingsRead, + "getUsbEmulationState": PermissionSettingsRead, + "getUsbConfig": PermissionSettingsRead, + "getUsbDevices": PermissionSettingsRead, + "getKeyboardMacros": PermissionSettingsRead, + "getWakeOnLanDevices": PermissionSettingsRead, + "getVirtualMediaState": PermissionMountList, +} + +// GetMethodPermission returns the required permission for an RPC method +func GetMethodPermission(method string) (Permission, bool) { + perm, exists := MethodPermissions[method] + return perm, exists +} diff --git a/internal/session/types.go b/internal/session/types.go new file mode 100644 index 000000000..1a983bdb6 --- /dev/null +++ b/internal/session/types.go @@ -0,0 +1,11 @@ +package session + +// SessionMode represents the role/mode of a session +type SessionMode string + +const ( + SessionModePrimary SessionMode = "primary" + SessionModeObserver SessionMode = "observer" + SessionModeQueued SessionMode = "queued" + SessionModePending SessionMode = "pending" +) diff --git a/internal/usbgadget/hid_keyboard.go b/internal/usbgadget/hid_keyboard.go index 74cf76f9e..d0b6eaa2f 100644 --- a/internal/usbgadget/hid_keyboard.go +++ b/internal/usbgadget/hid_keyboard.go @@ -354,7 +354,7 @@ func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState { u.keyboardStateLock.Unlock() if u.onKeysDownChange != nil { - (*u.onKeysDownChange)(state) // this enques to the outgoing hidrpc queue via usb.go → currentSession.enqueueKeysDownState(...) + (*u.onKeysDownChange)(state) } return state } diff --git a/internal/usbgadget/hid_mouse_absolute.go b/internal/usbgadget/hid_mouse_absolute.go index 374844f10..1f366d196 100644 --- a/internal/usbgadget/hid_mouse_absolute.go +++ b/internal/usbgadget/hid_mouse_absolute.go @@ -85,7 +85,7 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error { return nil } -func (u *UsbGadget) AbsMouseReport(x int, y int, buttons uint8) error { +func (u *UsbGadget) AbsMouseReport(x int16, y int16, buttons uint8) error { u.absMouseLock.Lock() defer u.absMouseLock.Unlock() diff --git a/jiggler.go b/jiggler.go index b2463e0ab..3323d0bb5 100644 --- a/jiggler.go +++ b/jiggler.go @@ -133,11 +133,12 @@ func runJiggler() { if timeSinceLastInput > time.Duration(inactivitySeconds)*time.Second { logger.Debug().Msg("Jiggling mouse...") //TODO: change to rel mouse - err := rpcAbsMouseReport(1, 1, 0) + // Use direct hardware calls for jiggler - bypass session permissions + err := gadget.AbsMouseReport(1, 1, 0) if err != nil { logger.Warn().Msgf("Failed to jiggle mouse: %v", err) } - err = rpcAbsMouseReport(0, 0, 0) + err = gadget.AbsMouseReport(0, 0, 0) if err != nil { logger.Warn().Msgf("Failed to reset mouse position: %v", err) } diff --git a/jsonrpc.go b/jsonrpc.go index 6b321c6db..0b2d8d842 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "reflect" + "regexp" "strconv" "sync" "time" @@ -23,6 +24,14 @@ import ( "github.com/jetkvm/kvm/internal/utils" ) +// nicknameRegex defines the valid pattern for nicknames (matching frontend validation) +var nicknameRegex = regexp.MustCompile(`^[a-zA-Z0-9\s\-_.@]+$`) + +// isValidNickname checks if a nickname contains only valid characters +func isValidNickname(nickname string) bool { + return nicknameRegex.MatchString(nickname) +} + type JSONRPCRequest struct { JSONRPC string `json:"jsonrpc"` Method string `json:"method"` @@ -54,11 +63,16 @@ type BacklightSettings struct { } func writeJSONRPCResponse(response JSONRPCResponse, session *Session) { + if session == nil || session.RPCChannel == nil { + return + } + responseBytes, err := json.Marshal(response) if err != nil { jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC response") return } + err = session.RPCChannel.SendText(string(responseBytes)) if err != nil { jsonRpcLogger.Warn().Err(err).Msg("Error sending JSONRPC response") @@ -67,6 +81,11 @@ func writeJSONRPCResponse(response JSONRPCResponse, session *Session) { } func writeJSONRPCEvent(event string, params any, session *Session) { + // Defensive checks: skip if session or RPC channel is not ready + if session == nil || session.RPCChannel == nil { + return // Channel not ready or already closed - this is expected during cleanup + } + request := JSONRPCEvent{ JSONRPC: "2.0", Method: event, @@ -77,10 +96,6 @@ func writeJSONRPCEvent(event string, params any, session *Session) { jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC event") return } - if session == nil || session.RPCChannel == nil { - jsonRpcLogger.Info().Msg("RPC channel not available") - return - } requestString := string(requestBytes) scopedLogger := jsonRpcLogger.With(). @@ -91,12 +106,36 @@ func writeJSONRPCEvent(event string, params any, session *Session) { err = session.RPCChannel.SendText(requestString) if err != nil { - scopedLogger.Warn().Err(err).Msg("error sending JSONRPC event") + // Only log at debug level - closed pipe errors are expected during reconnection + scopedLogger.Debug().Err(err).Str("event", event).Msg("Could not send JSONRPC event (channel may be closing)") return } } +func broadcastJSONRPCEvent(event string, params any) { + sessionManager.ForEachSession(func(s *Session) { + writeJSONRPCEvent(event, params, s) + }) +} + func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { + // Rate limit check (DoS protection) + if !session.CheckRPCRateLimit() { + jsonRpcLogger.Warn(). + Str("sessionId", session.ID). + Msg("RPC rate limit exceeded") + errorResponse := JSONRPCResponse{ + JSONRPC: "2.0", + Error: map[string]any{ + "code": -32000, + "message": "Rate limit exceeded", + }, + ID: 0, + } + writeJSONRPCResponse(errorResponse, session) + return + } + var request JSONRPCRequest err := json.Unmarshal(message.Data, &request) if err != nil { @@ -124,21 +163,248 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { scopedLogger.Trace().Msg("Received RPC request") - handler, ok := rpcHandlers[request.Method] - if !ok { - errorResponse := JSONRPCResponse{ - JSONRPC: "2.0", - Error: map[string]any{ - "code": -32601, - "message": "Method not found", - }, - ID: request.ID, + // Handle session-specific RPC methods first + var result any + var handlerErr error + + switch request.Method { + case "approvePrimaryRequest": + if err := RequirePermission(session, PermissionSessionTransfer); err != nil { + handlerErr = err + } else if requesterID, ok := request.Params["requesterID"].(string); ok { + handlerErr = sessionManager.ApprovePrimaryRequest(session.ID, requesterID) + if handlerErr == nil { + result = map[string]interface{}{"status": "approved"} + } + } else { + handlerErr = errors.New("invalid requesterID parameter") } - writeJSONRPCResponse(errorResponse, session) - return + case "denyPrimaryRequest": + if err := RequirePermission(session, PermissionSessionTransfer); err != nil { + handlerErr = err + } else if requesterID, ok := request.Params["requesterID"].(string); ok { + handlerErr = sessionManager.DenyPrimaryRequest(session.ID, requesterID) + if handlerErr == nil { + result = map[string]interface{}{"status": "denied"} + } + } else { + handlerErr = errors.New("invalid requesterID parameter") + } + case "approveNewSession": + 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() + result = map[string]interface{}{"status": "approved"} + } + } else { + handlerErr = errors.New("invalid sessionId parameter") + } + case "denyNewSession": + 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() + }() + } + result = map[string]interface{}{"status": "denied"} + } + } else { + handlerErr = errors.New("invalid sessionId parameter") + } + case "requestSessionApproval": + if session.Mode != SessionModePending { + handlerErr = errors.New("only pending sessions can request approval") + } else if currentSessionSettings != nil && currentSessionSettings.RequireApproval { + if primary := sessionManager.GetPrimarySession(); primary != nil { + go func() { + writeJSONRPCEvent("newSessionPending", map[string]interface{}{ + "sessionId": session.ID, + "source": session.Source, + "identity": session.Identity, + "nickname": session.Nickname, + }, primary) + }() + result = map[string]interface{}{"status": "requested"} + } else { + handlerErr = errors.New("no primary session available") + } + } else { + handlerErr = errors.New("session approval not required") + } + case "updateSessionNickname": + sessionID, _ := request.Params["sessionId"].(string) + nickname, _ := request.Params["nickname"].(string) + // Validate nickname to match frontend validation + if len(nickname) < 2 { + handlerErr = errors.New("nickname must be at least 2 characters") + } else if len(nickname) > 30 { + handlerErr = errors.New("nickname must be 30 characters or less") + } else if !isValidNickname(nickname) { + handlerErr = errors.New("nickname can only contain letters, numbers, spaces, and - _ . @") + } 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 + } + } + + 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") + } + } else { + handlerErr = errors.New("session not found") + } + case "getSessions": + sessions := sessionManager.GetAllSessions() + result = sessions + case "getPermissions": + permissions := session.GetPermissions() + permMap := make(map[string]bool) + for perm, allowed := range permissions { + permMap[string(perm)] = allowed + } + result = GetPermissionsResponse{ + Mode: string(session.Mode), + Permissions: permMap, + } + case "getSessionSettings": + if err := RequirePermission(session, PermissionSettingsRead); err != nil { + handlerErr = err + } else { + result = currentSessionSettings + } + case "setSessionSettings": + if err := RequirePermission(session, PermissionSessionManage); err != nil { + handlerErr = err + } else { + if settings, ok := request.Params["settings"].(map[string]interface{}); ok { + if requireApproval, ok := settings["requireApproval"].(bool); ok { + currentSessionSettings.RequireApproval = requireApproval + } + if requireNickname, ok := settings["requireNickname"].(bool); ok { + currentSessionSettings.RequireNickname = requireNickname + } + if reconnectGrace, ok := settings["reconnectGrace"].(float64); ok { + currentSessionSettings.ReconnectGrace = int(reconnectGrace) + } + if primaryTimeout, ok := settings["primaryTimeout"].(float64); ok { + currentSessionSettings.PrimaryTimeout = int(primaryTimeout) + } + if privateKeystrokes, ok := settings["privateKeystrokes"].(bool); ok { + currentSessionSettings.PrivateKeystrokes = privateKeystrokes + } + if maxRejectionAttempts, ok := settings["maxRejectionAttempts"].(float64); ok { + currentSessionSettings.MaxRejectionAttempts = int(maxRejectionAttempts) + } + if maxSessions, ok := settings["maxSessions"].(float64); ok { + currentSessionSettings.MaxSessions = int(maxSessions) + } + if observerTimeout, ok := settings["observerTimeout"].(float64); ok { + currentSessionSettings.ObserverTimeout = int(observerTimeout) + } + + // Trigger nickname auto-generation for sessions when RequireNickname changes + if sessionManager != nil { + sessionManager.updateAllSessionNicknames() + } + + // Save to persistent config + if err := SaveConfig(); err != nil { + handlerErr = errors.New("failed to save session settings") + } + result = currentSessionSettings + } else { + handlerErr = errors.New("invalid settings parameter") + } + } + case "generateNickname": + // Generate a nickname based on user agent (no permissions required) + userAgent := "" + if request.Params != nil { + if ua, ok := request.Params["userAgent"].(string); ok { + userAgent = ua + } + } + + // Use browser as fallback if no user agent provided + if userAgent == "" { + userAgent = "Mozilla/5.0 (Unknown) Browser" + } + + result = map[string]string{ + "nickname": generateNicknameFromUserAgent(userAgent), + } + default: + // Check method permissions using centralized permission system + if requiredPerm, exists := GetMethodPermission(request.Method); exists { + if !session.HasPermission(requiredPerm) { + errorResponse := JSONRPCResponse{ + JSONRPC: "2.0", + Error: map[string]any{ + "code": -32603, + "message": fmt.Sprintf("Permission denied: %s required", requiredPerm), + }, + ID: request.ID, + } + writeJSONRPCResponse(errorResponse, session) + return + } + } + + // Fall back to regular handlers + handler, ok := rpcHandlers[request.Method] + if !ok { + errorResponse := JSONRPCResponse{ + JSONRPC: "2.0", + Error: map[string]any{ + "code": -32601, + "message": "Method not found", + }, + ID: request.ID, + } + writeJSONRPCResponse(errorResponse, session) + return + } + result, handlerErr = callRPCHandler(scopedLogger, handler, request.Params) } - result, err := callRPCHandler(scopedLogger, handler, request.Params) + err = handlerErr if err != nil { scopedLogger.Error().Err(err).Msg("Error calling RPC handler") errorResponse := JSONRPCResponse{ @@ -154,7 +420,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { return } - scopedLogger.Trace().Interface("result", result).Msg("RPC handler returned") + scopedLogger.Info().Interface("result", result).Msg("RPC handler returned successfully") response := JSONRPCResponse{ JSONRPC: "2.0", @@ -1084,6 +1350,78 @@ func rpcSetLocalLoopbackOnly(enabled bool) error { return nil } +func rpcGetSessions() ([]SessionData, error) { + return sessionManager.GetAllSessions(), nil +} + +func rpcGetSessionData(sessionId string) (SessionData, error) { + session := sessionManager.GetSession(sessionId) + if session == nil { + return SessionData{}, ErrSessionNotFound + } + return SessionData{ + ID: session.ID, + Mode: session.Mode, + Source: session.Source, + Identity: session.Identity, + CreatedAt: session.CreatedAt, + LastActive: session.LastActive, + }, nil +} + +func rpcRequestPrimary(sessionId string) map[string]interface{} { + err := sessionManager.RequestPrimary(sessionId) + if err != nil { + return map[string]interface{}{ + "status": "error", + "message": err.Error(), + } + } + + // Check if the session was immediately promoted or queued + session := sessionManager.GetSession(sessionId) + if session == nil { + return map[string]interface{}{ + "status": "error", + "message": "session not found", + } + } + + return map[string]interface{}{ + "status": "success", + "mode": string(session.Mode), + } +} + +func rpcReleasePrimary(sessionId string) error { + return sessionManager.ReleasePrimary(sessionId) +} + +func rpcTransferPrimary(fromId string, toId string) error { + return sessionManager.TransferPrimary(fromId, toId) +} + +func rpcGetSessionConfig() (map[string]interface{}, error) { + maxSessions := 10 + primaryTimeout := 300 + + if config != nil && config.MultiSession != nil { + if config.MultiSession.MaxSessions > 0 { + maxSessions = config.MultiSession.MaxSessions + } + if config.MultiSession.PrimaryTimeout > 0 { + primaryTimeout = config.MultiSession.PrimaryTimeout + } + } + + return map[string]interface{}{ + "enabled": true, + "maxSessions": maxSessions, + "primaryTimeout": primaryTimeout, + "allowCloudOverride": true, + }, nil +} + var ( keyboardMacroCancel context.CancelFunc keyboardMacroLock sync.Mutex @@ -1119,8 +1457,9 @@ func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error { IsPaste: true, } - if currentSession != nil { - currentSession.reportHidRPCKeyboardMacroState(s) + // Report to primary session if exists + if primarySession := sessionManager.GetPrimarySession(); primarySession != nil { + primarySession.reportHidRPCKeyboardMacroState(s) } err := rpcDoExecuteKeyboardMacro(ctx, macro) @@ -1128,8 +1467,8 @@ func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error { setKeyboardMacroCancel(nil) s.State = false - if currentSession != nil { - currentSession.reportHidRPCKeyboardMacroState(s) + if primarySession := sessionManager.GetPrimarySession(); primarySession != nil { + primarySession.reportHidRPCKeyboardMacroState(s) } return err @@ -1269,4 +1608,10 @@ var rpcHandlers = map[string]RPCHandler{ "setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}}, "getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly}, "setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}}, + "getSessions": {Func: rpcGetSessions}, + "getSessionData": {Func: rpcGetSessionData, Params: []string{"sessionId"}}, + "getSessionConfig": {Func: rpcGetSessionConfig}, + "requestPrimary": {Func: rpcRequestPrimary, Params: []string{"sessionId"}}, + "releasePrimary": {Func: rpcReleasePrimary, Params: []string{"sessionId"}}, + "transferPrimary": {Func: rpcTransferPrimary, Params: []string{"fromId", "toId"}}, } diff --git a/main.go b/main.go index 81c854315..b595411fb 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,22 @@ var appCtx context.Context func Main() { LoadConfig() + // Initialize currentSessionSettings to use config's persistent SessionSettings + if config.SessionSettings == nil { + config.SessionSettings = &SessionSettings{ + RequireApproval: false, + RequireNickname: false, + ReconnectGrace: 10, + PrivateKeystrokes: false, + MaxRejectionAttempts: 3, + } + _ = SaveConfig() + } + 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() @@ -94,7 +110,8 @@ func Main() { continue } - if currentSession != nil { + // Skip update if there's an active primary session + if primarySession := sessionManager.GetPrimarySession(); primarySession != nil { logger.Debug().Msg("skipping update since a session is active") time.Sleep(1 * time.Minute) continue diff --git a/native.go b/native.go index e8eea745b..e74335e91 100644 --- a/native.go +++ b/native.go @@ -48,12 +48,21 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) { } }, OnVideoFrameReceived: func(frame []byte, duration time.Duration) { - if currentSession != nil { - err := currentSession.VideoTrack.WriteSample(media.Sample{Data: frame, Duration: duration}) - if err != nil { - nativeLogger.Warn().Err(err).Msg("error writing sample") + sessionManager.ForEachSession(func(s *Session) { + if !sessionManager.CanReceiveVideo(s, currentSessionSettings) { + return } - } + + if s.VideoTrack != nil { + err := s.VideoTrack.WriteSample(media.Sample{Data: frame, Duration: duration}) + if err != nil { + nativeLogger.Warn(). + Str("sessionID", s.ID). + Err(err). + Msg("error writing sample to session") + } + } + }) }, }) nativeInstance.Start() diff --git a/network.go b/network.go index b808d6fed..ff5d1de11 100644 --- a/network.go +++ b/network.go @@ -62,12 +62,7 @@ func initNetwork() error { }, OnDhcpLeaseChange: func(lease *udhcpc.Lease, state *network.NetworkInterfaceState) { networkStateChanged(state.IsOnline()) - - if currentSession == nil { - return - } - - writeJSONRPCEvent("networkState", networkState.RpcGetNetworkState(), currentSession) + broadcastJSONRPCEvent("networkState", networkState.RpcGetNetworkState()) }, OnConfigChange: func(networkConfig *network.NetworkConfig) { config.NetworkConfig = networkConfig diff --git a/ota.go b/ota.go index bf0828dcc..e1520ccec 100644 --- a/ota.go +++ b/ota.go @@ -302,11 +302,7 @@ var otaState = OTAState{} func triggerOTAStateUpdate() { go func() { - if currentSession == nil { - logger.Info().Msg("No active RPC session, skipping update state update") - return - } - writeJSONRPCEvent("otaState", otaState, currentSession) + broadcastJSONRPCEvent("otaState", otaState) }() } diff --git a/serial.go b/serial.go index 5439d135a..c0702eae8 100644 --- a/serial.go +++ b/serial.go @@ -57,12 +57,10 @@ func runATXControl() { newBtnRSTState := line[2] == '1' newBtnPWRState := line[3] == '1' - if currentSession != nil { - writeJSONRPCEvent("atxState", ATXState{ - Power: newLedPWRState, - HDD: newLedHDDState, - }, currentSession) - } + broadcastJSONRPCEvent("atxState", ATXState{ + Power: newLedPWRState, + HDD: newLedHDDState, + }) if newLedHDDState != ledHDDState || newLedPWRState != ledPWRState || @@ -210,9 +208,7 @@ func runDCControl() { // Update Prometheus metrics updateDCMetrics(dcState) - if currentSession != nil { - writeJSONRPCEvent("dcState", dcState, currentSession) - } + broadcastJSONRPCEvent("dcState", dcState) } } @@ -284,9 +280,16 @@ func reopenSerialPort() error { return nil } -func handleSerialChannel(d *webrtc.DataChannel) { +func handleSerialChannel(d *webrtc.DataChannel, session *Session) { scopedLogger := serialLogger.With(). - Uint16("data_channel_id", *d.ID()).Logger() + Uint16("data_channel_id", *d.ID()). + Str("session_id", session.ID).Logger() + + // Check serial access permission + if !session.HasPermission(PermissionSerialAccess) { + handlePermissionDeniedChannel(d, "Serial port access denied: Permission required") + return + } d.OnOpen(func() { go func() { diff --git a/session_manager.go b/session_manager.go new file mode 100644 index 000000000..7aa383796 --- /dev/null +++ b/session_manager.go @@ -0,0 +1,1838 @@ +package kvm + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog" +) + +// SessionMode and constants are now imported from internal/session via session_permissions.go + +var ( + ErrMaxSessionsReached = errors.New("maximum number of sessions reached") +) + +type SessionData struct { + ID string `json:"id"` + Mode SessionMode `json:"mode"` + Source string `json:"source"` + Identity string `json:"identity"` + Nickname string `json:"nickname,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastActive time.Time `json:"last_active"` +} + +// Event types for JSON-RPC notifications +type ( + SessionsUpdateEvent struct { + Sessions []SessionData `json:"sessions"` + YourMode SessionMode `json:"yourMode"` + } + + NewSessionPendingEvent struct { + SessionID string `json:"sessionId"` + Source string `json:"source"` + Identity string `json:"identity"` + Nickname string `json:"nickname,omitempty"` + } + + PrimaryRequestEvent struct { + RequestID string `json:"requestId"` + Source string `json:"source"` + Identity string `json:"identity"` + Nickname string `json:"nickname,omitempty"` + } +) + +// TransferBlacklistEntry prevents recently demoted sessions from immediately becoming primary again +type TransferBlacklistEntry struct { + SessionID string + ExpiresAt time.Time +} + +// Broadcast throttling to prevent DoS +var ( + lastBroadcast time.Time + broadcastMutex sync.Mutex + broadcastDelay = 100 * time.Millisecond // Min time between broadcasts + + // Pre-allocated event maps to reduce allocations + modePrimaryEvent = map[string]string{"mode": "primary"} + modeObserverEvent = map[string]string{"mode": "observer"} +) + +type SessionManager struct { + mu sync.RWMutex // 24 bytes - place first for better alignment + primaryTimeout time.Duration // 8 bytes + logger *zerolog.Logger // 8 bytes + sessions map[string]*Session // 8 bytes + reconnectGrace map[string]time.Time // 8 bytes + reconnectInfo map[string]*SessionData // 8 bytes + transferBlacklist []TransferBlacklistEntry // Prevent demoted sessions from immediate re-promotion + queueOrder []string // 24 bytes (slice header) + primarySessionID string // 16 bytes + lastPrimaryID string // 16 bytes + maxSessions int // 8 bytes + cleanupCancel context.CancelFunc // For stopping cleanup goroutine + + // Emergency promotion tracking for safety + lastEmergencyPromotion time.Time + consecutiveEmergencyPromotions int +} + +// NewSessionManager creates a new session manager +func NewSessionManager(logger *zerolog.Logger) *SessionManager { + // Use configuration values if available + maxSessions := 10 + primaryTimeout := 5 * time.Minute + + if config != nil && config.MultiSession != nil { + if config.MultiSession.MaxSessions > 0 { + maxSessions = config.MultiSession.MaxSessions + } + if config.MultiSession.PrimaryTimeout > 0 { + primaryTimeout = time.Duration(config.MultiSession.PrimaryTimeout) * time.Second + } + } + + // Override with session settings if available + if currentSessionSettings != nil { + if currentSessionSettings.PrimaryTimeout > 0 { + primaryTimeout = time.Duration(currentSessionSettings.PrimaryTimeout) * time.Second + } + if currentSessionSettings.MaxSessions > 0 { + maxSessions = currentSessionSettings.MaxSessions + } + } + + sm := &SessionManager{ + sessions: make(map[string]*Session), + reconnectGrace: make(map[string]time.Time), + reconnectInfo: make(map[string]*SessionData), + transferBlacklist: make([]TransferBlacklistEntry, 0), + queueOrder: make([]string, 0), + logger: logger, + maxSessions: maxSessions, + primaryTimeout: primaryTimeout, + } + + // Start background cleanup of inactive sessions + ctx, cancel := context.WithCancel(context.Background()) + sm.cleanupCancel = cancel + go sm.cleanupInactiveSessions(ctx) + + return sm +} + +func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSettings) error { + // Basic input validation + if session == nil { + sm.logger.Error().Msg("AddSession: session is nil") + return errors.New("session cannot be nil") + } + + // Validate nickname if provided (matching frontend validation) + if session.Nickname != "" { + if len(session.Nickname) < 2 { + return errors.New("nickname must be at least 2 characters") + } + if len(session.Nickname) > 30 { + return errors.New("nickname must be 30 characters or less") + } + // Note: Pattern validation is done in RPC layer, not here for performance + } + if len(session.Identity) > 256 { + return errors.New("identity too long") + } + + 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 + if graceTime, exists := sm.reconnectGrace[session.ID]; exists { + if time.Now().Before(graceTime) { + wasWithinGracePeriod = true + wasPreviouslyPrimary = (sm.lastPrimaryID == session.ID) + if reconnectInfo, hasInfo := sm.reconnectInfo[session.ID]; hasInfo { + wasPreviouslyPending = (reconnectInfo.Mode == SessionModePending) + } + } + } + + // Check if a session with this ID already exists (reconnection) + if existing, exists := sm.sessions[session.ID]; exists { + // 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 + if existing.peerConnection != nil { + existing.peerConnection.Close() + } + + // Update the existing session with new connection details + existing.peerConnection = session.peerConnection + existing.VideoTrack = session.VideoTrack + existing.ControlChannel = session.ControlChannel + existing.RPCChannel = session.RPCChannel + existing.HidChannel = session.HidChannel + existing.LastActive = time.Now() + existing.flushCandidates = session.flushCandidates + // Preserve existing mode and nickname + session.Mode = existing.Mode + session.Nickname = existing.Nickname + session.CreatedAt = existing.CreatedAt + + // Ensure session has auto-generated nickname if needed + sm.ensureNickname(session) + + sm.sessions[session.ID] = session + + // If this was the primary, try to restore primary status + if existing.Mode == SessionModePrimary { + 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 { + sm.primarySessionID = session.ID + sm.lastPrimaryID = "" + delete(sm.reconnectGrace, session.ID) + } else { + // Grace period expired, another session took over, or primary already exists + session.Mode = SessionModeObserver + } + } + + go sm.broadcastSessionListUpdate() + return nil + } + + if len(sm.sessions) >= sm.maxSessions { + return ErrMaxSessionsReached + } + + // Generate ID if not set + if session.ID == "" { + session.ID = uuid.New().String() + } + + // Set nickname from client settings if provided + if clientSettings != nil && clientSettings.Nickname != "" { + session.Nickname = clientSettings.Nickname + } + + // Use global settings for requirements (not client-provided) + globalSettings := currentSessionSettings + + primaryExists := sm.primarySessionID != "" && sm.sessions[sm.primarySessionID] != nil + + // Check if there's an active grace period for a primary session (different from this session) + hasActivePrimaryGracePeriod := false + if sm.lastPrimaryID != "" && sm.lastPrimaryID != session.ID { + if graceTime, exists := sm.reconnectGrace[sm.lastPrimaryID]; exists { + if time.Now().Before(graceTime) { + if reconnectInfo, hasInfo := sm.reconnectInfo[sm.lastPrimaryID]; hasInfo { + if reconnectInfo.Mode == SessionModePrimary { + hasActivePrimaryGracePeriod = true + } + } + } + } + } + + 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)) + + if shouldBecomePrimary { + if sm.primarySessionID == "" || sm.sessions[sm.primarySessionID] == nil { + session.Mode = SessionModePrimary + sm.primarySessionID = session.ID + sm.lastPrimaryID = "" + + // 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) + } + + session.hidRPCAvailable = false + } else { + session.Mode = SessionModeObserver + } + } else if wasPreviouslyPending { + session.Mode = SessionModePending + } else if globalSettings != nil && globalSettings.RequireApproval && primaryExists && !wasWithinGracePeriod { + session.Mode = SessionModePending + // Notify primary about the pending session, but only if nickname is not required OR already provided + if primary := sm.sessions[sm.primarySessionID]; primary != nil { + // Check if nickname is required and missing + requiresNickname := globalSettings.RequireNickname + hasNickname := session.Nickname != "" && len(session.Nickname) > 0 + + // Only send approval request if nickname is not required OR already provided + if !requiresNickname || hasNickname { + go func() { + writeJSONRPCEvent("newSessionPending", map[string]interface{}{ + "sessionId": session.ID, + "source": session.Source, + "identity": session.Identity, + "nickname": session.Nickname, + }, primary) + }() + } + // If nickname is required and missing, the approval request will be sent + // later when updateSessionNickname is called (see jsonrpc.go:232-242) + } + } else { + // No primary exists and approval is required, OR approval is not required + // In either case, this session becomes an observer + session.Mode = SessionModeObserver + } + + session.CreatedAt = time.Now() + session.LastActive = time.Now() + + sm.sessions[session.ID] = session + + sm.logger.Info(). + Str("sessionID", session.ID). + Str("mode", string(session.Mode)). + Int("totalSessions", len(sm.sessions)). + Msg("Session added to manager") + + // Ensure session has auto-generated nickname if needed + sm.ensureNickname(session) + + 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() + + return nil +} + +// RemoveSession removes a session from the manager +func (sm *SessionManager) RemoveSession(sessionID string) { + sm.mu.Lock() + defer sm.mu.Unlock() + + session, exists := sm.sessions[sessionID] + if !exists { + return + } + + wasPrimary := session.Mode == SessionModePrimary + delete(sm.sessions, sessionID) + + sm.logger.Info(). + Str("sessionID", sessionID). + Bool("wasPrimary", wasPrimary). + Int("remainingSessions", len(sm.sessions)). + Msg("Session removed from manager") + + // Remove from queue if present + sm.removeFromQueue(sessionID) + + // Check if this session was marked for immediate removal (intentional logout) + isIntentionalLogout := false + if graceTime, exists := sm.reconnectGrace[sessionID]; exists { + if time.Now().After(graceTime) { + isIntentionalLogout = true + delete(sm.reconnectGrace, sessionID) + delete(sm.reconnectInfo, sessionID) + } + } + + // Determine grace period duration (used for logging even if intentional logout) + gracePeriod := 10 + if currentSessionSettings != nil && currentSessionSettings.ReconnectGrace > 0 { + gracePeriod = currentSessionSettings.ReconnectGrace + } + + // Only add grace period if this is NOT an intentional logout + if !isIntentionalLogout { + // Limit grace period entries to prevent memory exhaustion + const maxGraceEntries = 10 + for len(sm.reconnectGrace) >= maxGraceEntries { + var oldestID string + var oldestTime time.Time + for id, graceTime := range sm.reconnectGrace { + if oldestTime.IsZero() || graceTime.Before(oldestTime) { + oldestID = id + oldestTime = graceTime + } + } + if oldestID != "" { + delete(sm.reconnectGrace, oldestID) + delete(sm.reconnectInfo, oldestID) + } else { + break + } + } + + sm.reconnectGrace[sessionID] = time.Now().Add(time.Duration(gracePeriod) * time.Second) + + // Store session info for potential reconnection + sm.reconnectInfo[sessionID] = &SessionData{ + ID: session.ID, + Mode: session.Mode, + Source: session.Source, + Identity: session.Identity, + Nickname: session.Nickname, + CreatedAt: session.CreatedAt, + } + } + + // If this was the primary session, clear primary slot and track for grace period + if wasPrimary { + if isIntentionalLogout { + // Intentional logout: clear immediately and promote right away + sm.primarySessionID = "" + sm.lastPrimaryID = "" + sm.logger.Info(). + Str("sessionID", sessionID). + Int("remainingSessions", len(sm.sessions)). + Msg("Primary session removed via intentional logout - immediate promotion") + } else { + // Accidental disconnect: use grace period + 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 + if len(sm.transferBlacklist) > 0 { + sm.transferBlacklist = make([]TransferBlacklistEntry, 0) + } + + sm.logger.Info(). + Str("sessionID", sessionID). + Dur("gracePeriod", time.Duration(gracePeriod)*time.Second). + Int("remainingSessions", len(sm.sessions)). + Msg("Primary session removed, grace period active") + } + + // Trigger validation for potential promotion + if len(sm.sessions) > 0 { + sm.validateSinglePrimary() + } + } + + // Notify remaining sessions + go sm.broadcastSessionListUpdate() +} + +// GetSession returns a session by ID +func (sm *SessionManager) GetSession(sessionID string) *Session { + sm.mu.RLock() + session := sm.sessions[sessionID] + sm.mu.RUnlock() + return session +} + +// IsValidReconnection checks if a session ID can be reused for reconnection +func (sm *SessionManager) IsValidReconnection(sessionID, source, identity string) bool { + sm.mu.RLock() + defer sm.mu.RUnlock() + + // Check if session is in reconnect grace period + if info, exists := sm.reconnectInfo[sessionID]; exists { + // Verify the source and identity match + return info.Source == source && info.Identity == identity + } + + return false +} + +// IsInGracePeriod checks if a session ID is within the reconnection grace period +func (sm *SessionManager) IsInGracePeriod(sessionID string) bool { + sm.mu.RLock() + defer sm.mu.RUnlock() + + if graceTime, exists := sm.reconnectGrace[sessionID]; exists { + return time.Now().Before(graceTime) + } + return false +} + +// ClearGracePeriod removes the grace period for a session (for intentional logout/disconnect) +// This marks the session for immediate removal without grace period protection +// Actual promotion will happen in RemoveSession when it detects no grace period +func (sm *SessionManager) ClearGracePeriod(sessionID string) { + sm.mu.Lock() + defer sm.mu.Unlock() + + // Clear grace period and reconnect info to prevent grace period from being added + delete(sm.reconnectGrace, sessionID) + delete(sm.reconnectInfo, sessionID) + + // Mark this session with a special "immediate removal" grace period (already expired) + // This signals to RemoveSession that this was intentional and should skip grace period + sm.reconnectGrace[sessionID] = time.Now().Add(-1 * time.Second) // Already expired + + sm.logger.Info(). + Str("sessionID", sessionID). + Str("lastPrimaryID", sm.lastPrimaryID). + Str("primarySessionID", sm.primarySessionID). + Msg("Marked session for immediate removal (intentional logout)") +} + +// isSessionBlacklisted checks if a session was recently demoted via transfer and should not become primary +func (sm *SessionManager) isSessionBlacklisted(sessionID string) bool { + now := time.Now() + + // Clean expired entries while we're here + validEntries := make([]TransferBlacklistEntry, 0, len(sm.transferBlacklist)) + for _, entry := range sm.transferBlacklist { + if now.Before(entry.ExpiresAt) { + validEntries = append(validEntries, entry) + if entry.SessionID == sessionID { + return true // Found active blacklist entry + } + } + } + sm.transferBlacklist = validEntries // Update with only non-expired entries + + return false +} + +// GetPrimarySession returns the current primary session +func (sm *SessionManager) GetPrimarySession() *Session { + sm.mu.RLock() + if sm.primarySessionID == "" { + sm.mu.RUnlock() + return nil + } + session := sm.sessions[sm.primarySessionID] + sm.mu.RUnlock() + return session +} + +// SetPrimarySession sets a session as primary +func (sm *SessionManager) SetPrimarySession(sessionID string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + session, exists := sm.sessions[sessionID] + if !exists { + return ErrSessionNotFound + } + + session.Mode = SessionModePrimary + sm.primarySessionID = sessionID + sm.lastPrimaryID = "" + return nil +} + +// CanReceiveVideo checks if a session is allowed to receive video +// Sessions in pending state cannot receive video +// Sessions that require nickname but don't have one also cannot receive video (if enforced) +func (sm *SessionManager) CanReceiveVideo(session *Session, settings *SessionSettings) bool { + // Check if session has video view permission + if !session.HasPermission(PermissionVideoView) { + return false + } + + // If nickname is required and session doesn't have one, block video + if settings != nil && settings.RequireNickname && session.Nickname == "" { + return false + } + + return true +} + +// GetAllSessions returns information about all active sessions +func (sm *SessionManager) GetAllSessions() []SessionData { + sm.mu.RLock() + defer sm.mu.RUnlock() + + // Don't run validation on every getSessions call + // This was causing immediate demotion during transfers and page refreshes + // Validation should only run during state changes, not data queries + + infos := make([]SessionData, 0, len(sm.sessions)) + for _, session := range sm.sessions { + infos = append(infos, SessionData{ + ID: session.ID, + Mode: session.Mode, + Source: session.Source, + Identity: session.Identity, + Nickname: session.Nickname, + CreatedAt: session.CreatedAt, + LastActive: session.LastActive, + }) + } + return infos +} + +// RequestPrimary requests primary control for a session +func (sm *SessionManager) RequestPrimary(sessionID string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + session, exists := sm.sessions[sessionID] + if !exists { + return ErrSessionNotFound + } + + // If already primary, nothing to do + if session.Mode == SessionModePrimary { + return nil + } + + // Check if there's a primary in grace period before promoting + if sm.primarySessionID == "" { + // Don't promote immediately if there's a primary waiting in grace period + if sm.lastPrimaryID != "" { + // Check if grace period is still active + if graceTime, exists := sm.reconnectGrace[sm.lastPrimaryID]; exists { + if time.Now().Before(graceTime) { + // Primary is in grace period, queue this request instead + sm.queueOrder = append(sm.queueOrder, sessionID) + session.Mode = SessionModeQueued + sm.logger.Info(). + Str("sessionID", sessionID). + Str("gracePrimaryID", sm.lastPrimaryID). + Msg("Request queued - primary session in grace period") + go sm.broadcastSessionListUpdate() + return nil + } + } + } + + // No grace period conflict, promote immediately using centralized system + err := sm.transferPrimaryRole("", sessionID, "initial_promotion", "first session auto-promotion") + if err == nil { + // Send mode change event after promoting + writeJSONRPCEvent("modeChanged", modePrimaryEvent, session) + go sm.broadcastSessionListUpdate() + } + return err + } + + // Notify the primary session about the request + if primarySession, exists := sm.sessions[sm.primarySessionID]; exists { + event := PrimaryRequestEvent{ + RequestID: sessionID, + Identity: session.Identity, + Source: session.Source, + Nickname: session.Nickname, + } + writeJSONRPCEvent("primaryControlRequested", event, primarySession) + } + + // Add to queue if not already there + if session.Mode != SessionModeQueued { + session.Mode = SessionModeQueued + sm.queueOrder = append(sm.queueOrder, sessionID) + } + + // Broadcast update in goroutine to avoid deadlock + go sm.broadcastSessionListUpdate() + return nil +} + +// ReleasePrimary releases primary control from a session +func (sm *SessionManager) ReleasePrimary(sessionID string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + session, exists := sm.sessions[sessionID] + if !exists { + return ErrSessionNotFound + } + + if session.Mode != SessionModePrimary { + return nil + } + + // Check if there are other sessions that could take control + hasOtherEligibleSessions := false + for id, s := range sm.sessions { + if id != sessionID && (s.Mode == SessionModeObserver || s.Mode == SessionModeQueued) { + hasOtherEligibleSessions = true + break + } + } + + // Don't allow releasing primary if no one else can take control + if !hasOtherEligibleSessions { + return errors.New("cannot release primary control - no other sessions available") + } + + // Demote to observer + session.Mode = SessionModeObserver + sm.primarySessionID = "" + + // Clear any active input state + sm.clearInputState() + + // Find the next session to promote (excluding the current primary) + // For voluntary releases, ignore blacklisting since this is user-initiated + promotedSessionID := sm.findNextSessionToPromoteExcludingIgnoreBlacklist(sessionID) + + // If we found someone to promote, use centralized transfer + if promotedSessionID != "" { + err := sm.transferPrimaryRole(sessionID, promotedSessionID, "release_transfer", "primary release and auto-promotion") + if err != nil { + sm.logger.Error(). + Str("error", err.Error()). + Str("releasedBySessionID", sessionID). + Str("promotedSessionID", promotedSessionID). + Msg("Failed to transfer primary role after release") + return err + } + + sm.logger.Info(). + Str("releasedBySessionID", sessionID). + Str("promotedSessionID", promotedSessionID). + Msg("Primary control released and transferred to observer") + + // Send mode change event for promoted session + go func() { + if promotedSession := sessionManager.GetSession(promotedSessionID); promotedSession != nil { + writeJSONRPCEvent("modeChanged", modePrimaryEvent, promotedSession) + } + }() + } else { + sm.logger.Warn(). + Str("releasedBySessionID", sessionID). + Msg("Primary control released but no eligible sessions found for promotion") + } + + // Broadcast update in goroutine to avoid deadlock + go sm.broadcastSessionListUpdate() + return nil +} + +// TransferPrimary transfers primary control from one session to another +func (sm *SessionManager) TransferPrimary(fromID, toID string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + // SECURITY: Verify fromID is the actual current primary + if sm.primarySessionID != fromID { + return fmt.Errorf("transfer denied: %s is not the current primary (current primary: %s)", fromID, sm.primarySessionID) + } + + fromSession, exists := sm.sessions[fromID] + if !exists { + return ErrSessionNotFound + } + + if fromSession.Mode != SessionModePrimary { + return errors.New("transfer denied: from session is not in primary mode") + } + + // Use centralized transfer method + err := sm.transferPrimaryRole(fromID, toID, "direct_transfer", "manual transfer request") + if err != nil { + return err + } + + // Send events in goroutines to avoid holding lock + go func() { + if fromSession := sessionManager.GetSession(fromID); fromSession != nil { + writeJSONRPCEvent("modeChanged", modeObserverEvent, fromSession) + } + }() + + go func() { + if toSession := sessionManager.GetSession(toID); toSession != nil { + writeJSONRPCEvent("modeChanged", modePrimaryEvent, toSession) + } + sm.broadcastSessionListUpdate() + }() + + return nil +} + +// ApprovePrimaryRequest approves a pending primary control request +func (sm *SessionManager) ApprovePrimaryRequest(currentPrimaryID, requesterID string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + // Log the approval request + sm.logger.Info(). + Str("currentPrimaryID", currentPrimaryID). + Str("requesterID", requesterID). + Str("actualPrimaryID", sm.primarySessionID). + Msg("ApprovePrimaryRequest called") + + // Verify current primary is correct + if sm.primarySessionID != currentPrimaryID { + sm.logger.Error(). + Str("currentPrimaryID", currentPrimaryID). + Str("actualPrimaryID", sm.primarySessionID). + Msg("Not the primary session") + return errors.New("not the primary session") + } + + // SECURITY: Verify requester session exists and is in Queued mode + requesterSession, exists := sm.sessions[requesterID] + if !exists { + sm.logger.Error(). + Str("requesterID", requesterID). + Msg("Requester session not found") + return errors.New("requester session not found") + } + + if requesterSession.Mode != SessionModeQueued { + sm.logger.Error(). + Str("requesterID", requesterID). + Str("actualMode", string(requesterSession.Mode)). + Msg("Requester session is not in queued mode") + return fmt.Errorf("requester session is not in queued mode (current mode: %s)", requesterSession.Mode) + } + + // Remove requester from queue + sm.removeFromQueue(requesterID) + + // Use centralized transfer method + err := sm.transferPrimaryRole(currentPrimaryID, requesterID, "approval_transfer", "primary approval request") + if err != nil { + return err + } + + // Send events after releasing lock to avoid deadlock + go func() { + if demotedSession := sessionManager.GetSession(currentPrimaryID); demotedSession != nil { + writeJSONRPCEvent("modeChanged", modeObserverEvent, demotedSession) + } + }() + + go func() { + if promotedSession := sessionManager.GetSession(requesterID); promotedSession != nil { + writeJSONRPCEvent("modeChanged", modePrimaryEvent, promotedSession) + } + sm.broadcastSessionListUpdate() + }() + + return nil +} + +// DenyPrimaryRequest denies a pending primary control request +func (sm *SessionManager) DenyPrimaryRequest(currentPrimaryID, requesterID string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + // Verify current primary is correct + if sm.primarySessionID != currentPrimaryID { + return errors.New("not the primary session") + } + + requester, exists := sm.sessions[requesterID] + if !exists { + return ErrSessionNotFound + } + + // Move requester back to observer + requester.Mode = SessionModeObserver + sm.removeFromQueue(requesterID) + + // Validate session consistency after mode change + sm.validateSinglePrimary() + + // Notify requester of denial in goroutine + go func() { + writeJSONRPCEvent("primaryControlDenied", map[string]interface{}{}, requester) + sm.broadcastSessionListUpdate() + }() + + 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() + // Create a copy of sessions to avoid holding lock during callbacks + sessionsCopy := make([]*Session, 0, len(sm.sessions)) + for _, session := range sm.sessions { + sessionsCopy = append(sessionsCopy, session) + } + sm.mu.RUnlock() + + // Call function outside of lock to prevent deadlocks + for _, session := range sessionsCopy { + fn(session) + } +} + +// UpdateLastActive updates the last active time for a session +func (sm *SessionManager) UpdateLastActive(sessionID string) { + sm.mu.Lock() + if session, exists := sm.sessions[sessionID]; exists { + session.LastActive = time.Now() + } + sm.mu.Unlock() +} + +// Internal helper methods + +// validateSinglePrimary ensures there's only one primary session and fixes any inconsistencies +func (sm *SessionManager) validateSinglePrimary() { + primarySessions := make([]*Session, 0) + + // Find all sessions that think they're primary + for _, session := range sm.sessions { + if session.Mode == SessionModePrimary { + primarySessions = append(primarySessions, session) + } + } + + // If we have multiple primaries, fix it + if len(primarySessions) > 1 { + sm.logger.Error(). + Int("primaryCount", len(primarySessions)). + Msg("Multiple primary sessions detected, fixing") + + // Keep the first one as primary, demote the rest + for i, session := range primarySessions { + if i == 0 { + sm.primarySessionID = session.ID + } else { + session.Mode = SessionModeObserver + } + } + } + + // Ensure manager's primarySessionID matches reality + if len(primarySessions) == 1 && sm.primarySessionID != primarySessions[0].ID { + sm.logger.Warn(). + Str("managerPrimaryID", sm.primarySessionID). + Str("actualPrimaryID", primarySessions[0].ID). + Msg("Manager primary ID mismatch, fixing...") + sm.primarySessionID = primarySessions[0].ID + } + + // Don't clear primary slot if there's a grace period active + if len(primarySessions) == 0 && sm.primarySessionID != "" { + 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 + } + } + } + sm.primarySessionID = "" + } + + // Check if there's an active grace period for any primary session + hasActivePrimaryGracePeriod := false + for sessionID, graceTime := range sm.reconnectGrace { + if time.Now().Before(graceTime) { + if reconnectInfo, hasInfo := sm.reconnectInfo[sessionID]; hasInfo { + if reconnectInfo.Mode == SessionModePrimary { + hasActivePrimaryGracePeriod = true + break + } + } + } + } + + // 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 + nextSessionID := sm.findNextSessionToPromote() + if nextSessionID != "" { + sm.logger.Info(). + Str("promotedSessionID", nextSessionID). + Msg("Auto-promoting observer to primary - no primary sessions exist and no grace period active") + + // Use the centralized promotion logic + err := sm.transferPrimaryRole("", nextSessionID, "emergency_auto_promotion", "no primary sessions detected") + if err != nil { + sm.logger.Error(). + Err(err). + Str("sessionID", nextSessionID). + Msg("Failed to auto-promote session to primary") + } + } else { + sm.logger.Warn(). + Msg("No eligible session found for emergency auto-promotion") + } + } +} + +// transferPrimaryRole is the centralized method for all primary role transfers +// It handles bidirectional blacklisting and logging consistently across all transfer types +func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transferType, context string) error { + // Validate sessions exist + toSession, toExists := sm.sessions[toSessionID] + if !toExists { + return ErrSessionNotFound + } + + // SECURITY: Prevent promoting a session that's already primary + if toSession.Mode == SessionModePrimary { + sm.logger.Warn(). + Str("sessionID", toSessionID). + Str("transferType", transferType). + Msg("Attempted to promote session that is already primary") + return errors.New("target session is already primary") + } + + var fromSession *Session + var fromExists bool + if fromSessionID != "" { + fromSession, fromExists = sm.sessions[fromSessionID] + if !fromExists { + return ErrSessionNotFound + } + } + + // Demote existing primary if specified + 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) + + sm.logger.Info(). + Str("demotedSessionID", fromSessionID). + Str("transferType", transferType). + Str("context", context). + Msg("Demoted existing primary session") + } + + // SECURITY: Before promoting, verify there are no other primary sessions + for id, sess := range sm.sessions { + if id != toSessionID && sess.Mode == SessionModePrimary { + sm.logger.Error(). + Str("existingPrimaryID", id). + Str("targetPromotionID", toSessionID). + Str("transferType", transferType). + Msg("CRITICAL: Attempted to create second primary - blocking promotion") + return fmt.Errorf("cannot promote: another primary session exists (%s)", id) + } + } + + // Promote target session + 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 + + // Clear input state + sm.clearInputState() + + // Reset consecutive emergency promotion counter on successful manual transfer + if fromSessionID != "" && transferType != "emergency_promotion_deadlock_prevention" && transferType != "emergency_timeout_promotion" { + sm.consecutiveEmergencyPromotions = 0 + } + + // 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) + } + } + 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++ + } + } + } + + // 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). + Str("toSessionID", toSessionID). + Str("transferType", transferType). + Str("context", context). + Int("blacklistedSessions", blacklistedCount). + 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 + + // Handle WebRTC connection state for promoted sessions + // When a session changes from observer to primary, the existing WebRTC connection + // was established for observer mode and needs to be re-negotiated for primary mode + if toExists && (transferType == "emergency_timeout_promotion" || transferType == "emergency_auto_promotion") { + go func() { + // Small delay to ensure session mode changes are committed + time.Sleep(100 * time.Millisecond) + + // Send connection reset signal to the promoted session + writeJSONRPCEvent("connectionModeChanged", map[string]interface{}{ + "sessionId": toSessionID, + "newMode": string(toSession.Mode), + "reason": "session_promotion", + "action": "reconnect_required", + "timestamp": time.Now().Unix(), + }, toSession) + + sm.logger.Info(). + Str("sessionId", toSessionID). + Str("newMode", string(toSession.Mode)). + Str("transferType", transferType). + Msg("Sent WebRTC reconnection signal to promoted session") + }() + } + + return nil +} + +// findNextSessionToPromote finds the next eligible session for promotion +// Replicates the logic from promoteNextSession but just returns the session ID +func (sm *SessionManager) findNextSessionToPromote() string { + return sm.findNextSessionToPromoteExcluding("", true) +} + +func (sm *SessionManager) findNextSessionToPromoteExcluding(excludeSessionID string, checkBlacklist bool) string { + // First, check if there are queued sessions (excluding the specified session) + if len(sm.queueOrder) > 0 { + nextID := sm.queueOrder[0] + if nextID != excludeSessionID { + if _, exists := sm.sessions[nextID]; exists { + if !checkBlacklist || !sm.isSessionBlacklisted(nextID) { + return nextID + } + } + } + } + + // Otherwise, find any observer session (excluding the specified session) + for id, session := range sm.sessions { + if id != excludeSessionID && session.Mode == SessionModeObserver { + if !checkBlacklist || !sm.isSessionBlacklisted(id) { + return id + } + } + } + + // If still no primary and there are pending sessions (edge case: all sessions are pending) + // This can happen if RequireApproval was enabled but primary left + for id, session := range sm.sessions { + if id != excludeSessionID && session.Mode == SessionModePending { + if !checkBlacklist || !sm.isSessionBlacklisted(id) { + return id + } + } + } + + return "" // No eligible session found +} + +func (sm *SessionManager) findNextSessionToPromoteExcludingIgnoreBlacklist(excludeSessionID string) string { + return sm.findNextSessionToPromoteExcluding(excludeSessionID, false) +} + +func (sm *SessionManager) removeFromQueue(sessionID string) { + // In-place removal is more efficient + for i, id := range sm.queueOrder { + if id == sessionID { + sm.queueOrder = append(sm.queueOrder[:i], sm.queueOrder[i+1:]...) + return + } + } +} + +func (sm *SessionManager) clearInputState() { + // Clear keyboard state + if gadget != nil { + _ = gadget.KeyboardReport(0, []byte{0, 0, 0, 0, 0, 0}) + } +} + +// getCurrentPrimaryTimeout returns the current primary timeout duration +func (sm *SessionManager) getCurrentPrimaryTimeout() time.Duration { + // Use session settings if available + if currentSessionSettings != nil { + if currentSessionSettings.PrimaryTimeout == 0 { + // 0 means disabled - return a very large duration + return 24 * time.Hour + } else if currentSessionSettings.PrimaryTimeout > 0 { + return time.Duration(currentSessionSettings.PrimaryTimeout) * time.Second + } + } + // Fall back to config or default + return sm.primaryTimeout +} + +// getSessionTrustScore calculates a trust score for session selection during emergency promotion +func (sm *SessionManager) getSessionTrustScore(sessionID string) int { + session, exists := sm.sessions[sessionID] + if !exists { + return -1000 // Session doesn't exist + } + + score := 0 + now := time.Now() + + // Longer session duration = more trust (up to 100 points for 100+ minutes) + sessionAge := now.Sub(session.CreatedAt) + score += int(sessionAge.Minutes()) + if score > 100 { + score = 100 // Cap age bonus at 100 points + } + + // Recently successful primary sessions get higher trust + if sm.lastPrimaryID == sessionID { + score += 50 + } + + // Observer mode is more trustworthy than queued/pending for emergency promotion + switch session.Mode { + case SessionModeObserver: + score += 20 + case SessionModeQueued: + score += 10 + case SessionModePending: + // Pending sessions get no bonus and are less preferred + score += 0 + } + + // Check if session has nickname when required (shows engagement) + if currentSessionSettings != nil && currentSessionSettings.RequireNickname { + if session.Nickname != "" { + score += 15 + } else { + score -= 30 // Penalize sessions without required nickname + } + } + + return score +} + +// findMostTrustedSessionForEmergency finds the most trustworthy session for emergency promotion +func (sm *SessionManager) findMostTrustedSessionForEmergency() string { + bestSessionID := "" + bestScore := -1 + + // First pass: try to find observers or queued sessions (preferred) + for sessionID, session := range sm.sessions { + // Skip if blacklisted, primary, or not eligible modes + if sm.isSessionBlacklisted(sessionID) || + session.Mode == SessionModePrimary || + (session.Mode != SessionModeObserver && session.Mode != SessionModeQueued) { + continue + } + + score := sm.getSessionTrustScore(sessionID) + if score > bestScore { + bestScore = score + bestSessionID = sessionID + } + } + + // If no observers/queued found, try pending sessions as last resort + if bestSessionID == "" { + for sessionID, session := range sm.sessions { + if sm.isSessionBlacklisted(sessionID) || session.Mode == SessionModePrimary { + continue + } + + if session.Mode == SessionModePending { + score := sm.getSessionTrustScore(sessionID) + if score > bestScore { + bestScore = score + bestSessionID = sessionID + } + } + } + } + + // Log the selection decision for audit trail + if bestSessionID != "" { + sm.logger.Info(). + Str("selectedSession", bestSessionID). + Int("trustScore", bestScore). + Msg("Selected most trusted session for emergency promotion") + } + + return bestSessionID +} + +// extractBrowserFromUserAgent extracts browser name from user agent string +func extractBrowserFromUserAgent(userAgent string) *string { + ua := strings.ToLower(userAgent) + + // Check for common browsers (order matters - Chrome contains Safari, etc.) + if strings.Contains(ua, "edg/") || strings.Contains(ua, "edge") { + return &BrowserEdge + } + if strings.Contains(ua, "firefox") { + return &BrowserFirefox + } + if strings.Contains(ua, "chrome") { + return &BrowserChrome + } + if strings.Contains(ua, "safari") && !strings.Contains(ua, "chrome") { + return &BrowserSafari + } + if strings.Contains(ua, "opera") || strings.Contains(ua, "opr/") { + return &BrowserOpera + } + + return &BrowserUnknown +} + +// generateAutoNickname creates a user-friendly auto-generated nickname +func generateAutoNickname(session *Session) string { + // Use browser type from session, fallback to "user" if not set + browser := "user" + if session.Browser != nil { + browser = *session.Browser + } + + // Use last 4 chars of session ID for uniqueness (lowercase) + sessionID := strings.ToLower(session.ID) + shortID := sessionID[len(sessionID)-4:] + + // Generate contextual lowercase nickname + return fmt.Sprintf("u-%s-%s", browser, shortID) +} + +// generateNicknameFromUserAgent creates a nickname from user agent (for frontend use) +func generateNicknameFromUserAgent(userAgent string) string { + // Extract browser info + browserPtr := extractBrowserFromUserAgent(userAgent) + browser := "user" + if browserPtr != nil { + browser = *browserPtr + } + + // Generate a random 4-character ID (lowercase) + shortID := strings.ToLower(fmt.Sprintf("%04x", time.Now().UnixNano()%0xFFFF)) + + // Generate contextual lowercase nickname + return fmt.Sprintf("u-%s-%s", browser, shortID) +} + +// ensureNickname ensures session has a nickname, auto-generating if needed +func (sm *SessionManager) ensureNickname(session *Session) { + // Skip if session already has a nickname + if session.Nickname != "" { + return + } + + // Skip if nickname is required (user must set manually) + if currentSessionSettings != nil && currentSessionSettings.RequireNickname { + return + } + + // Auto-generate nickname + session.Nickname = generateAutoNickname(session) + + sm.logger.Debug(). + Str("sessionID", session.ID). + Str("autoNickname", session.Nickname). + Msg("Auto-generated nickname for session") +} + +// updateAllSessionNicknames updates nicknames for all sessions when settings change +func (sm *SessionManager) updateAllSessionNicknames() { + sm.mu.Lock() + defer sm.mu.Unlock() + + updated := 0 + for _, session := range sm.sessions { + oldNickname := session.Nickname + sm.ensureNickname(session) + if session.Nickname != oldNickname { + updated++ + } + } + + if updated > 0 { + sm.logger.Info(). + Int("updatedSessions", updated). + Msg("Auto-generated nicknames for sessions after settings change") + + // Broadcast the update + go sm.broadcastSessionListUpdate() + } +} + +func (sm *SessionManager) broadcastSessionListUpdate() { + // Throttle broadcasts to prevent DoS + broadcastMutex.Lock() + if time.Since(lastBroadcast) < broadcastDelay { + broadcastMutex.Unlock() + return // Skip this broadcast to prevent storm + } + lastBroadcast = time.Now() + broadcastMutex.Unlock() + + // Must be called in a goroutine to avoid deadlock + // Get all sessions first - use read lock only, no validation during broadcasts + sm.mu.RLock() + + // Build session infos and collect active sessions in one pass + infos := make([]SessionData, 0, len(sm.sessions)) + activeSessions := make([]*Session, 0, len(sm.sessions)) + + for _, session := range sm.sessions { + infos = append(infos, SessionData{ + ID: session.ID, + Mode: session.Mode, + Source: session.Source, + Identity: session.Identity, + Nickname: session.Nickname, + CreatedAt: session.CreatedAt, + LastActive: session.LastActive, + }) + + // Only collect sessions ready for broadcast + if session.RPCChannel != nil { + activeSessions = append(activeSessions, session) + } + } + + sm.mu.RUnlock() + + // Now send events without holding lock + for _, session := range activeSessions { + // Per-session throttling to prevent broadcast storms + if time.Since(session.LastBroadcast) < 50*time.Millisecond { + continue + } + session.LastBroadcast = time.Now() + event := SessionsUpdateEvent{ + Sessions: infos, + YourMode: session.Mode, + } + writeJSONRPCEvent("sessionsUpdated", event, session) + } +} + +// Shutdown stops the session manager and cleans up resources +func (sm *SessionManager) Shutdown() { + if sm.cleanupCancel != nil { + sm.cleanupCancel() + } + + sm.mu.Lock() + defer sm.mu.Unlock() + + // Clean up all sessions + for id := range sm.sessions { + delete(sm.sessions, id) + } +} + +func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) { + ticker := time.NewTicker(1 * time.Second) // Check every second for grace periods + defer ticker.Stop() + + pendingTimeout := 1 * time.Minute // Reduced from 5 minutes to prevent DoS + validationCounter := 0 // Counter for periodic validateSinglePrimary calls + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + sm.mu.Lock() + now := time.Now() + needsBroadcast := false + + // Check for expired grace periods and promote if needed + gracePeriodExpired := false + for sessionID, graceTime := range sm.reconnectGrace { + if now.After(graceTime) { + delete(sm.reconnectGrace, sessionID) + gracePeriodExpired = true + + wasHoldingPrimarySlot := (sm.lastPrimaryID == sessionID) + + // Check if this expired session was the primary holding the slot + if wasHoldingPrimarySlot { + // The primary didn't reconnect in time, now we can clear the slot and promote + sm.primarySessionID = "" + sm.lastPrimaryID = "" + needsBroadcast = true + + sm.logger.Info(). + Str("expiredSessionID", sessionID). + Msg("Primary session grace period expired - slot now available") + + // Always try to promote when possible - approval is only for new pending sessions + // Use enhanced emergency promotion system for better security + isEmergencyPromotion := false + var promotedSessionID string + + // Check if this is an emergency scenario (RequireApproval enabled) + 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 { + 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 + } + } + + promotedSessionID = sm.findMostTrustedSessionForEmergency() + } else { + // Normal promotion - reset consecutive counter + sm.consecutiveEmergencyPromotions = 0 + promotedSessionID = sm.findNextSessionToPromote() + } + + if promotedSessionID != "" { + // Determine reason and log appropriately + reason := "grace_expiration_promotion" + if isEmergencyPromotion { + reason = "emergency_promotion_deadlock_prevention" + sm.lastEmergencyPromotion = now + sm.consecutiveEmergencyPromotions++ + + // Enhanced logging for emergency promotions + sm.logger.Warn(). + Str("expiredSessionID", sessionID). + Str("promotedSessionID", promotedSessionID). + Bool("requireApproval", true). + Int("consecutiveEmergencyPromotions", sm.consecutiveEmergencyPromotions). + Int("trustScore", sm.getSessionTrustScore(promotedSessionID)). + Msg("EMERGENCY: Bypassing approval requirement to prevent deadlock") + } + + err := sm.transferPrimaryRole("", promotedSessionID, reason, "primary grace period expired") + if err == nil { + logEvent := sm.logger.Info() + if isEmergencyPromotion { + logEvent = sm.logger.Warn() + } + logEvent. + Str("expiredSessionID", sessionID). + Str("promotedSessionID", promotedSessionID). + Str("reason", reason). + Bool("isEmergencyPromotion", isEmergencyPromotion). + Msg("Auto-promoted session after primary grace period expiration") + } else { + sm.logger.Error(). + Err(err). + Str("expiredSessionID", sessionID). + Str("promotedSessionID", promotedSessionID). + Str("reason", reason). + Bool("isEmergencyPromotion", isEmergencyPromotion). + Msg("Failed to promote session after grace period expiration") + } + } else { + logLevel := sm.logger.Info() + if isEmergencyPromotion { + logLevel = sm.logger.Error() // Emergency with no eligible sessions is critical + } + logLevel. + Str("expiredSessionID", sessionID). + Bool("isEmergencyPromotion", isEmergencyPromotion). + Msg("Primary grace period expired but no eligible sessions to promote") + } + } else { + // Non-primary session grace period expired - just cleanup + sm.logger.Debug(). + Str("expiredSessionID", sessionID). + Msg("Non-primary session grace period expired") + } + + // Also clean up reconnect info for expired sessions + delete(sm.reconnectInfo, sessionID) + } + } + + // Clean up pending sessions that have timed out (DoS protection) + for id, session := range sm.sessions { + if session.Mode == SessionModePending && + now.Sub(session.CreatedAt) > pendingTimeout { + websocketLogger.Info(). + Str("sessionId", id). + Dur("age", now.Sub(session.CreatedAt)). + Msg("Removing timed-out pending session") + delete(sm.sessions, id) + needsBroadcast = true + } + } + + // Clean up observer sessions with closed RPC channels (stale connections) + // This prevents accumulation of zombie observer sessions that are no longer connected + observerTimeout := 2 * time.Minute // Default: 2 minutes + if currentSessionSettings != nil && currentSessionSettings.ObserverTimeout > 0 { + observerTimeout = time.Duration(currentSessionSettings.ObserverTimeout) * time.Second + } + for id, session := range sm.sessions { + if session.Mode == SessionModeObserver { + // Check if RPC channel is nil/closed AND session has been inactive + if session.RPCChannel == nil && now.Sub(session.LastActive) > observerTimeout { + sm.logger.Info(). + Str("sessionId", id). + Dur("inactiveFor", now.Sub(session.LastActive)). + Dur("observerTimeout", observerTimeout). + Msg("Removing inactive observer session with closed RPC channel") + delete(sm.sessions, id) + needsBroadcast = true + } + } + } + + // Check primary session timeout (every 30 iterations = 30 seconds) + if sm.primarySessionID != "" { + if primary, exists := sm.sessions[sm.primarySessionID]; exists { + currentTimeout := sm.getCurrentPrimaryTimeout() + if now.Sub(primary.LastActive) > currentTimeout { + timedOutSessionID := primary.ID + primary.Mode = SessionModeObserver + sm.primarySessionID = "" + + // Use enhanced emergency promotion system for timeout scenarios too + isEmergencyPromotion := false + var promotedSessionID string + + // Check if this requires emergency promotion due to approval requirements + 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(). + 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 + } + } + + // Use trust-based selection but exclude the timed-out session + bestSessionID := "" + bestScore := -1 + for id, session := range sm.sessions { + if id != timedOutSessionID && + !sm.isSessionBlacklisted(id) && + (session.Mode == SessionModeObserver || session.Mode == SessionModeQueued) { + score := sm.getSessionTrustScore(id) + if score > bestScore { + bestScore = score + bestSessionID = id + } + } + } + promotedSessionID = bestSessionID + } else { + // Normal timeout promotion - find any observer except the timed-out one + for id, session := range sm.sessions { + if id != timedOutSessionID && session.Mode == SessionModeObserver && !sm.isSessionBlacklisted(id) { + promotedSessionID = id + break + } + } + } + + // If found a session to promote + if promotedSessionID != "" { + reason := "timeout_promotion" + if isEmergencyPromotion { + reason = "emergency_timeout_promotion" + sm.lastEmergencyPromotion = now + sm.consecutiveEmergencyPromotions++ + + // Enhanced logging for emergency timeout promotions + sm.logger.Warn(). + Str("timedOutSessionID", timedOutSessionID). + Str("promotedSessionID", promotedSessionID). + Bool("requireApproval", true). + Int("trustScore", sm.getSessionTrustScore(promotedSessionID)). + Msg("EMERGENCY: Timeout promotion bypassing approval requirement") + } + + err := sm.transferPrimaryRole(timedOutSessionID, promotedSessionID, reason, "primary session timeout") + if err == nil { + needsBroadcast = true + logEvent := sm.logger.Info() + if isEmergencyPromotion { + logEvent = sm.logger.Warn() + } + logEvent. + Str("timedOutSessionID", timedOutSessionID). + Str("promotedSessionID", promotedSessionID). + Bool("isEmergencyPromotion", isEmergencyPromotion). + Msg("Auto-promoted session after primary timeout") + } + } + } + } else { + // Primary session no longer exists, clear it + sm.primarySessionID = "" + needsBroadcast = true + } + } + + // Run validation immediately if a grace period expired, otherwise run periodically + if gracePeriodExpired { + sm.validateSinglePrimary() + } else { + // Periodic validateSinglePrimary to catch deadlock states + validationCounter++ + if validationCounter >= 10 { // Every 10 seconds + validationCounter = 0 + sm.validateSinglePrimary() + } + } + + sm.mu.Unlock() + + // Broadcast outside of lock if needed + if needsBroadcast { + go sm.broadcastSessionListUpdate() + } + } + } +} + +// Global session manager instance +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 diff --git a/session_permissions.go b/session_permissions.go new file mode 100644 index 000000000..05a1bcbee --- /dev/null +++ b/session_permissions.go @@ -0,0 +1,77 @@ +package kvm + +import ( + "github.com/jetkvm/kvm/internal/session" +) + +type ( + Permission = session.Permission + PermissionSet = session.PermissionSet + SessionMode = session.SessionMode +) + +const ( + SessionModePrimary = session.SessionModePrimary + SessionModeObserver = session.SessionModeObserver + SessionModeQueued = session.SessionModeQueued + SessionModePending = session.SessionModePending + + PermissionVideoView = session.PermissionVideoView + PermissionKeyboardInput = session.PermissionKeyboardInput + PermissionMouseInput = session.PermissionMouseInput + PermissionPaste = session.PermissionPaste + PermissionSessionTransfer = session.PermissionSessionTransfer + PermissionSessionApprove = session.PermissionSessionApprove + PermissionSessionKick = session.PermissionSessionKick + PermissionSessionRequestPrimary = session.PermissionSessionRequestPrimary + PermissionSessionReleasePrimary = session.PermissionSessionReleasePrimary + PermissionSessionManage = session.PermissionSessionManage + PermissionPowerControl = session.PermissionPowerControl + PermissionUSBControl = session.PermissionUSBControl + PermissionMountMedia = session.PermissionMountMedia + PermissionUnmountMedia = session.PermissionUnmountMedia + PermissionMountList = session.PermissionMountList + PermissionExtensionManage = session.PermissionExtensionManage + PermissionExtensionATX = session.PermissionExtensionATX + PermissionExtensionDC = session.PermissionExtensionDC + PermissionExtensionSerial = session.PermissionExtensionSerial + PermissionExtensionWOL = session.PermissionExtensionWOL + PermissionTerminalAccess = session.PermissionTerminalAccess + PermissionSerialAccess = session.PermissionSerialAccess + PermissionSettingsRead = session.PermissionSettingsRead + PermissionSettingsWrite = session.PermissionSettingsWrite + PermissionSettingsAccess = session.PermissionSettingsAccess + PermissionSystemReboot = session.PermissionSystemReboot + PermissionSystemUpdate = session.PermissionSystemUpdate + PermissionSystemNetwork = session.PermissionSystemNetwork +) + +var ( + GetMethodPermission = session.GetMethodPermission +) + +type GetPermissionsResponse = session.GetPermissionsResponse + +func (s *Session) HasPermission(perm Permission) bool { + if s == nil { + return false + } + return session.CheckPermission(s.Mode, perm) +} + +func (s *Session) GetPermissions() PermissionSet { + if s == nil { + return PermissionSet{} + } + return session.GetPermissionsForMode(s.Mode) +} + +func RequirePermission(s *Session, perm Permission) error { + if s == nil { + return session.RequirePermissionForMode(SessionModePending, perm) + } + if !s.HasPermission(perm) { + return session.RequirePermissionForMode(s.Mode, perm) + } + return nil +} diff --git a/terminal.go b/terminal.go index e06e5cdc1..ea13087c9 100644 --- a/terminal.go +++ b/terminal.go @@ -16,9 +16,16 @@ type TerminalSize struct { Cols int `json:"cols"` } -func handleTerminalChannel(d *webrtc.DataChannel) { +func handleTerminalChannel(d *webrtc.DataChannel, session *Session) { scopedLogger := terminalLogger.With(). - Uint16("data_channel_id", *d.ID()).Logger() + Uint16("data_channel_id", *d.ID()). + Str("session_id", session.ID).Logger() + + // Check terminal access permission + if !session.HasPermission(PermissionTerminalAccess) { + handlePermissionDeniedChannel(d, "Terminal access denied: Permission required") + return + } var ptmx *os.File var cmd *exec.Cmd diff --git a/ui/src/api/sessionApi.ts b/ui/src/api/sessionApi.ts new file mode 100644 index 000000000..b6602fe46 --- /dev/null +++ b/ui/src/api/sessionApi.ts @@ -0,0 +1,132 @@ +import { SessionInfo } from "@/stores/sessionStore"; + +interface JsonRpcResponse { + result?: unknown; + error?: { message: string }; +} + +type RpcSendFunction = (method: string, params: Record, callback: (response: JsonRpcResponse) => void) => void; + +export const sessionApi = { + getSessions: async (sendFn: RpcSendFunction): Promise => { + return new Promise((resolve, reject) => { + sendFn("getSessions", {}, (response: JsonRpcResponse) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve((response.result as SessionInfo[]) || []); + } + }); + }); + }, + + getSessionInfo: async (sendFn: RpcSendFunction, sessionId: string): Promise => { + return new Promise((resolve, reject) => { + sendFn("getSessionInfo", { sessionId }, (response: JsonRpcResponse) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(response.result as SessionInfo); + } + }); + }); + }, + + requestPrimary: async (sendFn: RpcSendFunction, sessionId: string): Promise<{ status: string; mode?: string; message?: string }> => { + return new Promise((resolve, reject) => { + sendFn("requestPrimary", { sessionId }, (response: JsonRpcResponse) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(response.result as { status: string; mode?: string; message?: string }); + } + }); + }); + }, + + releasePrimary: async (sendFn: RpcSendFunction, sessionId: string): Promise => { + return new Promise((resolve, reject) => { + sendFn("releasePrimary", { sessionId }, (response: JsonRpcResponse) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(); + } + }); + }); + }, + + transferPrimary: async ( + sendFn: RpcSendFunction, + fromId: string, + toId: string + ): Promise => { + return new Promise((resolve, reject) => { + sendFn("transferPrimary", { fromId, toId }, (response: JsonRpcResponse) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(); + } + }); + }); + }, + + updateNickname: async ( + sendFn: RpcSendFunction, + sessionId: string, + nickname: string + ): Promise => { + return new Promise((resolve, reject) => { + sendFn("updateSessionNickname", { sessionId, nickname }, (response: JsonRpcResponse) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(); + } + }); + }); + }, + + approveNewSession: async ( + sendFn: RpcSendFunction, + sessionId: string + ): Promise => { + return new Promise((resolve, reject) => { + sendFn("approveNewSession", { sessionId }, (response: JsonRpcResponse) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(); + } + }); + }); + }, + + denyNewSession: async ( + sendFn: RpcSendFunction, + sessionId: string + ): Promise => { + return new Promise((resolve, reject) => { + sendFn("denyNewSession", { sessionId }, (response: JsonRpcResponse) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(); + } + }); + }); + }, + + requestSessionApproval: async (sendFn: RpcSendFunction): Promise => { + return new Promise((resolve, reject) => { + sendFn("requestSessionApproval", {}, (response: JsonRpcResponse) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(); + } + }); + }); + } +}; \ No newline at end of file diff --git a/ui/src/components/AccessDeniedOverlay.tsx b/ui/src/components/AccessDeniedOverlay.tsx new file mode 100644 index 000000000..fd7f6f24f --- /dev/null +++ b/ui/src/components/AccessDeniedOverlay.tsx @@ -0,0 +1,156 @@ +import { useEffect, useState, useCallback } from "react"; +import { useNavigate } from "react-router"; +import { XCircleIcon } from "@heroicons/react/24/outline"; + +import { DEVICE_API, CLOUD_API } from "@/ui.config"; +import { isOnDevice } from "@/main"; +import { useUserStore, useSettingsStore } from "@/hooks/stores"; +import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore"; +import api from "@/api"; + +import { Button } from "./Button"; + +interface AccessDeniedOverlayProps { + show: boolean; + message?: string; + onRetry?: () => void; + onRequestApproval?: () => void; +} + +export default function AccessDeniedOverlay({ + show, + message = "Your session access was denied", + onRetry, + onRequestApproval +}: AccessDeniedOverlayProps) { + const navigate = useNavigate(); + const setUser = useUserStore(state => state.setUser); + const { clearSession, rejectionCount, incrementRejectionCount } = useSessionStore(); + const { clearNickname } = useSharedSessionStore(); + const { maxRejectionAttempts } = useSettingsStore(); + const [countdown, setCountdown] = useState(10); + const [isRetrying, setIsRetrying] = useState(false); + + const handleLogout = useCallback(async () => { + try { + const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`; + const res = await api.POST(logoutUrl); + if (!res.ok) { + console.warn("Logout API call failed, but continuing with local cleanup"); + } + } catch (error) { + console.error("Logout API call failed:", error); + } + + // Always clear local state and navigate, regardless of API call result + setUser(null); + clearSession(); + clearNickname(); + navigate("/"); + }, [navigate, setUser, clearSession, clearNickname]); + + useEffect(() => { + if (!show) return; + + const newCount = incrementRejectionCount(); + + if (newCount >= maxRejectionAttempts) { + return; + } + + const timer = setInterval(() => { + setCountdown(prev => { + if (prev <= 1) { + clearInterval(timer); + handleLogout(); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, [show, handleLogout, incrementRejectionCount, maxRejectionAttempts]); + + if (!show) return null; + + if (rejectionCount >= maxRejectionAttempts) { + return null; + } + + return ( +
+
+
+ +
+

+ Access Denied +

+

+ {message} +

+
+
+ +
+
+

+ The primary session has denied your access request. This could be for security reasons + or because the session is restricted. +

+
+ + {rejectionCount < maxRejectionAttempts && ( +
+

+ Attempt {rejectionCount} of {maxRejectionAttempts}: {rejectionCount === maxRejectionAttempts - 1 + ? "This is your last attempt. Further rejections will hide this dialog." + : `You have ${maxRejectionAttempts - rejectionCount} attempt${maxRejectionAttempts - rejectionCount === 1 ? '' : 's'} remaining.` + } +

+
+ )} + +

+ Redirecting in {countdown} seconds... +

+ +
+ {(onRequestApproval || onRetry) && rejectionCount < maxRejectionAttempts && ( +
+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index 4f79d7ed8..840f0ba80 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -2,8 +2,8 @@ import { MdOutlineContentPasteGo } from "react-icons/md"; import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu"; import { FaKeyboard } from "react-icons/fa6"; import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; -import { Fragment, useCallback, useRef } from "react"; -import { CommandLineIcon } from "@heroicons/react/20/solid"; +import { Fragment, useCallback, useRef, useEffect } from "react"; +import { CommandLineIcon, UserGroupIcon } from "@heroicons/react/20/solid"; import { Button } from "@components/Button"; import { @@ -11,14 +11,18 @@ import { useMountMediaStore, useSettingsStore, useUiStore, -} from "@/hooks/stores"; + useRTCStore } from "@/hooks/stores"; import Container from "@components/Container"; import { cx } from "@/cva.config"; import PasteModal from "@/components/popovers/PasteModal"; import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index"; import MountPopopover from "@/components/popovers/MountPopover"; 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"; export default function Actionbar({ requestFullscreen, @@ -33,6 +37,37 @@ export default function Actionbar({ state => state.remoteVirtualMediaState, ); const { developerMode } = useSettingsStore(); + const { currentMode, sessions, setSessions } = useSessionStore(); + const { rpcDataChannel } = useRTCStore(); + const { hasPermission } = usePermissions(); + + // Fetch sessions on mount if we have an RPC channel + useEffect(() => { + if (rpcDataChannel?.readyState === "open" && sessions.length === 0) { + const id = Math.random().toString(36).substring(2); + const message = JSON.stringify({ jsonrpc: "2.0", method: "getSessions", params: {}, id }); + + const handler = (event: MessageEvent) => { + try { + const response = JSON.parse(event.data); + if (response.id === id && response.result) { + setSessions(response.result); + rpcDataChannel.removeEventListener("message", handler); + } + } catch { + // Ignore parse errors for non-JSON messages + } + }; + + rpcDataChannel.addEventListener("message", handler); + rpcDataChannel.send(message); + + // Clean up after timeout + setTimeout(() => { + rpcDataChannel.removeEventListener("message", handler); + }, 5000); + } + }, [rpcDataChannel, sessions.length, setSessions]); // This is the only way to get a reliable state change for the popover // at time of writing this there is no mount, or unmount event for the popover @@ -44,7 +79,6 @@ export default function Actionbar({ if (!open) { setTimeout(() => { setDisableVideoFocusTrap(false); - console.debug("Popover is closing. Returning focus trap to video"); }, 0); } } @@ -60,7 +94,7 @@ export default function Actionbar({ className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-1.5" >
- {developerMode && ( + {developerMode && hasPermission(Permission.TERMINAL_ACCESS) && (
+ )} + {hasPermission(Permission.KEYBOARD_INPUT) && ( +
+ )} + + +
+ {/* Session Control */}
-
-
-
+ )} -
- - + {hasPermission(Permission.KEYBOARD_INPUT) && ( +
+
+ )}
-
-
+ {/* Only show Settings for sessions with settings access */} + {hasPermission(Permission.SETTINGS_ACCESS) && ( +
+
+ )}
diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx index a650693f4..6fc7bb274 100644 --- a/ui/src/components/Header.tsx +++ b/ui/src/components/Header.tsx @@ -12,6 +12,7 @@ import LogoWhiteIcon from "@/assets/logo-white.svg"; import USBStateStatus from "@components/USBStateStatus"; import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard"; import { CLOUD_API, DEVICE_API } from "@/ui.config"; +import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore"; import api from "../api"; import { isOnDevice } from "../main"; @@ -37,6 +38,8 @@ export default function DashboardNavbar({ }: NavbarProps) { const peerConnectionState = useRTCStore(state => state.peerConnectionState); const setUser = useUserStore(state => state.setUser); + const { clearSession } = useSessionStore(); + const { clearNickname } = useSharedSessionStore(); const navigate = useNavigate(); const onLogout = useCallback(async () => { const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`; @@ -44,9 +47,12 @@ export default function DashboardNavbar({ if (!res.ok) return; setUser(null); + // Clear the stored session data via zustand + clearNickname(); + clearSession(); // The root route will redirect to appropriate login page, be it the local one or the cloud one navigate("/"); - }, [navigate, setUser]); + }, [navigate, setUser, clearNickname, clearSession]); const { usbState } = useHidStore(); diff --git a/ui/src/components/MacroBar.tsx b/ui/src/components/MacroBar.tsx index 0ba8cf4f7..8726a778f 100644 --- a/ui/src/components/MacroBar.tsx +++ b/ui/src/components/MacroBar.tsx @@ -6,21 +6,26 @@ 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"; export default function MacroBar() { const { macros, initialized, loadMacros, setSendFn } = useMacrosStore(); const { executeMacro } = useKeyboard(); const { send } = useJsonRpc(); + const { permissions, hasPermission } = usePermissions(); useEffect(() => { setSendFn(send); - - if (!initialized) { + + // Only load macros if user has permission to read settings + if (!initialized && permissions[Permission.SETTINGS_READ] === true) { loadMacros(); } - }, [initialized, loadMacros, setSendFn, send]); + }, [initialized, send, loadMacros, setSendFn, permissions]); - if (macros.length === 0) { + // Don't show macros if user can't provide keyboard input or if no macros exist + if (macros.length === 0 || !hasPermission(Permission.KEYBOARD_INPUT)) { return null; } diff --git a/ui/src/components/NicknameModal.tsx b/ui/src/components/NicknameModal.tsx new file mode 100644 index 000000000..5b9586216 --- /dev/null +++ b/ui/src/components/NicknameModal.tsx @@ -0,0 +1,263 @@ +import { useState, useEffect, useRef } from "react"; +import { Dialog, DialogPanel, DialogBackdrop } from "@headlessui/react"; +import { UserIcon, XMarkIcon } from "@heroicons/react/20/solid"; + +import { useSettingsStore , useRTCStore } from "@/hooks/stores"; +import { useJsonRpc } from "@/hooks/useJsonRpc"; +import { generateNickname } from "@/utils/nicknameGenerator"; + +import { Button } from "./Button"; + +type SessionRole = "primary" | "observer" | "queued" | "pending"; + +interface NicknameModalProps { + isOpen: boolean; + onSubmit: (nickname: string) => void | Promise; + onSkip?: () => void; + title?: string; + description?: string; + isRequired?: boolean; + expectedRole?: SessionRole; +} + +export default function NicknameModal({ + isOpen, + onSubmit, + onSkip, + title = "Set Your Session Nickname", + description = "Add a nickname to help identify your session to other users", + isRequired, + expectedRole = "observer" +}: NicknameModalProps) { + const [nickname, setNickname] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [generatedNickname, setGeneratedNickname] = useState(""); + const inputRef = useRef(null); + const { requireSessionNickname } = useSettingsStore(); + const { send } = useJsonRpc(); + const { rpcDataChannel } = useRTCStore(); + + const isNicknameRequired = isRequired ?? requireSessionNickname; + + // Role-based color coding + const getRoleColors = (role: SessionRole) => { + switch (role) { + case "primary": + return { + bg: "bg-green-100 dark:bg-green-900/30", + icon: "text-green-600 dark:text-green-400" + }; + case "observer": + return { + bg: "bg-blue-100 dark:bg-blue-900/30", + icon: "text-blue-600 dark:text-blue-400" + }; + case "queued": + return { + bg: "bg-yellow-100 dark:bg-yellow-900/30", + icon: "text-yellow-600 dark:text-yellow-400" + }; + case "pending": + return { + bg: "bg-orange-100 dark:bg-orange-900/30", + icon: "text-orange-600 dark:text-orange-400" + }; + default: + return { + bg: "bg-slate-100 dark:bg-slate-900/30", + icon: "text-slate-600 dark:text-slate-400" + }; + } + }; + + const roleColors = getRoleColors(expectedRole); + + // Generate nickname when modal opens and RPC is ready + useEffect(() => { + if (!isOpen || generatedNickname) return; + if (rpcDataChannel?.readyState !== "open") return; + + generateNickname(send).then(nickname => { + setGeneratedNickname(nickname); + }).catch((error) => { + console.error('Backend nickname generation failed:', error); + }); + }, [isOpen, generatedNickname, rpcDataChannel?.readyState, send]); + + // Focus input when modal opens + useEffect(() => { + if (isOpen) { + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, 100); + } + }, [isOpen]); + + const validateNickname = (value: string): string | null => { + if (value.length < 2) { + return "Nickname must be at least 2 characters"; + } + if (value.length > 30) { + return "Nickname must be 30 characters or less"; + } + if (!/^[a-zA-Z0-9\s\-_.@]+$/.test(value)) { + return "Nickname can only contain letters, numbers, spaces, and - _ . @"; + } + return null; + }; + + const handleSubmit = async (e?: React.FormEvent) => { + e?.preventDefault(); + + // Use generated nickname if input is empty + const trimmedNickname = nickname.trim() || generatedNickname; + + // Validate + const validationError = validateNickname(trimmedNickname); + if (validationError) { + setError(validationError); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + await onSubmit(trimmedNickname); + setNickname(""); + setGeneratedNickname(""); // Reset generated nickname after successful submit + } catch (error) { + setError(error instanceof Error ? error.message : "Failed to set nickname"); + setIsSubmitting(false); + } + }; + + const handleSkip = () => { + if (!isNicknameRequired && onSkip) { + onSkip(); + setNickname(""); + setError(null); + setGeneratedNickname(""); // Reset generated nickname when skipping + } + }; + + return ( + { + if (!isNicknameRequired && onSkip) { + onSkip(); + setNickname(""); + setError(null); + setGeneratedNickname(""); + } + }} + className="relative z-50" + > + +
+ +
+
+
+
+ +
+
+

+ {title} +

+

+ {description} +

+
+
+ {!isNicknameRequired && ( + + )} +
+ +
+
+ + { + setNickname(e.target.value); + setError(null); + }} + placeholder={generatedNickname || "e.g., John's Laptop, Office PC, etc."} + className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-md + bg-white dark:bg-slate-700 text-slate-900 dark:text-white + placeholder-slate-400 dark:placeholder-slate-500 + focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + maxLength={30} + /> +
+ {error ? ( +

{error}

+ ) : ( +
+

+ {nickname.trim() === "" && generatedNickname + ? `Leave empty to use: ${generatedNickname}` + : "2-30 characters, letters, numbers, spaces, and - _ . @ allowed"} +

+
+ )} + + {nickname.length}/30 + +
+
+ + {isNicknameRequired && ( +
+

+ Required: A nickname is required by the administrator to help identify sessions. +

+
+ )} + +
+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/PendingApprovalOverlay.tsx b/ui/src/components/PendingApprovalOverlay.tsx new file mode 100644 index 000000000..6d96ab760 --- /dev/null +++ b/ui/src/components/PendingApprovalOverlay.tsx @@ -0,0 +1,53 @@ +import { useEffect, useState } from "react"; +import { ClockIcon } from "@heroicons/react/24/outline"; + +interface PendingApprovalOverlayProps { + show: boolean; +} + +export default function PendingApprovalOverlay({ show }: PendingApprovalOverlayProps) { + const [dots, setDots] = useState(""); + + useEffect(() => { + if (!show) return; + + const timer = setInterval(() => { + setDots(prev => (prev.length >= 3 ? "" : prev + ".")); + }, 500); + + return () => clearInterval(timer); + }, [show]); + + if (!show) return null; + + return ( +
+
+
+ + +
+

+ Awaiting Approval{dots} +

+

+ Your session is pending approval from the primary session +

+
+ +
+

+ The primary user will receive a notification to approve or deny your access. + This typically takes less than 30 seconds. +

+
+ +
+
+ Waiting for response from primary session +
+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/SessionControlPanel.tsx b/ui/src/components/SessionControlPanel.tsx new file mode 100644 index 000000000..675b306e7 --- /dev/null +++ b/ui/src/components/SessionControlPanel.tsx @@ -0,0 +1,143 @@ +import { + LockClosedIcon, + LockOpenIcon, + ClockIcon +} from "@heroicons/react/16/solid"; +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"; + +type RpcSendFunction = (method: string, params: Record, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void; + +interface SessionControlPanelProps { + sendFn: RpcSendFunction; + className?: string; +} + +export default function SessionControlPanel({ sendFn, className }: SessionControlPanelProps) { + const { + currentSessionId, + currentMode, + sessions, + isRequestingPrimary, + setRequestingPrimary, + setSessionError, + canRequestPrimary + } = useSessionStore(); + const { hasPermission } = usePermissions(); + + + const handleRequestPrimary = async () => { + if (!currentSessionId || isRequestingPrimary) return; + + setRequestingPrimary(true); + setSessionError(null); + + try { + const result = await sessionApi.requestPrimary(sendFn, currentSessionId); + + if (result.status === "success") { + if (result.mode === "primary") { + // Immediately became primary + setRequestingPrimary(false); + } else if (result.mode === "queued") { + // Request sent, waiting for approval + // Keep isRequestingPrimary true to show waiting state + } + } else if (result.status === "error") { + setSessionError(result.message || "Failed to request primary control"); + setRequestingPrimary(false); + } + } catch (error) { + setSessionError(error instanceof Error ? error.message : "Unknown error"); + console.error("Failed to request primary control:", error); + setRequestingPrimary(false); + } + }; + + const handleReleasePrimary = async () => { + if (!currentSessionId || currentMode !== "primary") return; + + try { + await sessionApi.releasePrimary(sendFn, currentSessionId); + } catch (error) { + setSessionError(error instanceof Error ? error.message : "Unknown error"); + console.error("Failed to release primary control:", error); + } + }; + + const canReleasePrimary = () => { + const otherEligibleSessions = sessions.filter( + s => s.id !== currentSessionId && (s.mode === "observer" || s.mode === "queued") + ); + return otherEligibleSessions.length > 0; + }; + + + return ( +
+ {/* Current session controls */} +
+

+ Session Control +

+ + {hasPermission(Permission.SESSION_RELEASE_PRIMARY) && ( +
+
+ )} + + {hasPermission(Permission.SESSION_REQUEST_PRIMARY) && ( + <> + {isRequestingPrimary ? ( +
+ + + Waiting for approval from primary session... + +
+ ) : ( +
+ +
+ ); +} \ No newline at end of file diff --git a/ui/src/components/SessionsList.tsx b/ui/src/components/SessionsList.tsx new file mode 100644 index 000000000..46e49626c --- /dev/null +++ b/ui/src/components/SessionsList.tsx @@ -0,0 +1,151 @@ +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"; + +interface Session { + id: string; + mode: string; + nickname?: string; + identity?: string; + source?: string; + createdAt?: string; +} + +interface SessionsListProps { + sessions: Session[]; + currentSessionId?: string; + onEditNickname?: (sessionId: string) => void; + onApprove?: (sessionId: string) => void; + onDeny?: (sessionId: string) => void; + onTransfer?: (sessionId: string) => void; + formatDuration?: (createdAt: string) => string; +} + +export default function SessionsList({ + sessions, + currentSessionId, + onEditNickname, + onApprove, + onDeny, + onTransfer, + formatDuration = (createdAt: string) => formatters.timeAgo(new Date(createdAt)) || "" +}: SessionsListProps) { + const { hasPermission } = usePermissions(); + return ( +
+ {sessions.map(session => ( +
+
+
+ + {session.id === currentSessionId && ( + (You) + )} +
+
+ + {session.createdAt ? formatDuration(session.createdAt) : ""} + + {/* Show approve/deny for pending sessions if user has permission */} + {session.mode === "pending" && hasPermission(Permission.SESSION_APPROVE) && onApprove && onDeny && ( +
+ + +
+ )} + {/* Show Transfer button if user has permission to transfer */} + {hasPermission(Permission.SESSION_TRANSFER) && session.mode === "observer" && session.id !== currentSessionId && onTransfer && ( + + )} + {/* Allow users with session manage permission to edit any nickname, or anyone to edit their own */} + {onEditNickname && (hasPermission(Permission.SESSION_MANAGE) || session.id === currentSessionId) && ( + + )} +
+
+ +
+ {session.nickname && ( +

+ {session.nickname} +

+ )} + {session.identity && ( +

+ {session.source === "cloud" ? "☁️ " : ""}{session.identity} +

+ )} + {session.mode === "pending" && ( +

+ Awaiting approval +

+ )} +
+
+ ))} +
+ ); +} + +export function SessionModeBadge({ mode }: { mode: string }) { + const getBadgeStyle = () => { + switch (mode) { + case "primary": + return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"; + case "observer": + return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"; + case "queued": + return "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"; + case "pending": + return "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"; + default: + return "bg-slate-100 text-slate-700 dark:bg-slate-900/30 dark:text-slate-400"; + } + }; + + return ( + + {mode} + + ); +} \ No newline at end of file diff --git a/ui/src/components/UnifiedSessionRequestDialog.tsx b/ui/src/components/UnifiedSessionRequestDialog.tsx new file mode 100644 index 000000000..ca936095c --- /dev/null +++ b/ui/src/components/UnifiedSessionRequestDialog.tsx @@ -0,0 +1,262 @@ +import { useEffect, useState } from "react"; +import { XMarkIcon, UserIcon, GlobeAltIcon, ComputerDesktopIcon } from "@heroicons/react/20/solid"; + +import { Button } from "./Button"; + +type RequestType = "session_approval" | "primary_control"; + +interface UnifiedSessionRequest { + id: string; // sessionId or requestId + type: RequestType; + source: "local" | "cloud" | string; // Allow string for IP addresses + identity?: string; + nickname?: string; +} + +interface UnifiedSessionRequestDialogProps { + request: UnifiedSessionRequest | null; + onApprove: (id: string) => void | Promise; + onDeny: (id: string) => void | Promise; + onDismiss?: () => void; + onClose: () => void; +} + +export default function UnifiedSessionRequestDialog({ + request, + onApprove, + onDeny, + onDismiss, + onClose +}: UnifiedSessionRequestDialogProps) { + const [timeRemaining, setTimeRemaining] = useState(0); + const [isProcessing, setIsProcessing] = useState(false); + const [hasTimedOut, setHasTimedOut] = useState(false); + + useEffect(() => { + if (!request) return; + + const isSessionApproval = request.type === "session_approval"; + const initialTime = isSessionApproval ? 60 : 0; // 60s for session approval, no timeout for primary control + + setTimeRemaining(initialTime); + setIsProcessing(false); + setHasTimedOut(false); + + // Only start timer for session approval requests + if (isSessionApproval) { + const timer = setInterval(() => { + setTimeRemaining(prev => { + const newTime = prev - 1; + if (newTime <= 0) { + clearInterval(timer); + setHasTimedOut(true); + return 0; + } + return newTime; + }); + }, 1000); + + return () => clearInterval(timer); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [request?.id, request?.type]); // Only depend on stable properties to avoid unnecessary re-renders + + // Handle auto-deny when timeout occurs + useEffect(() => { + if (hasTimedOut && !isProcessing && request) { + setIsProcessing(true); + Promise.resolve(onDeny(request.id)) + .catch(error => { + console.error("Failed to auto-deny request:", error); + }) + .finally(() => { + onClose(); + }); + } + }, [hasTimedOut, isProcessing, request, onDeny, onClose]); + + if (!request) return null; + + const isSessionApproval = request.type === "session_approval"; + const isPrimaryControl = request.type === "primary_control"; + + // Determine if source is cloud, local, or IP address + const getSourceInfo = () => { + if (request.source === "cloud") { + return { + type: "cloud", + label: "Cloud Session", + icon: GlobeAltIcon, + iconColor: "text-blue-500" + }; + } else if (request.source === "local") { + return { + type: "local", + label: "Local Session", + icon: ComputerDesktopIcon, + iconColor: "text-green-500" + }; + } else { + // Assume it's an IP address or hostname + return { + type: "ip", + label: request.source, + icon: ComputerDesktopIcon, + iconColor: "text-green-500" + }; + } + }; + + const sourceInfo = getSourceInfo(); + + const getTitle = () => { + if (isSessionApproval) return "New Session Request"; + if (isPrimaryControl) return "Primary Control Request"; + return "Session Request"; + }; + + const getDescription = () => { + if (isSessionApproval) return "A new session is attempting to connect to this device:"; + if (isPrimaryControl) return "A user is requesting primary control of this session:"; + return "A user is making a request:"; + }; + + return ( +
+
+
+

+ {getTitle()} +

+ +
+ +
+

+ {getDescription()} +

+ +
+ {/* Session type - always show with icon for both session approval and primary control */} +
+ + + {sourceInfo.type === "cloud" ? "Cloud Session" : + sourceInfo.type === "local" ? "Local Session" : + `Local Session`} + + {sourceInfo.type === "ip" && ( + + ({sourceInfo.label}) + + )} +
+ + {/* Nickname - always show with icon for consistency */} + {request.nickname && ( +
+ + + Nickname:{" "} + {request.nickname} + +
+ )} + + {/* Identity/User */} + {request.identity && ( +
+ {isSessionApproval ? ( +

Identity: {request.identity}

+ ) : ( +

+ User:{" "} + {request.identity} +

+ )} +
+ )} +
+ + {/* Security Note - only for session approval */} + {isSessionApproval && ( +
+

+ Security Note: Only approve sessions you recognize. + Approved sessions will have observer access and can request primary control. +

+
+ )} + + {/* Auto-deny timer - only for session approval */} + {isSessionApproval && ( +
+

+ Auto-deny in {timeRemaining} seconds +

+
+ )} + +
+
+
+ {onDismiss && ( +
+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 64452bf8d..9d4158d04 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -14,6 +14,8 @@ import { useSettingsStore, useVideoStore, } from "@/hooks/stores"; +import { usePermissions } from "@/hooks/usePermissions"; +import { Permission } from "@/types/permissions"; import useMouse from "@/hooks/useMouse"; import { @@ -35,6 +37,7 @@ export default function WebRTCVideo() { // Store hooks const settings = useSettingsStore(); + const { hasPermission } = usePermissions(); const { handleKeyPress, resetKeyboardState } = useKeyboard(); const { getRelMouseMoveHandler, @@ -214,29 +217,47 @@ export default function WebRTCVideo() { document.addEventListener("fullscreenchange", handleFullscreenChange); }, [releaseKeyboardLock]); - const absMouseMoveHandler = useMemo( - () => getAbsMouseMoveHandler({ + const absMouseMoveHandler = useMemo(() => { + const handler = getAbsMouseMoveHandler({ videoClientWidth, videoClientHeight, videoWidth, videoHeight, - }), - [getAbsMouseMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight], - ); - - const relMouseMoveHandler = useMemo( - () => getRelMouseMoveHandler(), - [getRelMouseMoveHandler], - ); - - const mouseWheelHandler = useMemo( - () => getMouseWheelHandler(), - [getMouseWheelHandler], - ); + }); + return (e: MouseEvent) => { + // Only allow input if user has mouse permission + if (!hasPermission(Permission.MOUSE_INPUT)) return; + handler(e); + }; + }, [getAbsMouseMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight, hasPermission]); + + const relMouseMoveHandler = useMemo(() => { + const handler = getRelMouseMoveHandler(); + return (e: MouseEvent) => { + // Only allow input if user has mouse permission + if (!hasPermission(Permission.MOUSE_INPUT)) return; + handler(e); + }; + }, [getRelMouseMoveHandler, hasPermission]); + + const mouseWheelHandler = useMemo(() => { + const handler = getMouseWheelHandler(); + return (e: WheelEvent) => { + // Only allow input if user has mouse permission + if (!hasPermission(Permission.MOUSE_INPUT)) return; + handler(e); + }; + }, [getMouseWheelHandler, hasPermission]); const keyDownHandler = useCallback( (e: KeyboardEvent) => { e.preventDefault(); + + // Only allow input if user has keyboard permission + if (!hasPermission(Permission.KEYBOARD_INPUT)) { + return; + } + if (e.repeat) return; const code = getAdjustedKeyCode(e); const hidKey = keys[code]; @@ -252,11 +273,9 @@ export default function WebRTCVideo() { // https://bugzilla.mozilla.org/show_bug.cgi?id=1299553 if (e.metaKey && hidKey < 0xE0) { setTimeout(() => { - console.debug(`Forcing the meta key release of associated key: ${hidKey}`); handleKeyPress(hidKey, false); }, 10); } - console.debug(`Key down: ${hidKey}`); handleKeyPress(hidKey, true); if (!isKeyboardLockActive && hidKey === keys.MetaLeft) { @@ -264,17 +283,22 @@ export default function WebRTCVideo() { // we'll never see the keyup event because the browser is going to lose // focus so set a deferred keyup after a short delay setTimeout(() => { - console.debug(`Forcing the left meta key release`); handleKeyPress(hidKey, false); }, 100); } }, - [handleKeyPress, isKeyboardLockActive], + [handleKeyPress, isKeyboardLockActive, hasPermission], ); const keyUpHandler = useCallback( async (e: KeyboardEvent) => { e.preventDefault(); + + // Only allow input if user has keyboard permission + if (!hasPermission(Permission.KEYBOARD_INPUT)) { + return; + } + const code = getAdjustedKeyCode(e); const hidKey = keys[code]; @@ -283,10 +307,9 @@ export default function WebRTCVideo() { return; } - console.debug(`Key up: ${hidKey}`); handleKeyPress(hidKey, false); }, - [handleKeyPress], + [handleKeyPress, hasPermission], ); const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { @@ -297,7 +320,6 @@ export default function WebRTCVideo() { // Fix only works in chrome based browsers. if (e.code === "Space") { if (videoElm.current.paused) { - console.debug("Force playing video"); videoElm.current.play(); } } @@ -556,7 +578,7 @@ export default function WebRTCVideo() { )}
- + {hasPermission(Permission.KEYBOARD_INPUT) && }
diff --git a/ui/src/components/popovers/SessionPopover.tsx b/ui/src/components/popovers/SessionPopover.tsx new file mode 100644 index 000000000..cf618eca0 --- /dev/null +++ b/ui/src/components/popovers/SessionPopover.tsx @@ -0,0 +1,208 @@ +import { useState, useEffect, useCallback } from "react"; +import { + UserGroupIcon, + ArrowPathIcon, + PencilIcon, +} from "@heroicons/react/20/solid"; +import clsx from "clsx"; + +import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore"; +import { useJsonRpc } from "@/hooks/useJsonRpc"; +import SessionControlPanel from "@/components/SessionControlPanel"; +import NicknameModal from "@/components/NicknameModal"; +import SessionsList, { SessionModeBadge } from "@/components/SessionsList"; +import { sessionApi } from "@/api/sessionApi"; + +export default function SessionPopover() { + const { + currentSessionId, + currentMode, + sessions, + sessionError, + setSessions, + } = useSessionStore(); + const { setNickname } = useSharedSessionStore(); + + const [isRefreshing, setIsRefreshing] = useState(false); + const [showNicknameModal, setShowNicknameModal] = useState(false); + const [editingSessionId, setEditingSessionId] = useState(null); + + const { send } = useJsonRpc(); + + // Adapter function to match existing callback pattern + const sendRpc = useCallback((method: string, params: Record, callback?: (response: { result?: unknown; error?: { message: string } }) => void) => { + send(method, params, (response) => { + if (callback) callback(response); + }); + }, [send]); + + const handleRefresh = async () => { + if (isRefreshing) return; + + setIsRefreshing(true); + try { + const refreshedSessions = await sessionApi.getSessions(sendRpc); + setSessions(refreshedSessions); + } catch (error) { + console.error("Failed to refresh sessions:", error); + } finally { + setIsRefreshing(false); + } + }; + + // Fetch sessions on mount + useEffect(() => { + if (sessions.length === 0) { + sessionApi.getSessions(sendRpc) + .then(sessions => setSessions(sessions)) + .catch(error => console.error("Failed to fetch sessions:", error)); + } + }, [sendRpc, sessions.length, setSessions]); + + return ( +
+ {/* Header */} +
+
+
+ +

+ Session Management +

+
+ +
+
+ + {/* Session Error */} + {sessionError && ( +
+

{sessionError}

+
+ )} + + {/* Current Session */} +
+
+
+
+ Your Session + +
+ +
+ + {currentSessionId && ( + <> + {/* Display current session nickname if exists */} + {sessions.find(s => s.id === currentSessionId)?.nickname && ( +
+ Nickname: + + {sessions.find(s => s.id === currentSessionId)?.nickname} + +
+ )} + +
+ +
+ + )} +
+
+ + {/* Active Sessions List */} +
+
+ Active Sessions ({sessions.length}) +
+ + {sessions.length > 0 ? ( + { + setEditingSessionId(sessionId); + setShowNicknameModal(true); + }} + onApprove={(sessionId) => { + sendRpc("approveNewSession", { sessionId }, (response) => { + if (response.error) { + console.error("Failed to approve session:", response.error); + } else { + handleRefresh(); + } + }); + }} + onDeny={(sessionId) => { + sendRpc("denyNewSession", { sessionId }, (response) => { + if (response.error) { + console.error("Failed to deny session:", response.error); + } else { + handleRefresh(); + } + }); + }} + onTransfer={async (sessionId) => { + try { + await sessionApi.transferPrimary(sendRpc, currentSessionId!, sessionId); + handleRefresh(); + } catch (error) { + console.error("Failed to transfer primary:", error); + } + }} + /> + ) : ( +

No active sessions

+ )} +
+ + s.id === currentSessionId)?.nickname ? "Update Your Nickname" : "Set Your Nickname") + : `Set Nickname for ${sessions.find(s => s.id === editingSessionId)?.mode || 'Session'}`} + description={editingSessionId === currentSessionId + ? "Choose a nickname to help identify your session to others" + : "Choose a nickname to help identify this session"} + onSubmit={async (nickname) => { + if (editingSessionId && sendRpc) { + try { + await sessionApi.updateNickname(sendRpc, editingSessionId, nickname); + if (editingSessionId === currentSessionId) { + setNickname(nickname); + } + setShowNicknameModal(false); + setEditingSessionId(null); + handleRefresh(); + } catch (error) { + console.error("Failed to update nickname:", error); + throw error; + } + } + }} + onSkip={() => { + setShowNicknameModal(false); + setEditingSessionId(null); + }} + /> +
+ ); +} + diff --git a/ui/src/contexts/PermissionsContext.ts b/ui/src/contexts/PermissionsContext.ts new file mode 100644 index 000000000..c6268d968 --- /dev/null +++ b/ui/src/contexts/PermissionsContext.ts @@ -0,0 +1,5 @@ +import { createContext } from "react"; + +import { PermissionsContextValue } from "@/hooks/usePermissions"; + +export const PermissionsContext = createContext(undefined); diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index bfbbb26e5..a3a8f3018 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -329,6 +329,15 @@ export interface SettingsState { developerMode: boolean; setDeveloperMode: (enabled: boolean) => void; + requireSessionNickname: boolean; + setRequireSessionNickname: (required: boolean) => void; + + requireSessionApproval: boolean; + setRequireSessionApproval: (required: boolean) => void; + + maxRejectionAttempts: number; + setMaxRejectionAttempts: (attempts: number) => void; + displayRotation: string; setDisplayRotation: (rotation: string) => void; @@ -369,6 +378,15 @@ export const useSettingsStore = create( developerMode: false, setDeveloperMode: (enabled: boolean) => set({ developerMode: enabled }), + requireSessionNickname: false, + setRequireSessionNickname: (required: boolean) => set({ requireSessionNickname: required }), + + requireSessionApproval: true, + setRequireSessionApproval: (required: boolean) => set({ requireSessionApproval: required }), + + maxRejectionAttempts: 3, + setMaxRejectionAttempts: (attempts: number) => set({ maxRejectionAttempts: attempts }), + displayRotation: "270", setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }), diff --git a/ui/src/hooks/useJsonRpc.ts b/ui/src/hooks/useJsonRpc.ts index 5c52d59cd..91965c744 100644 --- a/ui/src/hooks/useJsonRpc.ts +++ b/ui/src/hooks/useJsonRpc.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useRTCStore } from "@/hooks/stores"; @@ -36,6 +36,12 @@ let requestCounter = 0; export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { const { rpcDataChannel } = useRTCStore(); + const onRequestRef = useRef(onRequest); + + // Update ref when callback changes + useEffect(() => { + onRequestRef.current = onRequest; + }, [onRequest]); const send = useCallback( async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => { @@ -59,7 +65,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { // The "API" can also "request" data from the client // If the payload has a method, it's a request if ("method" in payload) { - if (onRequest) onRequest(payload); + if (onRequestRef.current) onRequestRef.current(payload); return; } @@ -79,7 +85,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { rpcDataChannel.removeEventListener("message", messageHandler); }; }, - [rpcDataChannel, onRequest]); + [rpcDataChannel]); // Remove onRequest from dependencies return { send }; } diff --git a/ui/src/hooks/usePermissions.ts b/ui/src/hooks/usePermissions.ts new file mode 100644 index 000000000..a717bab2f --- /dev/null +++ b/ui/src/hooks/usePermissions.ts @@ -0,0 +1,34 @@ +import { useContext } from "react"; + +import { PermissionsContext } from "@/contexts/PermissionsContext"; +import { Permission } from "@/types/permissions"; + +export interface PermissionsContextValue { + permissions: Record; + 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); + + if (context === undefined) { + return { + permissions: {}, + isLoading: true, + hasPermission: () => false, + hasAnyPermission: () => false, + hasAllPermissions: () => false, + isPrimary: () => false, + isObserver: () => false, + isPending: () => false, + }; + } + + return context; +} diff --git a/ui/src/hooks/useSessionEvents.ts b/ui/src/hooks/useSessionEvents.ts new file mode 100644 index 000000000..58d9715d0 --- /dev/null +++ b/ui/src/hooks/useSessionEvents.ts @@ -0,0 +1,167 @@ +import { useEffect, useRef } from "react"; + +import { useSessionStore, SessionInfo } from "@/stores/sessionStore"; +import { useRTCStore } from "@/hooks/stores"; +import { sessionApi } from "@/api/sessionApi"; +import { notify } from "@/notifications"; + +type RpcSendFunction = (method: string, params: Record, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void; + +interface SessionEventData { + sessions: SessionInfo[]; + yourMode: string; +} + +interface ModeChangedData { + mode: string; +} + +interface ConnectionModeChangedData { + newMode: string; +} + +export function useSessionEvents(sendFn: RpcSendFunction | null) { + const { + currentMode, + setSessions, + updateSessionMode, + setSessionError + } = useSessionStore(); + + const sendFnRef = useRef(sendFn); + sendFnRef.current = sendFn; + + const handleSessionEvent = (method: string, params: unknown) => { + switch (method) { + case "sessionsUpdated": + handleSessionsUpdated(params as SessionEventData); + break; + case "modeChanged": + handleModeChanged(params as ModeChangedData); + break; + case "connectionModeChanged": + handleConnectionModeChanged(params as ConnectionModeChangedData); + break; + case "hidReadyForPrimary": + handleHidReadyForPrimary(); + break; + case "otherSessionConnected": + handleOtherSessionConnected(); + break; + default: + break; + } + }; + + const handleSessionsUpdated = (data: SessionEventData) => { + if (data.sessions) { + setSessions(data.sessions); + } + + // CRITICAL: Only update mode, never show notifications from sessionsUpdated + // Notifications are exclusively handled by handleModeChanged to prevent duplicates + if (data.yourMode && data.yourMode !== currentMode) { + updateSessionMode(data.yourMode as "primary" | "observer" | "queued" | "pending"); + } + }; + + // Debounce notifications to prevent rapid-fire duplicates + const lastNotificationRef = useRef<{mode: string, timestamp: number}>({mode: "", timestamp: 0}); + + const handleModeChanged = (data: ModeChangedData) => { + if (data.mode) { + // Get the most current mode from the store to avoid race conditions + const { currentMode: currentModeFromStore } = useSessionStore.getState(); + const previousMode = currentModeFromStore; + updateSessionMode(data.mode as "primary" | "observer" | "queued" | "pending"); + + if (previousMode === "queued" && data.mode !== "queued") { + const { setRequestingPrimary } = useSessionStore.getState(); + setRequestingPrimary(false); + } + + if (previousMode === "pending" && data.mode === "observer") { + const { resetRejectionCount } = useSessionStore.getState(); + resetRejectionCount(); + } + + // HID re-initialization is now handled automatically by permission changes in usePermissions + + // CRITICAL: Debounce notifications to prevent duplicates from rapid-fire events + const now = Date.now(); + const lastNotification = lastNotificationRef.current; + + // Only show notification if: + // 1. Mode actually changed, AND + // 2. Haven't shown the same notification in the last 2 seconds + const shouldNotify = previousMode !== data.mode && + (lastNotification.mode !== data.mode || now - lastNotification.timestamp > 2000); + + if (shouldNotify) { + if (data.mode === "primary") { + notify.success("Primary control granted"); + lastNotificationRef.current = {mode: "primary", timestamp: now}; + } else if (data.mode === "observer" && previousMode === "primary") { + notify.info("Primary control released"); + lastNotificationRef.current = {mode: "observer", timestamp: now}; + } + } + } + }; + + const handleConnectionModeChanged = (data: ConnectionModeChangedData) => { + if (data.newMode) { + handleModeChanged({ mode: data.newMode }); + } + }; + + const handleHidReadyForPrimary = () => { + const { rpcHidChannel } = useRTCStore.getState(); + if (rpcHidChannel?.readyState === "open") { + rpcHidChannel.dispatchEvent(new Event("open")); + } + }; + + const handleOtherSessionConnected = () => { + notify.warning("Another session is connecting", { + duration: 5000 + }); + }; + + useEffect(() => { + if (!sendFnRef.current) return; + + const fetchSessions = async () => { + try { + const sessions = await sessionApi.getSessions(sendFnRef.current!); + setSessions(sessions); + } catch (error) { + console.error("Failed to fetch sessions:", error); + setSessionError("Failed to fetch session information"); + } + }; + + fetchSessions(); + }, [setSessions, setSessionError]); + + useEffect(() => { + if (!sendFnRef.current) return; + + const intervalId = setInterval(async () => { + if (!sendFnRef.current) return; + + try { + const sessions = await sessionApi.getSessions(sendFnRef.current); + setSessions(sessions); + } catch { + // Silently fail on refresh errors + } + }, 30000); // Refresh every 30 seconds + + return () => clearInterval(intervalId); + }, [setSessions]); + + return { + handleSessionEvent + }; +} \ No newline at end of file diff --git a/ui/src/hooks/useSessionManagement.ts b/ui/src/hooks/useSessionManagement.ts new file mode 100644 index 000000000..8925b3902 --- /dev/null +++ b/ui/src/hooks/useSessionManagement.ts @@ -0,0 +1,173 @@ +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"; + +type RpcSendFunction = (method: string, params: Record, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void; + +interface SessionResponse { + sessionId?: string; + mode?: string; +} + +interface PrimaryControlRequest { + requestId: string; + identity: string; + source: string; + nickname?: string; +} + +interface NewSessionRequest { + sessionId: string; + source: "local" | "cloud"; + identity?: string; + nickname?: string; +} + +export function useSessionManagement(sendFn: RpcSendFunction | null) { + const { + setCurrentSession, + clearSession + } = useSessionStore(); + + const { hasPermission, isLoading: isLoadingPermissions } = usePermissions(); + + const { requireSessionApproval } = useSettingsStore(); + const { handleSessionEvent } = useSessionEvents(sendFn); + const [primaryControlRequest, setPrimaryControlRequest] = useState(null); + const [newSessionRequest, setNewSessionRequest] = useState(null); + + const handleSessionResponse = useCallback((response: SessionResponse) => { + if (response.sessionId && response.mode) { + setCurrentSession(response.sessionId, response.mode as "primary" | "observer" | "queued" | "pending"); + } + }, [setCurrentSession]); + + const handleApprovePrimaryRequest = useCallback(async (requestId: string) => { + if (!sendFn) return; + + return new Promise((resolve, reject) => { + sendFn("approvePrimaryRequest", { requesterID: requestId }, (response: { result?: unknown; error?: { message: string } }) => { + if (response.error) { + console.error("Failed to approve primary request:", response.error); + reject(new Error(response.error.message || "Failed to approve")); + } else { + setPrimaryControlRequest(null); + resolve(); + } + }); + }); + }, [sendFn]); + + const handleDenyPrimaryRequest = useCallback(async (requestId: string) => { + if (!sendFn) return; + + return new Promise((resolve, reject) => { + sendFn("denyPrimaryRequest", { requesterID: requestId }, (response: { result?: unknown; error?: { message: string } }) => { + if (response.error) { + console.error("Failed to deny primary request:", response.error); + reject(new Error(response.error.message || "Failed to deny")); + } else { + setPrimaryControlRequest(null); + resolve(); + } + }); + }); + }, [sendFn]); + + const handleApproveNewSession = useCallback(async (sessionId: string) => { + if (!sendFn) return; + + return new Promise((resolve, reject) => { + sendFn("approveNewSession", { sessionId }, (response: { result?: unknown; error?: { message: string } }) => { + if (response.error) { + console.error("Failed to approve new session:", response.error); + reject(new Error(response.error.message || "Failed to approve")); + } else { + setNewSessionRequest(null); + resolve(); + } + }); + }); + }, [sendFn]); + + const handleDenyNewSession = useCallback(async (sessionId: string) => { + if (!sendFn) return; + + return new Promise((resolve, reject) => { + sendFn("denyNewSession", { sessionId }, (response: { result?: unknown; error?: { message: string } }) => { + if (response.error) { + console.error("Failed to deny new session:", response.error); + reject(new Error(response.error.message || "Failed to deny")); + } else { + setNewSessionRequest(null); + resolve(); + } + }); + }); + }, [sendFn]); + + const handleRpcEvent = useCallback((method: string, params: unknown) => { + 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); + } + } + + if (method === "primaryControlRequested") { + setPrimaryControlRequest(params as PrimaryControlRequest); + } + + if (method === "primaryControlApproved") { + const { setRequestingPrimary } = useSessionStore.getState(); + setRequestingPrimary(false); + } + + if (method === "primaryControlDenied") { + const { setRequestingPrimary, setSessionError } = useSessionStore.getState(); + setRequestingPrimary(false); + setSessionError("Your primary control request was denied"); + } + + if (method === "sessionAccessDenied") { + const { setSessionError } = useSessionStore.getState(); + 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]); + + useEffect(() => { + return () => { + clearSession(); + }; + }, [clearSession]); + + return { + handleSessionResponse, + handleRpcEvent, + primaryControlRequest, + handleApprovePrimaryRequest, + handleDenyPrimaryRequest, + closePrimaryControlRequest: () => setPrimaryControlRequest(null), + newSessionRequest, + handleApproveNewSession, + handleDenyNewSession, + closeNewSessionRequest: () => setNewSessionRequest(null) + }; +} \ No newline at end of file diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 79ca67170..f05d92f7c 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -49,6 +49,7 @@ const SecurityAccessLocalAuthRoute = lazy(() => import("@routes/devices.$id.sett const SettingsMacrosRoute = lazy(() => import("@routes/devices.$id.settings.macros")); const SettingsMacrosAddRoute = lazy(() => import("@routes/devices.$id.settings.macros.add")); const SettingsMacrosEditRoute = lazy(() => import("@routes/devices.$id.settings.macros.edit")); +const SettingsMultiSessionsRoute = lazy(() => import("@routes/devices.$id.settings.multi-session")); export const isOnDevice = import.meta.env.MODE === "device"; export const isInCloud = !isOnDevice; @@ -211,6 +212,10 @@ if (isOnDevice) { }, ], }, + { + path: "sessions", + element: , + }, ], }, ], @@ -344,6 +349,10 @@ if (isOnDevice) { }, ], }, + { + path: "sessions", + element: , + }, ], }, ], diff --git a/ui/src/notifications.tsx b/ui/src/notifications.tsx index 5158d8d30..a10e63a34 100644 --- a/ui/src/notifications.tsx +++ b/ui/src/notifications.tsx @@ -1,6 +1,11 @@ import toast, { Toast, Toaster, useToasterStore } from "react-hot-toast"; import React, { useEffect } from "react"; -import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid"; +import { + CheckCircleIcon, + XCircleIcon, + InformationCircleIcon, + ExclamationTriangleIcon +} from "@heroicons/react/20/solid"; import Card from "@/components/Card"; @@ -57,6 +62,32 @@ const notifications = { { duration: 2000, ...options }, ); }, + + info: (message: string, options?: NotificationOptions) => { + return toast.custom( + t => ( + } + message={message} + t={t} + /> + ), + { duration: 2000, ...options }, + ); + }, + + warning: (message: string, options?: NotificationOptions) => { + return toast.custom( + t => ( + } + message={message} + t={t} + /> + ), + { duration: 3000, ...options }, + ); + }, }; function useMaxToasts(max: number) { @@ -82,7 +113,12 @@ export function Notifications({ } // eslint-disable-next-line react-refresh/only-export-components -export default Object.assign(Notifications, { +export const notify = { success: notifications.success, error: notifications.error, -}); + info: notifications.info, + warning: notifications.warning, +}; + +// eslint-disable-next-line react-refresh/only-export-components +export default Object.assign(Notifications, notify); diff --git a/ui/src/providers/PermissionsProvider.tsx b/ui/src/providers/PermissionsProvider.tsx new file mode 100644 index 000000000..57ac8e486 --- /dev/null +++ b/ui/src/providers/PermissionsProvider.tsx @@ -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, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void; + +interface PermissionsResponse { + mode: string; + permissions: Record; +} + +export function PermissionsProvider({ children }: { children: ReactNode }) { + const { currentMode } = useSessionStore(); + const { setRpcHidProtocolVersion, rpcHidChannel, rpcDataChannel } = useRTCStore(); + const [permissions, setPermissions] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + const previousCanControl = useRef(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 ( + + {children} + + ); +} diff --git a/ui/src/routes/devices.$id.settings.access._index.tsx b/ui/src/routes/devices.$id.settings.access._index.tsx index f30bfef1c..18a680a49 100644 --- a/ui/src/routes/devices.$id.settings.access._index.tsx +++ b/ui/src/routes/devices.$id.settings.access._index.tsx @@ -201,6 +201,7 @@ export default function SettingsAccessIndexRoute() { if ("error" in resp) return console.error(resp.error); setDeviceId(resp.result as string); }); + }, [send, getCloudState, getTLSState]); return ( @@ -327,6 +328,7 @@ export default function SettingsAccessIndexRoute() { )} +
{ setDisplayRotation(rotation); @@ -58,17 +61,39 @@ export default function SettingsHardwareRoute() { }); }; + // Check permissions before fetching settings data useEffect(() => { - send("getBacklightSettings", {}, (resp: JsonRpcResponse) => { - if ("error" in resp) { - return notifications.error( - `Failed to get backlight settings: ${resp.error.data || "Unknown error"}`, - ); - } - const result = resp.result as BacklightSettings; - setBacklightSettings(result); - }); - }, [send, setBacklightSettings]); + // Only fetch settings if user has permission + if (!isLoading && permissions[Permission.SETTINGS_READ] === true) { + send("getBacklightSettings", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + return notifications.error( + `Failed to get backlight settings: ${resp.error.data || "Unknown error"}`, + ); + } + const result = resp.result as BacklightSettings; + setBacklightSettings(result); + }); + } + }, [send, setBacklightSettings, isLoading, permissions]); + + // Return early if permissions are loading + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + // Return early if no permission + if (!hasPermission(Permission.SETTINGS_READ)) { + return ( +
+
Access Denied: You do not have permission to view these settings.
+
+ ); + } return (
diff --git a/ui/src/routes/devices.$id.settings.multi-session.tsx b/ui/src/routes/devices.$id.settings.multi-session.tsx new file mode 100644 index 000000000..4691876ec --- /dev/null +++ b/ui/src/routes/devices.$id.settings.multi-session.tsx @@ -0,0 +1,374 @@ +import { useEffect, useState } from "react"; +import { + UserGroupIcon, +} from "@heroicons/react/16/solid"; + +import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc"; +import { usePermissions } from "@/hooks/usePermissions"; +import { Permission } from "@/types/permissions"; +import { useSettingsStore } from "@/hooks/stores"; +import { notify } from "@/notifications"; +import Card from "@/components/Card"; +import Checkbox from "@/components/Checkbox"; +import { SettingsPageHeader } from "@/components/SettingsPageheader"; +import { SettingsItem } from "@/components/SettingsItem"; + +export default function SessionsSettings() { + const { send } = useJsonRpc(); + const { hasPermission } = usePermissions(); + const canModifySettings = hasPermission(Permission.SETTINGS_WRITE); + + const { + requireSessionNickname, + setRequireSessionNickname, + requireSessionApproval, + setRequireSessionApproval, + maxRejectionAttempts, + setMaxRejectionAttempts + } = useSettingsStore(); + + const [reconnectGrace, setReconnectGrace] = useState(10); + const [primaryTimeout, setPrimaryTimeout] = useState(300); + const [privateKeystrokes, setPrivateKeystrokes] = useState(false); + const [maxSessions, setMaxSessions] = useState(10); + const [observerTimeout, setObserverTimeout] = useState(120); + + useEffect(() => { + send("getSessionSettings", {}, (response: JsonRpcResponse) => { + if ("error" in response) { + console.error("Failed to get session settings:", response.error); + } else { + const settings = response.result as { + requireApproval: boolean; + requireNickname: boolean; + reconnectGrace?: number; + primaryTimeout?: number; + privateKeystrokes?: boolean; + maxRejectionAttempts?: number; + maxSessions?: number; + observerTimeout?: number; + }; + setRequireSessionApproval(settings.requireApproval); + setRequireSessionNickname(settings.requireNickname); + if (settings.reconnectGrace !== undefined) { + setReconnectGrace(settings.reconnectGrace); + } + if (settings.primaryTimeout !== undefined) { + setPrimaryTimeout(settings.primaryTimeout); + } + if (settings.privateKeystrokes !== undefined) { + setPrivateKeystrokes(settings.privateKeystrokes); + } + if (settings.maxRejectionAttempts !== undefined) { + setMaxRejectionAttempts(settings.maxRejectionAttempts); + } + if (settings.maxSessions !== undefined) { + setMaxSessions(settings.maxSessions); + } + if (settings.observerTimeout !== undefined) { + setObserverTimeout(settings.observerTimeout); + } + } + }); + }, [send, setRequireSessionApproval, setRequireSessionNickname, setMaxRejectionAttempts]); + + const updateSessionSettings = (updates: Partial<{ + requireApproval: boolean; + requireNickname: boolean; + reconnectGrace: number; + primaryTimeout: number; + privateKeystrokes: boolean; + maxRejectionAttempts: number; + maxSessions: number; + observerTimeout: number; + }>) => { + if (!canModifySettings) { + notify.error("Only the primary session can change this setting"); + return; + } + + send("setSessionSettings", { + settings: { + requireApproval: requireSessionApproval, + requireNickname: requireSessionNickname, + reconnectGrace: reconnectGrace, + primaryTimeout: primaryTimeout, + privateKeystrokes: privateKeystrokes, + maxRejectionAttempts: maxRejectionAttempts, + maxSessions: maxSessions, + observerTimeout: observerTimeout, + ...updates + } + }, (response: JsonRpcResponse) => { + if ("error" in response) { + console.error("Failed to update session settings:", response.error); + notify.error("Failed to update session settings"); + } + }); + }; + + return ( +
+ + + {!canModifySettings && ( + +
+ Note: Only the primary session can modify these settings. + Request primary control to change settings. +
+
+ )} + + +
+
+ +

+ Access Control +

+
+ + + { + const newValue = e.target.checked; + setRequireSessionApproval(newValue); + updateSessionSettings({ requireApproval: newValue }); + notify.success( + newValue + ? "New sessions will require approval" + : "New sessions will be automatically approved" + ); + }} + /> + + + + { + const newValue = e.target.checked; + setRequireSessionNickname(newValue); + updateSessionSettings({ requireNickname: newValue }); + notify.success( + newValue + ? "Session nicknames are now required" + : "Session nicknames are now optional" + ); + }} + /> + + + +
+ { + const newValue = parseInt(e.target.value) || 3; + if (newValue < 1 || newValue > 10) { + notify.error("Maximum attempts must be between 1 and 10"); + return; + } + setMaxRejectionAttempts(newValue); + updateSessionSettings({ maxRejectionAttempts: newValue }); + notify.success( + `Denied sessions can now retry up to ${newValue} time${newValue === 1 ? '' : 's'}` + ); + }} + className="w-20 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm" + /> + attempts +
+
+ + +
+ { + const newValue = parseInt(e.target.value) || 10; + if (newValue < 5 || newValue > 60) { + notify.error("Grace period must be between 5 and 60 seconds"); + return; + } + setReconnectGrace(newValue); + updateSessionSettings({ reconnectGrace: newValue }); + notify.success( + `Session will have ${newValue} seconds to reconnect` + ); + }} + className="w-20 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm" + /> + seconds +
+
+ + +
+ { + const newValue = parseInt(e.target.value) || 0; + if (newValue < 0 || newValue > 3600) { + notify.error("Timeout must be between 0 and 3600 seconds"); + return; + } + setPrimaryTimeout(newValue); + updateSessionSettings({ primaryTimeout: newValue }); + notify.success( + newValue === 0 + ? "Primary session timeout disabled" + : `Primary session will timeout after ${Math.round(newValue / 60)} minutes of inactivity` + ); + }} + className="w-24 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm" + /> + seconds +
+
+ + +
+ { + const newValue = parseInt(e.target.value) || 10; + if (newValue < 1 || newValue > 20) { + notify.error("Max sessions must be between 1 and 20"); + return; + } + setMaxSessions(newValue); + updateSessionSettings({ maxSessions: newValue }); + notify.success( + `Maximum concurrent sessions set to ${newValue}` + ); + }} + className="w-20 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm" + /> + sessions +
+
+ + +
+ { + const newValue = parseInt(e.target.value) || 120; + if (newValue < 30 || newValue > 600) { + notify.error("Timeout must be between 30 and 600 seconds"); + return; + } + setObserverTimeout(newValue); + updateSessionSettings({ observerTimeout: newValue }); + notify.success( + `Observer cleanup timeout set to ${Math.round(newValue / 60)} minute${Math.round(newValue / 60) === 1 ? '' : 's'}` + ); + }} + className="w-20 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm" + /> + seconds +
+
+ + + { + const newValue = e.target.checked; + setPrivateKeystrokes(newValue); + updateSessionSettings({ privateKeystrokes: newValue }); + notify.success( + newValue + ? "Keystrokes are now private to primary session" + : "Keystrokes are visible to all authorized sessions" + ); + }} + /> + +
+
+ + +
+
+

+ How Multi-Session Access Works +

+
+
+ Primary: + Full control over the KVM device including keyboard, mouse, and settings +
+
+ Observer: + View-only access to monitor activity without control capabilities +
+
+ Pending: + Awaiting approval from the primary session (when approval is required) +
+
+
+ Use the Sessions panel in the top navigation bar to view and manage active sessions. +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 338beb976..a81f5c1c4 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; -import { NavLink, Outlet, useLocation } from "react-router"; +import { NavLink, Outlet, useLocation , useNavigate } from "react-router"; import { LuSettings, LuMouse, @@ -12,6 +12,7 @@ import { LuPalette, LuCommand, LuNetwork, + LuUsers, } from "react-icons/lu"; import { useResizeObserver } from "usehooks-ts"; @@ -20,11 +21,24 @@ import Card from "@components/Card"; 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"; /* 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() { const location = useLocation(); + const navigate = useNavigate(); const { setDisableVideoFocusTrap } = useUiStore(); + const { currentMode } = useSessionStore(); + const { hasPermission, isLoading, permissions } = usePermissions(); + + useEffect(() => { + if (!isLoading && !permissions[Permission.SETTINGS_ACCESS] && currentMode !== null) { + navigate("/", { replace: true }); + } + }, [permissions, isLoading, currentMode, navigate]); + const scrollContainerRef = useRef(null); const [showLeftGradient, setShowLeftGradient] = useState(false); const [showRightGradient, setShowRightGradient] = useState(false); @@ -69,6 +83,21 @@ export default function SettingsRoute() { }; }, [setDisableVideoFocusTrap]); + // Check permissions first - return early to prevent any content flash + // Show loading state while permissions are being checked + if (isLoading) { + return ( +
+
Checking permissions...
+
+ ); + } + + // Don't render settings content if user doesn't have permission + if (!hasPermission(Permission.SETTINGS_ACCESS)) { + return null; + } + return (
@@ -223,6 +252,17 @@ export default function SettingsRoute() {
+
+ (isActive ? "active" : "")} + > +
+ +

Multi-Session Access

+
+
+
import('@/components/sidebar/connectionStats')); const Terminal = lazy(() => import('@components/Terminal')); @@ -48,8 +53,14 @@ 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"; +import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore"; +import { sessionApi } from "@/api/sessionApi"; interface LocalLoaderResp { authMode: "password" | "noPassword" | null; @@ -122,7 +133,7 @@ export default function KvmIdRoute() { const authMode = "authMode" in loaderResp ? loaderResp.authMode : null; const params = useParams() as { id: string }; - const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore(); + const { sidebarView, setSidebarView, disableVideoFocusTrap, setDisableVideoFocusTrap } = useUiStore(); const [ queryParams, setQueryParams ] = useSearchParams(); const { @@ -141,14 +152,19 @@ export default function KvmIdRoute() { const location = useLocation(); const isLegacySignalingEnabled = useRef(false); const [connectionFailed, setConnectionFailed] = useState(false); + const [showNicknameModal, setShowNicknameModal] = useState(false); + const [accessDenied, setAccessDenied] = useState(false); const navigate = useNavigate(); const { otaState, setOtaState, setModalView } = useUpdateStore(); + const { currentSessionId, currentMode, setCurrentSession } = useSessionStore(); + const { nickname, setNickname } = useSharedSessionStore(); + const { setRequireSessionApproval, setRequireSessionNickname } = useSettingsStore(); + const [globalSessionSettings, setGlobalSessionSettings] = useState<{requireApproval: boolean, requireNickname: boolean} | null>(null); const [loadingMessage, setLoadingMessage] = useState("Connecting to device..."); const cleanupAndStopReconnecting = useCallback( function cleanupAndStopReconnecting() { - console.log("Closing peer connection"); setConnectionFailed(true); if (peerConnection) { @@ -186,7 +202,6 @@ export default function KvmIdRoute() { try { await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription)); - console.log("[setRemoteSessionDescription] Remote description set successfully"); setLoadingMessage("Establishing secure connection..."); } catch (error) { console.error( @@ -204,7 +219,6 @@ export default function KvmIdRoute() { // When vivaldi has disabled "Broadcast IP for Best WebRTC Performance", this never connects if (pc.sctp?.state === "connected") { - console.log("[setRemoteSessionDescription] Remote description set"); clearInterval(checkInterval); setLoadingMessage("Connection established"); } else if (attempts >= 10) { @@ -217,11 +231,6 @@ export default function KvmIdRoute() { ); cleanupAndStopReconnecting(); clearInterval(checkInterval); - } else { - console.log("[setRemoteSessionDescription] Waiting for connection, state:", { - connectionState: pc.connectionState, - iceConnectionState: pc.iceConnectionState, - }); } }, 1000); }, @@ -244,18 +253,15 @@ export default function KvmIdRoute() { reconnectAttempts: 15, reconnectInterval: 1000, onReconnectStop: () => { - console.debug("Reconnect stopped"); cleanupAndStopReconnecting(); }, - shouldReconnect(event) { - console.debug("[Websocket] shouldReconnect", event); + shouldReconnect(_event) { // TODO: Why true? return true; }, - onClose(event) { - console.debug("[Websocket] onClose", event); + onClose(_event) { // We don't want to close everything down, we wait for the reconnect to stop instead }, @@ -264,7 +270,7 @@ export default function KvmIdRoute() { // We don't want to close everything down, we wait for the reconnect to stop instead }, onOpen() { - console.debug("[Websocket] onOpen"); + // Connection established, message handling will begin }, onMessage: message => { @@ -285,27 +291,49 @@ export default function KvmIdRoute() { const parsedMessage = JSON.parse(message.data); if (parsedMessage.type === "device-metadata") { - const { deviceVersion } = parsedMessage.data; - console.debug("[Websocket] Received device-metadata message"); - console.debug("[Websocket] Device version", deviceVersion); + const { deviceVersion, sessionSettings } = parsedMessage.data; + + // Store session settings if provided + if (sessionSettings) { + setGlobalSessionSettings({ + requireNickname: sessionSettings.requireNickname || false, + requireApproval: sessionSettings.requireApproval || false + }); + // Also update the settings store for approval handling + setRequireSessionApproval(sessionSettings.requireApproval || false); + setRequireSessionNickname(sessionSettings.requireNickname || false); + } + // If the device version is not set, we can assume the device is using the legacy signaling if (!deviceVersion) { - console.log("[Websocket] Device is using legacy signaling"); // Now we don't need the websocket connection anymore, as we've established that we need to use the legacy signaling // which does everything over HTTP(at least from the perspective of the client) isLegacySignalingEnabled.current = true; getWebSocket()?.close(); } else { - console.log("[Websocket] Device is using new signaling"); isLegacySignalingEnabled.current = false; } + + // Always setup peer connection first to establish RPC channel for nickname generation setupPeerConnection(); + + // Check if nickname is required and not set - modal will be shown after RPC channel is ready + const requiresNickname = sessionSettings?.requireNickname || false; + + if (requiresNickname && !nickname) { + // Store that we need to show the nickname modal once RPC is ready + // The useEffect in NicknameModal will handle waiting for RPC channel readiness + setShowNicknameModal(true); + setDisableVideoFocusTrap(true); + } } - if (!peerConnection) return; + if (!peerConnection) { + console.warn("[Websocket] Ignoring message because peerConnection is not ready:", parsedMessage.type); + return; + } if (parsedMessage.type === "answer") { - console.debug("[Websocket] Received answer"); const readyForOffer = // If we're making an offer, we don't want to accept an answer !makingOffer && @@ -319,14 +347,41 @@ export default function KvmIdRoute() { // Set so we don't accept an answer while we're setting the remote description isSettingRemoteAnswerPending.current = parsedMessage.type === "answer"; - console.debug( - "[Websocket] Setting remote answer pending", - isSettingRemoteAnswerPending.current, - ); const sd = atob(parsedMessage.data); const remoteSessionDescription = JSON.parse(sd); + if (parsedMessage.sessionId && parsedMessage.mode) { + handleSessionResponse({ + sessionId: parsedMessage.sessionId, + mode: parsedMessage.mode + }); + + // Store sessionId via zustand (persists to sessionStorage for per-tab isolation) + setCurrentSession(parsedMessage.sessionId, parsedMessage.mode); + if (parsedMessage.requireNickname !== undefined && parsedMessage.requireApproval !== undefined) { + setGlobalSessionSettings({ + requireNickname: parsedMessage.requireNickname, + requireApproval: parsedMessage.requireApproval + }); + // Also update the settings store for approval handling + setRequireSessionApproval(parsedMessage.requireApproval); + setRequireSessionNickname(parsedMessage.requireNickname); + } + + // Show nickname modal if: + // 1. Nickname is required by backend settings + // 2. We don't already have a nickname + // This happens even for pending sessions so the nickname is included in approval + const hasNickname = parsedMessage.nickname && parsedMessage.nickname.length > 0; + const requiresNickname = parsedMessage.requireNickname || globalSessionSettings?.requireNickname; + + if (requiresNickname && !hasNickname) { + setShowNicknameModal(true); + setDisableVideoFocusTrap(true); + } + } + setRemoteSessionDescription( peerConnection, new RTCSessionDescription(remoteSessionDescription), @@ -335,9 +390,11 @@ export default function KvmIdRoute() { // Reset the remote answer pending flag isSettingRemoteAnswerPending.current = false; } else if (parsedMessage.type === "new-ice-candidate") { - console.debug("[Websocket] Received new-ice-candidate"); const candidate = parsedMessage.data; - peerConnection.addIceCandidate(candidate); + // Always try to add the ICE candidate - the browser will queue it internally if needed + peerConnection.addIceCandidate(candidate).catch(error => { + console.warn("[Websocket] Failed to add ICE candidate:", error); + }); } }, }, @@ -350,9 +407,16 @@ export default function KvmIdRoute() { (type: string, data: unknown) => { // Second argument tells the library not to queue the message, and send it once the connection is established again. // We have event handlers that handle the connection set up, so we don't need to queue the message. - sendMessage(JSON.stringify({ type, data }), false); + const message = JSON.stringify({ type, data }); + const ws = getWebSocket(); + if (ws?.readyState === WebSocket.OPEN) { + sendMessage(message, false); + } else { + console.warn(`[WebSocket] WebSocket not open, queuing message:`, message); + sendMessage(message, true); // Queue the message + } }, - [sendMessage], + [sendMessage, getWebSocket], ); const legacyHTTPSignaling = useCallback( @@ -363,12 +427,12 @@ export default function KvmIdRoute() { // In device mode, old devices wont server this JS, and on newer devices legacy mode wont be enabled const sessionUrl = `${CLOUD_API}/webrtc/session`; - console.log("Trying to get remote session description"); setLoadingMessage( `Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`, ); const res = await api.POST(sessionUrl, { sd, + userAgent: navigator.userAgent, // When on device, we don't need to specify the device id, as it's already known ...(isOnDevice ? {} : { id: params.id }), }); @@ -381,7 +445,6 @@ export default function KvmIdRoute() { return; } - console.debug("Successfully got Remote Session Description. Setting."); setLoadingMessage("Setting remote session description..."); const decodedSd = atob(json.sd); @@ -392,13 +455,11 @@ export default function KvmIdRoute() { ); const setupPeerConnection = useCallback(async () => { - console.debug("[setupPeerConnection] Setting up peer connection"); setConnectionFailed(false); setLoadingMessage("Connecting to device..."); let pc: RTCPeerConnection; try { - console.debug("[setupPeerConnection] Creating peer connection"); setLoadingMessage("Creating peer connection..."); pc = new RTCPeerConnection({ // We only use STUN or TURN servers if we're in the cloud @@ -408,7 +469,6 @@ export default function KvmIdRoute() { }); setPeerConnectionState(pc.connectionState); - console.debug("[setupPeerConnection] Peer connection created", pc); setLoadingMessage("Setting up connection to device..."); } catch (e) { console.error(`[setupPeerConnection] Error creating peer connection: ${e}`); @@ -420,13 +480,11 @@ export default function KvmIdRoute() { // Set up event listeners and data channels pc.onconnectionstatechange = () => { - console.debug("[setupPeerConnection] Connection state changed", pc.connectionState); setPeerConnectionState(pc.connectionState); }; pc.onnegotiationneeded = async () => { try { - console.debug("[setupPeerConnection] Creating offer"); makingOffer.current = true; const offer = await pc.createOffer(); @@ -434,9 +492,19 @@ export default function KvmIdRoute() { const sd = btoa(JSON.stringify(pc.localDescription)); const isNewSignalingEnabled = isLegacySignalingEnabled.current === false; if (isNewSignalingEnabled) { - sendWebRTCSignal("offer", { sd: sd }); - } else { - console.log("Legacy signaling. Waiting for ICE Gathering to complete..."); + // Get nickname and sessionId from zustand stores + // sessionId is per-tab (sessionStorage), nickname is shared (localStorage) + const { currentSessionId: storeSessionId } = useSessionStore.getState(); + const { nickname: storeNickname } = useSharedSessionStore.getState(); + + sendWebRTCSignal("offer", { + sd: sd, + sessionId: storeSessionId || undefined, + userAgent: navigator.userAgent, + sessionSettings: { + nickname: storeNickname || undefined + } + }); } } catch (e) { console.error( @@ -450,15 +518,18 @@ export default function KvmIdRoute() { }; pc.onicecandidate = ({ candidate }) => { - if (!candidate) return; - if (candidate.candidate === "") return; + if (!candidate) { + return; + } + if (candidate.candidate === "") { + return; + } sendWebRTCSignal("new-ice-candidate", candidate); }; pc.onicegatheringstatechange = event => { const pc = event.currentTarget as RTCPeerConnection; if (pc.iceGatheringState === "complete") { - console.debug("ICE Gathering completed"); setLoadingMessage("ICE Gathering completed"); if (isLegacySignalingEnabled.current) { @@ -466,7 +537,6 @@ export default function KvmIdRoute() { legacyHTTPSignaling(pc); } } else if (pc.iceGatheringState === "gathering") { - console.debug("ICE Gathering Started"); setLoadingMessage("Gathering ICE candidates..."); } }; @@ -609,42 +679,54 @@ export default function KvmIdRoute() { const { navigateTo } = useDeviceUiNavigation(); function onJsonRpcRequest(resp: JsonRpcRequest) { - if (resp.method === "otherSessionConnected") { - navigateTo("/other-session"); + // Handle session-related events + if (resp.method === "sessionsUpdated" || + resp.method === "modeChanged" || + resp.method === "connectionModeChanged" || + resp.method === "otherSessionConnected" || + resp.method === "primaryControlRequested" || + resp.method === "primaryControlApproved" || + resp.method === "primaryControlDenied" || + resp.method === "newSessionPending" || + resp.method === "sessionAccessDenied") { + handleRpcEvent(resp.method, resp.params); + + // Show access denied overlay if our session was denied + if (resp.method === "sessionAccessDenied") { + setAccessDenied(true); + } + + if (resp.method === "otherSessionConnected") { + navigateTo("/other-session"); + } } if (resp.method === "usbState") { const usbState = resp.params as unknown as USBStates; - console.debug("Setting USB state", usbState); setUsbState(usbState); } if (resp.method === "videoInputState") { const hdmiState = resp.params as Parameters[0]; - console.debug("Setting HDMI state", hdmiState); setHdmiState(hdmiState); } if (resp.method === "networkState") { - console.debug("Setting network state", resp.params); setNetworkState(resp.params as NetworkState); } if (resp.method === "keyboardLedState") { const ledState = resp.params as KeyboardLedState; - console.debug("Setting keyboard led state", ledState); setKeyboardLedState(ledState); } if (resp.method === "keysDownState") { const downState = resp.params as KeysDownState; - console.debug("Setting key down state:", downState); setKeysDownState(downState); } if (resp.method === "otaState") { const otaState = resp.params as OtaState; - console.debug("Setting OTA state", otaState); setOtaState(otaState); if (otaState.updating === true) { @@ -670,24 +752,38 @@ export default function KvmIdRoute() { const { send } = useJsonRpc(onJsonRpcRequest); + const { + handleSessionResponse, + handleRpcEvent, + primaryControlRequest, + handleApprovePrimaryRequest, + handleDenyPrimaryRequest, + closePrimaryControlRequest, + newSessionRequest, + handleApproveNewSession, + handleDenyNewSession, + closeNewSessionRequest + } = useSessionManagement(send); + + const { hasPermission, isLoading: isLoadingPermissions } = usePermissions(); + useEffect(() => { if (rpcDataChannel?.readyState !== "open") return; - console.log("Requesting video state"); + if (isLoadingPermissions || !hasPermission(Permission.VIDEO_VIEW)) return; + send("getVideoState", {}, (resp: JsonRpcResponse) => { if ("error" in resp) return; const hdmiState = resp.result as Parameters[0]; - console.debug("Setting HDMI state", hdmiState); 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; - console.log("Requesting keyboard led state"); + if (isLoadingPermissions || !hasPermission(Permission.KEYBOARD_INPUT)) return; send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { @@ -695,26 +791,22 @@ export default function KvmIdRoute() { return; } else { const ledState = resp.result as KeyboardLedState; - console.debug("Keyboard led state: ", ledState); setKeyboardLedState(ledState); } 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; - console.log("Requesting keys down state"); + 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 { @@ -722,12 +814,11 @@ export default function KvmIdRoute() { } } else { const downState = resp.result as KeysDownState; - console.debug("Keyboard key down state", downState); setKeysDownState(downState); } 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(() => { @@ -762,7 +853,8 @@ export default function KvmIdRoute() { if (appVersion) return; getLocalVersion(); - }, [appVersion, getLocalVersion]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appVersion]); const ConnectionStatusElement = useMemo(() => { const hasConnectionFailed = @@ -802,8 +894,9 @@ export default function KvmIdRoute() { ]); return ( - - {!outlet && otaState.updating && ( + + + {!outlet && otaState.updating && ( +
- -
-
- {!!ConnectionStatusElement && ConnectionStatusElement} + {/* Only show video feed if nickname is set (when required) and not pending approval */} + {(!showNicknameModal && currentMode !== "pending") ? ( + <> + +
+
+ {!!ConnectionStatusElement && ConnectionStatusElement} +
+
+ + ) : ( +
+
+ {showNicknameModal &&

Please set your nickname to continue

} + {currentMode === "pending" &&

Waiting for session approval...

} +
-
+ )}
@@ -870,6 +976,27 @@ export default function KvmIdRoute() { {/* The 'used by other session' modal needs to have access to the connectWebRTC function */} + + { + setNickname(nickname); + setShowNicknameModal(false); + setDisableVideoFocusTrap(false); + + if (currentSessionId && send) { + try { + await sessionApi.updateNickname(send, currentSessionId, nickname); + } catch (error) { + console.error("Failed to update nickname:", error); + } + } + }} + onSkip={() => { + setShowNicknameModal(false); + setDisableVideoFocusTrap(false); + }} + />
{kvmTerminal && ( @@ -879,7 +1006,71 @@ export default function KvmIdRoute() { {serialConsole && ( )} - + + {/* Unified Session Request Dialog */} + {(primaryControlRequest || newSessionRequest) && ( + + )} + + { + if (!send) return; + try { + await sessionApi.requestSessionApproval(send); + setAccessDenied(false); + } catch (error) { + console.error("Failed to re-request approval:", error); + } + }} + /> + + + + ); } diff --git a/ui/src/stores/sessionStore.ts b/ui/src/stores/sessionStore.ts new file mode 100644 index 000000000..eee8aa0a2 --- /dev/null +++ b/ui/src/stores/sessionStore.ts @@ -0,0 +1,175 @@ +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; + +export type SessionMode = "primary" | "observer" | "queued" | "pending"; + +export interface SessionInfo { + id: string; + mode: SessionMode; + source: "local" | "cloud"; + identity?: string; + nickname?: string; + createdAt: string; + lastActive: string; +} + +export interface SessionState { + // Current session info + currentSessionId: string | null; + currentMode: SessionMode | null; + + // All active sessions + sessions: SessionInfo[]; + + // UI state + isRequestingPrimary: boolean; + sessionError: string | null; + rejectionCount: number; + + // Actions + setCurrentSession: (id: string, mode: SessionMode) => void; + setSessions: (sessions: SessionInfo[]) => void; + setRequestingPrimary: (requesting: boolean) => void; + setSessionError: (error: string | null) => void; + updateSessionMode: (mode: SessionMode) => void; + clearSession: () => void; + incrementRejectionCount: () => number; + resetRejectionCount: () => void; + + // Computed getters + isPrimary: () => boolean; + isObserver: () => boolean; + isQueued: () => boolean; + isPending: () => boolean; + canRequestPrimary: () => boolean; + getPrimarySession: () => SessionInfo | undefined; + getQueuePosition: () => number; +} + +export const useSessionStore = create()( + persist( + (set, get) => ({ + // Initial state + currentSessionId: null, + currentMode: null, + sessions: [], + isRequestingPrimary: false, + sessionError: null, + rejectionCount: 0, + + // Actions + setCurrentSession: (id: string, mode: SessionMode) => { + set({ + currentSessionId: id, + currentMode: mode, + sessionError: null + }); + }, + + setSessions: (sessions: SessionInfo[]) => { + set({ sessions }); + }, + + setRequestingPrimary: (requesting: boolean) => { + set({ isRequestingPrimary: requesting }); + }, + + setSessionError: (error: string | null) => { + set({ sessionError: error }); + }, + + updateSessionMode: (mode: SessionMode) => { + set({ currentMode: mode }); + }, + + clearSession: () => { + set({ + currentSessionId: null, + currentMode: null, + sessions: [], + sessionError: null, + isRequestingPrimary: false, + rejectionCount: 0 + }); + }, + + incrementRejectionCount: () => { + const newCount = get().rejectionCount + 1; + set({ rejectionCount: newCount }); + return newCount; + }, + + resetRejectionCount: () => { + set({ rejectionCount: 0 }); + }, + + // Computed getters + isPrimary: () => { + return get().currentMode === "primary"; + }, + + isObserver: () => { + return get().currentMode === "observer"; + }, + + isQueued: () => { + return get().currentMode === "queued"; + }, + + isPending: () => { + return get().currentMode === "pending"; + }, + + canRequestPrimary: () => { + const state = get(); + return state.currentMode === "observer" && + !state.isRequestingPrimary && + state.sessions.some(s => s.mode === "primary"); + }, + + getPrimarySession: () => { + return get().sessions.find(s => s.mode === "primary"); + }, + + getQueuePosition: () => { + const state = get(); + if (state.currentMode !== "queued") return -1; + + const queuedSessions = state.sessions + .filter(s => s.mode === "queued") + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + + return queuedSessions.findIndex(s => s.id === state.currentSessionId) + 1; + } + }), + { + name: 'session', + storage: createJSONStorage(() => sessionStorage), + partialize: (state) => ({ + currentSessionId: state.currentSessionId, + }), + } + ) +); + +// Shared session store - separate with localStorage (shared across tabs) +// Used for user preferences that should be consistent across all tabs +export interface SharedSessionState { + nickname: string | null; + setNickname: (nickname: string | null) => void; + clearNickname: () => void; +} + +export const useSharedSessionStore = create()( + persist( + (set) => ({ + nickname: null, + setNickname: (nickname: string | null) => set({ nickname }), + clearNickname: () => set({ nickname: null }), + }), + { + name: 'sharedSession', + storage: createJSONStorage(() => localStorage), + } + ) +); \ No newline at end of file diff --git a/ui/src/types/permissions.ts b/ui/src/types/permissions.ts new file mode 100644 index 000000000..5035fed88 --- /dev/null +++ b/ui/src/types/permissions.ts @@ -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", +} diff --git a/ui/src/utils/nicknameGenerator.ts b/ui/src/utils/nicknameGenerator.ts new file mode 100644 index 000000000..76c09fabb --- /dev/null +++ b/ui/src/utils/nicknameGenerator.ts @@ -0,0 +1,36 @@ +// Nickname generation using backend API for consistency + +type RpcSendFunction = (method: string, params: Record, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void; + +// Main function that uses backend generation +export async function generateNickname(sendFn?: RpcSendFunction): Promise { + // Require backend function - no fallback + if (!sendFn) { + throw new Error('Backend connection required for nickname generation'); + } + + return new Promise((resolve, reject) => { + try { + const result = sendFn('generateNickname', { userAgent: navigator.userAgent }, (response: { result?: unknown; error?: { message: string } }) => { + const result = response.result as { nickname?: string } | undefined; + if (response && !response.error && result?.nickname) { + resolve(result.nickname); + } else { + reject(new Error('Failed to generate nickname from backend')); + } + }); + + // If sendFn returns undefined (RPC channel not ready), reject immediately + if (result === undefined) { + reject(new Error('RPC connection not ready yet')); + } + } catch (error) { + reject(error); + } + }); +} + +// Synchronous version removed - backend generation is always async +export function generateNicknameSync(): string { + throw new Error('Synchronous nickname generation not supported - use backend generateNickname()'); +} \ No newline at end of file diff --git a/usb.go b/usb.go index af57692f6..87f549668 100644 --- a/usb.go +++ b/usb.go @@ -27,20 +27,43 @@ func initUsbGadget() { }() gadget.SetOnKeyboardStateChange(func(state usbgadget.KeyboardState) { - if currentSession != nil { - currentSession.reportHidRPCKeyboardLedState(state) + // Check if keystrokes should be private + if currentSessionSettings != nil && currentSessionSettings.PrivateKeystrokes { + // Report to primary session only + if primary := sessionManager.GetPrimarySession(); primary != nil { + primary.reportHidRPCKeyboardLedState(state) + } + } else { + // Report to all authorized sessions (primary and observers, but not pending) + sessionManager.ForEachSession(func(s *Session) { + if s.Mode == SessionModePrimary || s.Mode == SessionModeObserver { + s.reportHidRPCKeyboardLedState(state) + } + }) } }) gadget.SetOnKeysDownChange(func(state usbgadget.KeysDownState) { - if currentSession != nil { - currentSession.enqueueKeysDownState(state) + // Check if keystrokes should be private + if currentSessionSettings != nil && currentSessionSettings.PrivateKeystrokes { + // Report to primary session only + if primary := sessionManager.GetPrimarySession(); primary != nil { + primary.enqueueKeysDownState(state) + } + } else { + // Report to all authorized sessions (primary and observers, but not pending) + sessionManager.ForEachSession(func(s *Session) { + if s.Mode == SessionModePrimary || s.Mode == SessionModeObserver { + s.enqueueKeysDownState(state) + } + }) } }) gadget.SetOnKeepAliveReset(func() { - if currentSession != nil { - currentSession.resetKeepAliveTime() + // Reset keep-alive for primary session + if primary := sessionManager.GetPrimarySession(); primary != nil { + primary.resetKeepAliveTime() } }) @@ -50,26 +73,82 @@ func initUsbGadget() { } } -func rpcKeyboardReport(modifier byte, keys []byte) error { +func (s *Session) rpcKeyboardReport(modifier byte, keys []byte) error { + if s == nil || !s.HasPermission(PermissionKeyboardInput) { + return ErrPermissionDeniedKeyboard + } + sessionManager.UpdateLastActive(s.ID) return gadget.KeyboardReport(modifier, keys) } -func rpcKeypressReport(key byte, press bool) error { +func (s *Session) rpcKeypressReport(key byte, press bool) error { + if s == nil || !s.HasPermission(PermissionKeyboardInput) { + return ErrPermissionDeniedKeyboard + } + sessionManager.UpdateLastActive(s.ID) return gadget.KeypressReport(key, press) } -func rpcAbsMouseReport(x int, y int, buttons uint8) error { +func (s *Session) rpcAbsMouseReport(x int16, y int16, buttons uint8) error { + if s == nil || !s.HasPermission(PermissionMouseInput) { + return ErrPermissionDeniedMouse + } + sessionManager.UpdateLastActive(s.ID) return gadget.AbsMouseReport(x, y, buttons) } -func rpcRelMouseReport(dx int8, dy int8, buttons uint8) error { +func (s *Session) rpcRelMouseReport(dx int8, dy int8, buttons uint8) error { + if s == nil || !s.HasPermission(PermissionMouseInput) { + return ErrPermissionDeniedMouse + } + sessionManager.UpdateLastActive(s.ID) return gadget.RelMouseReport(dx, dy, buttons) } -func rpcWheelReport(wheelY int8) error { +func (s *Session) rpcWheelReport(wheelY int8) error { + if s == nil || !s.HasPermission(PermissionMouseInput) { + return ErrPermissionDeniedMouse + } + sessionManager.UpdateLastActive(s.ID) return gadget.AbsMouseWheelReport(wheelY) } +// RPC functions that route to the primary session +func rpcKeyboardReport(modifier byte, keys []byte) error { + if primary := sessionManager.GetPrimarySession(); primary != nil { + return primary.rpcKeyboardReport(modifier, keys) + } + return ErrNotPrimarySession +} + +func rpcKeypressReport(key byte, press bool) error { + if primary := sessionManager.GetPrimarySession(); primary != nil { + return primary.rpcKeypressReport(key, press) + } + return ErrNotPrimarySession +} + +func rpcAbsMouseReport(x int16, y int16, buttons uint8) error { + if primary := sessionManager.GetPrimarySession(); primary != nil { + return primary.rpcAbsMouseReport(x, y, buttons) + } + return ErrNotPrimarySession +} + +func rpcRelMouseReport(dx int8, dy int8, buttons uint8) error { + if primary := sessionManager.GetPrimarySession(); primary != nil { + return primary.rpcRelMouseReport(dx, dy, buttons) + } + return ErrNotPrimarySession +} + +func rpcWheelReport(wheelY int8) error { + if primary := sessionManager.GetPrimarySession(); primary != nil { + return primary.rpcWheelReport(wheelY) + } + return ErrNotPrimarySession +} + func rpcGetKeyboardLedState() (state usbgadget.KeyboardState) { return gadget.GetKeyboardState() } @@ -89,11 +168,7 @@ func rpcGetUSBState() (state string) { func triggerUSBStateUpdate() { go func() { - if currentSession == nil { - usbLogger.Info().Msg("No active RPC session, skipping USB state update") - return - } - writeJSONRPCEvent("usbState", usbState, currentSession) + broadcastJSONRPCEvent("usbState", usbState) }() } diff --git a/video.go b/video.go index cd74e6804..a77db0004 100644 --- a/video.go +++ b/video.go @@ -20,7 +20,7 @@ const ( func triggerVideoStateUpdate() { go func() { - writeJSONRPCEvent("videoInputState", lastVideoState, currentSession) + broadcastJSONRPCEvent("videoInputState", lastVideoState) }() nativeLogger.Info().Interface("state", lastVideoState).Msg("video state updated") diff --git a/web.go b/web.go index 452535793..bd5f9e827 100644 --- a/web.go +++ b/web.go @@ -34,10 +34,25 @@ import ( var staticFiles embed.FS type WebRTCSessionRequest struct { - Sd string `json:"sd"` - OidcGoogle string `json:"OidcGoogle,omitempty"` - IP string `json:"ip,omitempty"` - ICEServers []string `json:"iceServers,omitempty"` + Sd string `json:"sd"` + SessionId string `json:"sessionId,omitempty"` + OidcGoogle string `json:"OidcGoogle,omitempty"` + IP string `json:"ip,omitempty"` + ICEServers []string `json:"iceServers,omitempty"` + UserAgent string `json:"userAgent,omitempty"` // Browser user agent for nickname generation + SessionSettings *SessionSettings `json:"sessionSettings,omitempty"` +} + +type SessionSettings struct { + RequireApproval bool `json:"requireApproval"` + RequireNickname bool `json:"requireNickname"` + ReconnectGrace int `json:"reconnectGrace,omitempty"` // Grace period in seconds for primary reconnection + PrimaryTimeout int `json:"primaryTimeout,omitempty"` // Inactivity timeout in seconds for primary session + Nickname string `json:"nickname,omitempty"` + PrivateKeystrokes bool `json:"privateKeystrokes,omitempty"` // If true, only primary session sees keystroke events + MaxRejectionAttempts int `json:"maxRejectionAttempts,omitempty"` // Number of times denied session can retry before modal hides + MaxSessions int `json:"maxSessions,omitempty"` // Maximum number of concurrent sessions (default: 10) + ObserverTimeout int `json:"observerTimeout,omitempty"` // Time in seconds to wait before cleaning up inactive observer sessions (default: 120) } type SetPasswordRequest struct { @@ -158,32 +173,16 @@ func setupRouter() *gin.Engine { protected := r.Group("/") protected.Use(protectedMiddleware()) { - /* - * Legacy WebRTC session endpoint - * - * This endpoint is maintained for backward compatibility when users upgrade from a version - * using the legacy HTTP-based signaling method to the new WebSocket-based signaling method. - * - * During the upgrade process, when the "Rebooting device after update..." message appears, - * the browser still runs the previous JavaScript code which polls this endpoint to establish - * a new WebRTC session. Once the session is established, the page will automatically reload - * with the updated code. - * - * Without this endpoint, the stale JavaScript would fail to establish a connection, - * causing users to see the "Rebooting device after update..." message indefinitely - * until they manually refresh the page, leading to a confusing user experience. - */ - protected.POST("/webrtc/session", handleWebRTCSession) protected.GET("/webrtc/signaling/client", handleLocalWebRTCSignal) protected.POST("/cloud/register", handleCloudRegister) protected.GET("/cloud/state", handleCloudState) protected.GET("/device", handleDevice) protected.POST("/auth/logout", handleLogout) - protected.POST("/auth/password-local", handleCreatePassword) - protected.PUT("/auth/password-local", handleUpdatePassword) - protected.DELETE("/auth/local-password", handleDeletePassword) - protected.POST("/storage/upload", handleUploadHttp) + protected.POST("/auth/password-local", requirePermissionMiddleware(PermissionSettingsWrite), handleCreatePassword) + protected.PUT("/auth/password-local", requirePermissionMiddleware(PermissionSettingsWrite), handleUpdatePassword) + protected.DELETE("/auth/local-password", requirePermissionMiddleware(PermissionSettingsWrite), handleDeletePassword) + protected.POST("/storage/upload", requirePermissionMiddleware(PermissionMountMedia), handleUploadHttp) } // Catch-all route for SPA @@ -198,44 +197,6 @@ func setupRouter() *gin.Engine { return r } -// TODO: support multiple sessions? -var currentSession *Session - -func handleWebRTCSession(c *gin.Context) { - var req WebRTCSessionRequest - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - session, err := newSession(SessionConfig{}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err}) - return - } - - sd, err := session.ExchangeOffer(req.Sd) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err}) - return - } - if currentSession != nil { - writeJSONRPCEvent("otherSessionConnected", nil, currentSession) - peerConn := currentSession.peerConnection - go func() { - time.Sleep(1 * time.Second) - _ = peerConn.Close() - }() - } - - // Cancel any ongoing keyboard macro when session changes - cancelKeyboardMacro() - - currentSession = session - c.JSON(http.StatusOK, gin.H{"sd": sd}) -} - var ( pingMessage = []byte("ping") pongMessage = []byte("pong") @@ -244,7 +205,15 @@ var ( func handleLocalWebRTCSignal(c *gin.Context) { // get the source from the request source := c.ClientIP() - connectionID := uuid.New().String() + + // Try to get existing session ID from cookie for session persistence + sessionID, _ := c.Cookie("sessionId") + if sessionID == "" { + sessionID = uuid.New().String() + // Set session ID cookie with same expiry as auth token (7 days) + c.SetCookie("sessionId", sessionID, 7*24*60*60, "/", "", false, true) + } + connectionID := sessionID scopedLogger := websocketLogger.With(). Str("component", "websocket"). @@ -276,7 +245,17 @@ func handleLocalWebRTCSignal(c *gin.Context) { // Now use conn for websocket operations defer wsCon.Close(websocket.StatusNormalClosure, "") - err = wsjson.Write(context.Background(), wsCon, gin.H{"type": "device-metadata", "data": gin.H{"deviceVersion": builtAppVersion}}) + // Include session settings in device metadata so client knows requirements upfront + sessionSettingsData := gin.H{ + "deviceVersion": builtAppVersion, + } + if currentSessionSettings != nil { + sessionSettingsData["sessionSettings"] = gin.H{ + "requireNickname": currentSessionSettings.RequireNickname, + "requireApproval": currentSessionSettings.RequireApproval, + } + } + err = wsjson.Write(context.Background(), wsCon, gin.H{"type": "device-metadata", "data": sessionSettingsData}) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -380,6 +359,13 @@ func handleWebRTCSignalWsMessages( typ, msg, err := wsCon.Read(runCtx) if err != nil { l.Warn().Str("error", err.Error()).Msg("websocket read error") + // Clean up session when websocket closes + if session := sessionManager.GetSession(connectionID); session != nil && session.peerConnection != nil { + l.Info(). + Str("sessionID", session.ID). + Msg("Closing peer connection due to websocket error") + _ = session.peerConnection.Close() + } return err } if typ != websocket.MessageText { @@ -412,14 +398,17 @@ func handleWebRTCSignalWsMessages( continue } + l.Info().Str("type", message.Type).Str("dataLen", fmt.Sprintf("%d", len(message.Data))).Msg("received WebSocket message") + if message.Type == "offer" { - l.Info().Msg("new session request received") + l.Info().Str("dataRaw", string(message.Data)).Msg("new session request received with raw data") var req WebRTCSessionRequest err = json.Unmarshal(message.Data, &req) if err != nil { - l.Warn().Str("error", err.Error()).Msg("unable to parse session request data") + l.Warn().Str("error", err.Error()).Str("dataRaw", string(message.Data)).Msg("unable to parse session request data") continue } + l.Info().Str("sd", req.Sd[:50]).Msg("parsed session request") if req.OidcGoogle != "" { l.Info().Str("oidcGoogle", req.OidcGoogle).Msg("new session request with OIDC Google") @@ -427,7 +416,7 @@ func handleWebRTCSignalWsMessages( metricConnectionSessionRequestCount.WithLabelValues(sourceType, source).Inc() metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).SetToCurrentTime() - err = handleSessionRequest(runCtx, wsCon, req, isCloudConnection, source, &l) + err = handleSessionRequest(runCtx, wsCon, req, isCloudConnection, source, connectionID, &l) if err != nil { l.Warn().Str("error", err.Error()).Msg("error starting new session") continue @@ -449,14 +438,16 @@ func handleWebRTCSignalWsMessages( l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("unmarshalled incoming ICE candidate") - if currentSession == nil { - l.Warn().Msg("no current session, skipping incoming ICE candidate") + // Find the session this ICE candidate belongs to using the connectionID + session := sessionManager.GetSession(connectionID) + if session == nil { + l.Warn().Str("connectionID", connectionID).Msg("no session found for connection ID, skipping incoming ICE candidate") continue } - l.Info().Str("data", fmt.Sprintf("%v", candidate)).Msg("adding incoming ICE candidate to current session") - if err = currentSession.peerConnection.AddICECandidate(candidate); err != nil { - l.Warn().Str("error", err.Error()).Msg("failed to add incoming ICE candidate to our peer connection") + l.Info().Str("sessionID", session.ID).Str("data", fmt.Sprintf("%v", candidate)).Msg("adding incoming ICE candidate to correct session") + if err = session.peerConnection.AddICECandidate(candidate); err != nil { + l.Warn().Str("error", err.Error()).Str("sessionID", session.ID).Msg("failed to add incoming ICE candidate to peer connection") } } } @@ -481,7 +472,16 @@ func handleLogin(c *gin.Context) { return } - config.LocalAuthToken = uuid.New().String() + // Don't generate a new token - use the existing one + // This ensures all sessions can share the same auth token + if config.LocalAuthToken == "" { + // Only generate if we don't have one (shouldn't happen in normal operation) + config.LocalAuthToken = uuid.New().String() + if err := SaveConfig(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"}) + return + } + } // Set the cookie c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true) @@ -490,14 +490,30 @@ func handleLogin(c *gin.Context) { } func handleLogout(c *gin.Context) { - config.LocalAuthToken = "" - if err := SaveConfig(); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"}) - return + // Get session ID from cookie before clearing + sessionID, _ := c.Cookie("sessionId") + + // Close the WebRTC session immediately for intentional logout + if sessionID != "" { + if session := sessionManager.GetSession(sessionID); session != nil { + websocketLogger.Info(). + Str("sessionID", sessionID). + Msg("Closing session due to intentional logout - no grace period") + + // Close peer connection (will trigger cleanupSession) + if session.peerConnection != nil { + _ = session.peerConnection.Close() + } + + // Clear grace period for intentional logout - observer should be promoted immediately + sessionManager.ClearGracePeriod(sessionID) + } } - // Clear the auth cookie + // Clear the cookies for this session, don't invalidate the token + // The token should remain valid for other sessions c.SetCookie("authToken", "", -1, "/", "", false, true) + c.SetCookie("sessionId", "", -1, "/", "", false, true) c.JSON(http.StatusOK, gin.H{"message": "Logout successful"}) } @@ -519,6 +535,38 @@ func protectedMiddleware() gin.HandlerFunc { } } +// requirePermissionMiddleware creates a middleware that enforces specific permissions +func requirePermissionMiddleware(permission Permission) gin.HandlerFunc { + return func(c *gin.Context) { + // Get session ID from cookie + sessionID, err := c.Cookie("sessionId") + if err != nil || sessionID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "No session ID found"}) + c.Abort() + return + } + + // Get session from manager + session := sessionManager.GetSession(sessionID) + if session == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Session not found"}) + c.Abort() + return + } + + // Check permission + if !session.HasPermission(permission) { + c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("Permission denied: %s required", permission)}) + c.Abort() + return + } + + // Store session in context for use by handlers + c.Set("session", session) + c.Next() + } +} + func sendErrorJsonThenAbort(c *gin.Context, status int, message string) { c.JSON(status, gin.H{"error": message}) c.Abort() @@ -591,7 +639,7 @@ func RunWebServer() { logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server") if err := r.Run(bindAddress); err != nil { - panic(err) + logger.Fatal().Err(err).Msg("failed to start web server") } } diff --git a/web_tls.go b/web_tls.go index 41f532ea9..5d04b031b 100644 --- a/web_tls.go +++ b/web_tls.go @@ -184,7 +184,7 @@ func runWebSecureServer() { err := server.ListenAndServeTLS("", "") if !errors.Is(err, http.ErrServerClosed) { - panic(err) + websecureLogger.Fatal().Err(err).Msg("failed to start websecure server") } } diff --git a/webrtc.go b/webrtc.go index 37488f778..e6eec5816 100644 --- a/webrtc.go +++ b/webrtc.go @@ -19,13 +19,39 @@ import ( "github.com/rs/zerolog" ) +// Predefined browser string constants for memory efficiency +var ( + BrowserChrome = "chrome" + BrowserFirefox = "firefox" + BrowserSafari = "safari" + BrowserEdge = "edge" + BrowserOpera = "opera" + BrowserUnknown = "user" +) + type Session struct { + ID string + Mode SessionMode + Source string + Identity string + Nickname string + Browser *string // Pointer to predefined browser string constant for memory efficiency + CreatedAt time.Time + LastActive time.Time + LastBroadcast time.Time // Per-session broadcast throttle + + // RPC rate limiting (DoS protection) + rpcRateLimitMu sync.Mutex // Protects rate limit fields + rpcRateLimit int // Count of RPCs in current window + rpcRateLimitWin time.Time // Start of current rate limit window + peerConnection *webrtc.PeerConnection VideoTrack *webrtc.TrackLocalStaticSample ControlChannel *webrtc.DataChannel RPCChannel *webrtc.DataChannel HidChannel *webrtc.DataChannel shouldUmountVirtualMedia bool + flushCandidates func() // Callback to flush buffered ICE candidates rpcQueue chan webrtc.DataChannelMessage @@ -52,19 +78,35 @@ func incrActiveSessions() int { return actionSessions } -func decrActiveSessions() int { +func getActiveSessions() int { activeSessionsMutex.Lock() defer activeSessionsMutex.Unlock() - actionSessions-- return actionSessions } -func getActiveSessions() int { - activeSessionsMutex.Lock() - defer activeSessionsMutex.Unlock() +// 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 + rateLimitWindow = time.Second + ) + + s.rpcRateLimitMu.Lock() + defer s.rpcRateLimitMu.Unlock() + + now := time.Now() + // Reset window if it has expired + if now.Sub(s.rpcRateLimitWin) > rateLimitWindow { + s.rpcRateLimit = 0 + s.rpcRateLimitWin = now + } - return actionSessions + s.rpcRateLimit++ + if s.rpcRateLimit > maxRPCPerSecond { + return false // Rate limit exceeded + } + return true // Within limits } func (s *Session) resetKeepAliveTime() { @@ -83,6 +125,7 @@ type SessionConfig struct { ICEServers []string LocalIP string IsCloud bool + UserAgent string // User agent for browser detection and nickname generation ws *websocket.Conn Logger *zerolog.Logger } @@ -134,7 +177,14 @@ func (s *Session) initQueues() { func (s *Session) handleQueues(index int) { for msg := range s.hidQueue[index] { - onHidMessage(msg, s) + // Get current session from manager to ensure we have the latest state + currentSession := sessionManager.GetSession(s.ID) + if currentSession != nil { + onHidMessage(msg, currentSession) + } else { + // Session was removed, use original to avoid nil panic + onHidMessage(msg, s) + } } } @@ -246,7 +296,10 @@ func newSession(config SessionConfig) (*Session, error) { return nil, err } - session := &Session{peerConnection: peerConnection} + session := &Session{ + peerConnection: peerConnection, + Browser: extractBrowserFromUserAgent(config.UserAgent), + } session.rpcQueue = make(chan webrtc.DataChannelMessage, 256) session.initQueues() session.initKeysDownStateQueue() @@ -254,7 +307,16 @@ func newSession(config SessionConfig) (*Session, error) { go func() { for msg := range session.rpcQueue { // TODO: only use goroutine if the task is asynchronous - go onRPCMessage(msg, session) + go func(m webrtc.DataChannelMessage) { + // Get current session from manager to ensure we have the latest state + currentSession := sessionManager.GetSession(session.ID) + if currentSession != nil { + onRPCMessage(m, currentSession) + } else { + // Session was removed, use original to avoid nil panic + onRPCMessage(m, session) + } + }(msg) } }() @@ -290,9 +352,9 @@ func newSession(config SessionConfig) (*Session, error) { triggerVideoStateUpdate() triggerUSBStateUpdate() case "terminal": - handleTerminalChannel(d) + handleTerminalChannel(d, session) case "serial": - handleSerialChannel(d) + handleSerialChannel(d, session) default: if strings.HasPrefix(d.Label(), uploadIdPrefix) { go handleUploadChannel(d) @@ -325,9 +387,23 @@ func newSession(config SessionConfig) (*Session, error) { }() var isConnected bool + // Buffer to hold ICE candidates until answer is sent + var candidateBuffer []webrtc.ICECandidateInit + var candidateBufferMutex sync.Mutex + var answerSent bool + peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) { scopedLogger.Info().Interface("candidate", candidate).Msg("WebRTC peerConnection has a new ICE candidate") if candidate != nil { + candidateBufferMutex.Lock() + if !answerSent { + // Buffer the candidate until answer is sent + candidateBuffer = append(candidateBuffer, candidate.ToJSON()) + candidateBufferMutex.Unlock() + return + } + candidateBufferMutex.Unlock() + err := wsjson.Write(context.Background(), config.ws, gin.H{"type": "new-ice-candidate", "data": candidate.ToJSON()}) if err != nil { scopedLogger.Warn().Err(err).Msg("failed to write new-ice-candidate to WebRTC signaling channel") @@ -335,57 +411,119 @@ func newSession(config SessionConfig) (*Session, error) { } }) - peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) { - scopedLogger.Info().Str("connectionState", connectionState.String()).Msg("ICE Connection State has changed") - if connectionState == webrtc.ICEConnectionStateConnected { - if !isConnected { - isConnected = true - onActiveSessionsChanged() - if incrActiveSessions() == 1 { - onFirstSessionConnected() - } + // Store the callback to flush buffered candidates + session.flushCandidates = func() { + candidateBufferMutex.Lock() + answerSent = true + // Send all buffered candidates + for _, candidate := range candidateBuffer { + err := wsjson.Write(context.Background(), config.ws, gin.H{"type": "new-ice-candidate", "data": candidate}) + if err != nil { + scopedLogger.Warn().Err(err).Msg("failed to write buffered new-ice-candidate to WebRTC signaling channel") } } - //state changes on closing browser tab disconnected->failed, we need to manually close it - if connectionState == webrtc.ICEConnectionStateFailed { - scopedLogger.Debug().Msg("ICE Connection State is failed, closing peerConnection") - _ = peerConnection.Close() + candidateBuffer = nil + candidateBufferMutex.Unlock() + } + + // Track cleanup state to prevent double cleanup + var cleanedUp bool + var cleanupMutex sync.Mutex + + cleanupSession := func(reason string) { + cleanupMutex.Lock() + defer cleanupMutex.Unlock() + + if cleanedUp { + return + } + cleanedUp = true + + scopedLogger.Info(). + Str("sessionID", session.ID). + Str("reason", reason). + Msg("Cleaning up session") + + // Remove from session manager + sessionManager.RemoveSession(session.ID) + + // Cancel any ongoing keyboard macro if session has permission + if session.HasPermission(PermissionPaste) { + cancelKeyboardMacro() + } + + // Stop RPC processor + if session.rpcQueue != nil { + close(session.rpcQueue) + session.rpcQueue = nil } - if connectionState == webrtc.ICEConnectionStateClosed { - scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media") - if session == currentSession { - // Cancel any ongoing keyboard report multi when session closes - cancelKeyboardMacro() - currentSession = nil - } - // Stop RPC processor - if session.rpcQueue != nil { - close(session.rpcQueue) - session.rpcQueue = nil - } - // Stop HID RPC processor - for i := 0; i < len(session.hidQueue); i++ { + // Stop HID RPC processor + for i := 0; i < len(session.hidQueue); i++ { + if session.hidQueue[i] != nil { close(session.hidQueue[i]) session.hidQueue[i] = nil } + } + if session.keysDownStateQueue != nil { close(session.keysDownStateQueue) session.keysDownStateQueue = nil + } - if session.shouldUmountVirtualMedia { - if err := rpcUnmountImage(); err != nil { - scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close") - } + if session.shouldUmountVirtualMedia { + if err := rpcUnmountImage(); err != nil { + scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close") + } + } + + if isConnected { + isConnected = false + actionSessions-- + onActiveSessionsChanged() + if actionSessions == 0 { + onLastSessionDisconnected() } - if isConnected { - isConnected = false + } + } + + peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) { + scopedLogger.Info(). + Str("sessionID", session.ID). + Str("connectionState", connectionState.String()). + Msg("ICE Connection State has changed") + + if connectionState == webrtc.ICEConnectionStateConnected { + if !isConnected { + isConnected = true onActiveSessionsChanged() - if decrActiveSessions() == 0 { - onLastSessionDisconnected() + if incrActiveSessions() == 1 { + onFirstSessionConnected() } } } + + // Handle disconnection and failure states + if connectionState == webrtc.ICEConnectionStateDisconnected { + scopedLogger.Info(). + Str("sessionID", session.ID). + Msg("ICE Connection State is disconnected, connection may recover") + } + + if connectionState == webrtc.ICEConnectionStateFailed { + scopedLogger.Info(). + Str("sessionID", session.ID). + Msg("ICE Connection State is failed, closing peerConnection and cleaning up") + cleanupSession("ice-failed") + _ = peerConnection.Close() + } + + if connectionState == webrtc.ICEConnectionStateClosed { + scopedLogger.Info(). + Str("sessionID", session.ID). + Msg("ICE Connection State is closed, cleaning up") + cleanupSession("ice-closed") + } }) return session, nil }