diff --git a/cloud.go b/cloud.go index a851d51f..33fa7377 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,72 @@ 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") + } + 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 + } - // Cancel any ongoing keyboard macro when session changes - cancelKeyboardMacro() + if session.HasPermission(PermissionPaste) { + cancelKeyboardMacro() + } - currentSession = session - _ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd}) + requireNickname := false + requireApproval := false + if currentSessionSettings != nil { + requireNickname = currentSessionSettings.RequireNickname + requireApproval = currentSessionSettings.RequireApproval + } + + 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 c83ccfc7..bc8b463d 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"` } func (c *Config) GetDisplayRotation() uint16 { @@ -132,12 +143,25 @@ var defaultConfig = &Config{ 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 + SessionSettings: &SessionSettings{ + RequireApproval: false, + RequireNickname: false, + ReconnectGrace: 10, // 10 seconds default + PrivateKeystrokes: false, // By default, share keystrokes with observers + }, JigglerEnabled: false, // This is the "Standard" jiggler option in the UI JigglerConfig: &JigglerConfig{ diff --git a/datachannel_helpers.go b/datachannel_helpers.go new file mode 100644 index 00000000..dd1ad2da --- /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 00000000..b1d9f698 --- /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") +) \ No newline at end of file diff --git a/hidrpc.go b/hidrpc.go index ebe03daa..c4c8c8ae 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 00000000..6fd10e3b --- /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 minimal permissions + PermissionVideoView: true, + }, +} + +// 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 +} \ No newline at end of file diff --git a/internal/session/types.go b/internal/session/types.go new file mode 100644 index 00000000..50348d0e --- /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" +) \ No newline at end of file diff --git a/internal/usbgadget/hid_keyboard.go b/internal/usbgadget/hid_keyboard.go index 74cf76f9..d0b6eaa2 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 374844f1..1f366d19 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 b2463e0a..3323d0bb 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 0ff44a78..f7e4b0a4 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"` @@ -47,6 +56,7 @@ type DisplayRotationSettings struct { Rotation string `json:"rotation"` } + type BacklightSettings struct { MaxBrightness int `json:"max_brightness"` DimAfter int `json:"dim_after"` @@ -54,11 +64,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") @@ -96,7 +111,30 @@ func writeJSONRPCEvent(event string, params any, session *Session) { } } +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 +162,206 @@ 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 { + if targetSession := sessionManager.GetSession(sessionID); targetSession != nil && targetSession.Mode == SessionModePending { + targetSession.Mode = SessionModeObserver + sessionManager.broadcastSessionListUpdate() + result = map[string]interface{}{"status": "approved"} + } else { + handlerErr = errors.New("session not found or not pending") + } + } else { + handlerErr = errors.New("invalid sessionId parameter") + } + case "denyNewSession": + if err := RequirePermission(session, PermissionSessionApprove); err != nil { + handlerErr = err + } else if sessionID, ok := request.Params["sessionId"].(string); ok { + if targetSession := sessionManager.GetSession(sessionID); targetSession != nil && targetSession.Mode == SessionModePending { + writeJSONRPCEvent("sessionAccessDenied", map[string]interface{}{ + "message": "Access denied by primary session", + }, targetSession) + sessionManager.RemoveSession(sessionID) + result = map[string]interface{}{"status": "denied"} + } else { + handlerErr = errors.New("session not found or not pending") + } + } else { + handlerErr = errors.New("invalid sessionId parameter") + } + 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) { + 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 + } + + // 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 +377,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 +1307,93 @@ 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 +} + +func (s *Session) rpcApprovePrimaryRequest(requesterID string) error { + if s == nil || s.ID == "" { + return errors.New("invalid session") + } + return sessionManager.ApprovePrimaryRequest(s.ID, requesterID) +} + +func (s *Session) rpcDenyPrimaryRequest(requesterID string) error { + if s == nil || s.ID == "" { + return errors.New("invalid session") + } + return sessionManager.DenyPrimaryRequest(s.ID, requesterID) +} + var ( keyboardMacroCancel context.CancelFunc keyboardMacroLock sync.Mutex @@ -1119,8 +1429,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 +1439,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 @@ -1267,4 +1578,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 e9931d46..dec37695 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,18 @@ 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, + } + SaveConfig() + } + currentSessionSettings = config.SessionSettings + var cancel context.CancelFunc appCtx, cancel = context.WithCancel(context.Background()) defer cancel() @@ -91,7 +103,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 e8eea745..e74335e9 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 b808d6fe..ff5d1de1 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 bf0828dc..e1520cce 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 5439d135..c0702eae 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 00000000..87ddf7d7 --- /dev/null +++ b/session_manager.go @@ -0,0 +1,1633 @@ +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 && currentSessionSettings.PrimaryTimeout > 0 { + primaryTimeout = time.Duration(currentSessionSettings.PrimaryTimeout) * time.Second + } + + 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 { + 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 if this session ID is within grace period for reconnection + wasWithinGracePeriod := false + wasPreviouslyPrimary := false + if graceTime, exists := sm.reconnectGrace[session.ID]; exists { + if time.Now().Before(graceTime) { + wasWithinGracePeriod = true + // Check if this was specifically the primary + wasPreviouslyPrimary = (sm.lastPrimaryID == session.ID) + } + // Clean up grace period entry + delete(sm.reconnectGrace, session.ID) + } + + // 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)") + } + + // CRITICAL: Close old connection to prevent multiple active connections for same session ID + if existing.peerConnection != nil { + sm.logger.Info(). + Str("sessionID", session.ID). + Msg("Closing old peer connection for session reconnection") + existing.peerConnection.Close() + } + + // 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 { + // Check if this session is still the reserved primary AND not blacklisted + isBlacklisted := sm.isSessionBlacklisted(session.ID) + if sm.lastPrimaryID == session.ID && !isBlacklisted { + // This is the rightful primary reconnecting within grace period + sm.primarySessionID = session.ID + sm.lastPrimaryID = "" // Clear since primary successfully reconnected + delete(sm.reconnectGrace, session.ID) // Clear grace period + sm.logger.Debug(). + Str("sessionID", session.ID). + Msg("Primary session successfully reconnected within grace period") + } else { + // This session was primary but grace period expired, another took over, or is blacklisted + session.Mode = SessionModeObserver + sm.logger.Debug(). + Str("sessionID", session.ID). + Str("currentPrimaryID", sm.primarySessionID). + Bool("isBlacklisted", isBlacklisted). + Msg("Former primary session reconnected but grace period expired, another took over, or session is blacklisted - demoting to observer") + } + } + + // NOTE: Skip validation during reconnection to preserve grace period + // validateSinglePrimary() would clear primary slot during reconnection window + + 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() + } + + // Clean up any grace period entries for this session since it's reconnecting + if wasWithinGracePeriod { + delete(sm.reconnectGrace, session.ID) + delete(sm.reconnectInfo, session.ID) + sm.logger.Info(). + Str("sessionID", session.ID). + Msg("Session reconnected within grace period - cleaned up grace period entries") + } + + // Set nickname from client settings if provided + if clientSettings != nil && clientSettings.Nickname != "" { + session.Nickname = clientSettings.Nickname + } + + // Use global settings for requirements (not client-provided) + globalSettings := currentSessionSettings + + // Set mode based on current state and global settings + // ATOMIC CHECK AND ASSIGN: Check if there's currently no primary session + // and assign primary status atomically to prevent race conditions + primaryExists := sm.primarySessionID != "" && sm.sessions[sm.primarySessionID] != nil + + // Check if this session was recently demoted via transfer + isBlacklisted := sm.isSessionBlacklisted(session.ID) + + sm.logger.Debug(). + Str("newSessionID", session.ID). + Str("nickname", session.Nickname). + Str("currentPrimarySessionID", sm.primarySessionID). + Bool("primaryExists", primaryExists). + Int("totalSessions", len(sm.sessions)). + Bool("wasWithinGracePeriod", wasWithinGracePeriod). + Bool("wasPreviouslyPrimary", wasPreviouslyPrimary). + Bool("isBlacklisted", isBlacklisted). + Msg("AddSession state analysis") + + // Become primary only if: + // 1. Was previously primary (within grace) AND no current primary, OR + // 2. There's no primary at all AND not recently transferred away + // Never allow primary promotion if already restored within grace period + shouldBecomePrimary := !wasWithinGracePeriod && ((wasPreviouslyPrimary && !primaryExists) || (!primaryExists && !isBlacklisted)) + + if wasWithinGracePeriod { + sm.logger.Debug(). + Str("sessionID", session.ID). + Bool("wasPreviouslyPrimary", wasPreviouslyPrimary). + Bool("primaryExists", primaryExists). + Str("currentPrimarySessionID", sm.primarySessionID). + Msg("Session within grace period - skipping primary promotion logic") + } + + if shouldBecomePrimary { + // Double-check primary doesn't exist (race condition prevention) + if sm.primarySessionID == "" || sm.sessions[sm.primarySessionID] == nil { + // Since we now generate nicknames automatically when required, + // we can always promote to primary when no primary exists + session.Mode = SessionModePrimary + sm.primarySessionID = session.ID + sm.lastPrimaryID = "" // Clear since we have a new primary + + // Clear all existing grace periods when a new primary is established + // This prevents multiple sessions from fighting for primary status via grace period + if len(sm.reconnectGrace) > 0 || len(sm.reconnectInfo) > 0 { + sm.logger.Debug(). + Int("clearedGracePeriods", len(sm.reconnectGrace)). + Int("clearedReconnectInfo", len(sm.reconnectInfo)). + Str("newPrimarySessionID", session.ID). + Msg("Clearing all existing grace periods for new primary session in AddSession") + + // Clear all existing grace periods and reconnect info + for oldSessionID := range sm.reconnectGrace { + delete(sm.reconnectGrace, oldSessionID) + } + for oldSessionID := range sm.reconnectInfo { + delete(sm.reconnectInfo, oldSessionID) + } + } + + // Reset HID availability to force re-handshake for input functionality + session.hidRPCAvailable = false + } else { + // Someone else became primary in the meantime, become observer + session.Mode = SessionModeObserver + } + } else if globalSettings != nil && globalSettings.RequireApproval && primaryExists && !wasWithinGracePeriod { + // New session requires approval from primary (but only if there IS a primary to approve) + // Skip approval for sessions reconnecting within grace period + 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() + + // Add session to sessions map BEFORE primary checks + // This ensures that primary existence checks work correctly during restoration + sm.sessions[session.ID] = session + + // Ensure session has auto-generated nickname if needed + sm.ensureNickname(session) + + // Validate sessions but respect grace periods + sm.validateSinglePrimary() + + // 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) + + // Remove from queue if present + sm.removeFromQueue(sessionID) + + // Add a grace period for reconnection for all sessions + // Use configured grace period or default to 10 seconds + gracePeriod := 10 + if currentSessionSettings != nil && currentSessionSettings.ReconnectGrace > 0 { + gracePeriod = currentSessionSettings.ReconnectGrace + } + + // Limit grace period entries to prevent memory exhaustion (DoS protection) + const maxGraceEntries = 10 // Reduced from 20 to limit memory usage + for len(sm.reconnectGrace) >= maxGraceEntries { + // Find and remove the oldest grace period entry + var oldestID string + var oldestTime time.Time + for id, graceTime := range sm.reconnectGrace { + if oldestTime.IsZero() || graceTime.Before(oldestTime) { + oldestID = id + oldestTime = graceTime + } + } + if oldestID != "" { + delete(sm.reconnectGrace, oldestID) + delete(sm.reconnectInfo, oldestID) + } else { + break // Safety check to prevent infinite loop + } + } + + 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 { + sm.lastPrimaryID = sessionID // Remember this was the primary for grace period + sm.primarySessionID = "" // Clear primary slot so other sessions can be promoted + sm.logger.Info(). + Str("sessionID", sessionID). + Dur("gracePeriod", time.Duration(gracePeriod)*time.Second). + Msg("Primary session removed, grace period active - auto-promotion will occur after grace expires") + + // NOTE: Do NOT call validateSinglePrimary() here - let grace period expire naturally + // The cleanupInactiveSessions() function will handle promotion after grace period expires + } + + // 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 +} + +// 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() + + // 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") + } + + // 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 +} + +// 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, this is a critical bug - fix it + if len(primarySessions) > 1 { + sm.logger.Error(). + Int("primaryCount", len(primarySessions)). + Msg("CRITICAL BUG: Multiple primary sessions detected, fixing...") + + // Keep the first one as primary, demote the rest + for i, session := range primarySessions { + if i == 0 { + // Keep this as primary and update manager state + sm.primarySessionID = session.ID + sm.logger.Info(). + Str("keptPrimaryID", session.ID). + Msg("Kept session as primary") + } else { + // Demote all others + session.Mode = SessionModeObserver + sm.logger.Info(). + Str("demotedSessionID", session.ID). + Msg("Demoted duplicate primary session") + } + } + } + + // 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 + // This prevents instant promotion during primary session reconnection + if len(primarySessions) == 0 && sm.primarySessionID != "" { + // Check if the current primary is in grace period waiting to reconnect + if sm.lastPrimaryID == sm.primarySessionID { + if graceTime, exists := sm.reconnectGrace[sm.primarySessionID]; exists { + if time.Now().Before(graceTime) { + // Primary is in grace period, DON'T clear the slot yet + sm.logger.Info(). + Str("gracePrimaryID", sm.primarySessionID). + Msg("Primary slot preserved - session in grace period") + return // Exit validation, keep primary slot reserved + } + } + } + + // No grace period, safe to clear orphaned primary + sm.logger.Warn(). + Str("orphanedPrimaryID", sm.primarySessionID). + Msg("Cleared orphaned primary ID") + sm.primarySessionID = "" + } + + sm.logger.Debug(). + Int("primarySessionCount", len(primarySessions)). + Str("primarySessionID", sm.primarySessionID). + Int("totalSessions", len(sm.sessions)). + Msg("validateSinglePrimary state check") + + // Auto-promote if there are NO primary sessions at all + if len(primarySessions) == 0 && sm.primarySessionID == "" && len(sm.sessions) > 0 { + // 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") + + // 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") + } + } else { + sm.logger.Debug(). + Int("primarySessions", len(primarySessions)). + Str("primarySessionID", sm.primarySessionID). + Bool("hasSessions", len(sm.sessions) > 0). + Msg("Emergency auto-promotion conditions not met") + } +} + +// 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 + } + + 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 + 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") + } + + // Promote target session + toSession.Mode = SessionModePrimary + toSession.hidRPCAvailable = false // Force re-handshake + sm.primarySessionID = toSessionID + sm.lastPrimaryID = toSessionID // Set to new primary so grace period works on refresh + + // 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 + now := time.Now() + blacklistDuration := 60 * time.Second + blacklistedCount := 0 + + // First, clear any existing blacklist entries for the newly promoted session + cleanedBlacklist := make([]TransferBlacklistEntry, 0) + for _, entry := range sm.transferBlacklist { + if entry.SessionID != toSessionID { // Remove any old blacklist entries for the new primary + cleanedBlacklist = append(cleanedBlacklist, entry) + } + } + 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++ + } + } + + // Clear all grace periods to prevent conflicts + if len(sm.reconnectGrace) > 0 || len(sm.reconnectInfo) > 0 { + for oldSessionID := range sm.reconnectGrace { + delete(sm.reconnectGrace, oldSessionID) + } + for oldSessionID := range sm.reconnectInfo { + delete(sm.reconnectInfo, oldSessionID) + } + } + + sm.logger.Info(). + Str("fromSessionID", fromSessionID). + Str("toSessionID", toSessionID). + Str("transferType", transferType). + Str("context", context). + Int("blacklistedSessions", blacklistedCount). + Dur("blacklistDuration", blacklistDuration). + Msg("Primary role transferred with bidirectional protection") + + // Validate session consistency after role transfer + sm.validateSinglePrimary() + + // Handle WebRTC connection state for promoted sessions + // When a session changes from observer to primary, the existing WebRTC connection + // 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 + + 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 + } + } + + // 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 + for sessionID, graceTime := range sm.reconnectGrace { + if now.After(graceTime) { + delete(sm.reconnectGrace, sessionID) + + 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 + + // Rate limiting for emergency promotions + if now.Sub(sm.lastEmergencyPromotion) < 30*time.Second { + sm.logger.Warn(). + Str("expiredSessionID", sessionID). + Dur("timeSinceLastEmergency", now.Sub(sm.lastEmergencyPromotion)). + Msg("Emergency promotion rate limit exceeded - potential attack") + continue // Skip this grace period expiration + } + + // Limit consecutive emergency promotions + if sm.consecutiveEmergencyPromotions >= 3 { + sm.logger.Error(). + Str("expiredSessionID", sessionID). + 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 + } + } + + // 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 + + // Rate limiting for emergency promotions + 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 + } + } + + // Periodic validateSinglePrimary to catch deadlock states + validationCounter++ + if validationCounter >= 10 { // Every 10 seconds + validationCounter = 0 + sm.logger.Debug().Msg("Running periodic session validation to catch deadlock states") + sm.validateSinglePrimary() + } + + sm.mu.Unlock() + + // Broadcast outside of lock if needed + if needsBroadcast { + go sm.broadcastSessionListUpdate() + } + } + } +} + +// Global session manager instance +var sessionManager = NewSessionManager(websocketLogger) + +// Global session settings - references config.SessionSettings for persistence +var currentSessionSettings *SessionSettings \ No newline at end of file diff --git a/session_permissions.go b/session_permissions.go new file mode 100644 index 00000000..35dcd43d --- /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 +} \ No newline at end of file diff --git a/terminal.go b/terminal.go index e06e5cdc..ea13087c 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 00000000..bbd93b6e --- /dev/null +++ b/ui/src/api/sessionApi.ts @@ -0,0 +1,113 @@ +import { SessionInfo } from "@/stores/sessionStore"; + +export const sessionApi = { + getSessions: async (sendFn: Function): Promise => { + return new Promise((resolve, reject) => { + sendFn("getSessions", {}, (response: any) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(response.result || []); + } + }); + }); + }, + + getSessionInfo: async (sendFn: Function, sessionId: string): Promise => { + return new Promise((resolve, reject) => { + sendFn("getSessionInfo", { sessionId }, (response: any) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(response.result); + } + }); + }); + }, + + requestPrimary: async (sendFn: Function, sessionId: string): Promise<{ status: string; mode?: string; message?: string }> => { + return new Promise((resolve, reject) => { + sendFn("requestPrimary", { sessionId }, (response: any) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(response.result); + } + }); + }); + }, + + releasePrimary: async (sendFn: Function, sessionId: string): Promise => { + return new Promise((resolve, reject) => { + sendFn("releasePrimary", { sessionId }, (response: any) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(); + } + }); + }); + }, + + transferPrimary: async ( + sendFn: Function, + fromId: string, + toId: string + ): Promise => { + return new Promise((resolve, reject) => { + sendFn("transferPrimary", { fromId, toId }, (response: any) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(); + } + }); + }); + }, + + updateNickname: async ( + sendFn: Function, + sessionId: string, + nickname: string + ): Promise => { + return new Promise((resolve, reject) => { + sendFn("updateSessionNickname", { sessionId, nickname }, (response: any) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(); + } + }); + }); + }, + + approveNewSession: async ( + sendFn: Function, + sessionId: string + ): Promise => { + return new Promise((resolve, reject) => { + sendFn("approveNewSession", { sessionId }, (response: any) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(); + } + }); + }); + }, + + denyNewSession: async ( + sendFn: Function, + sessionId: string + ): Promise => { + return new Promise((resolve, reject) => { + sendFn("denyNewSession", { sessionId }, (response: any) => { + 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 00000000..7d0b6e46 --- /dev/null +++ b/ui/src/components/AccessDeniedOverlay.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router"; +import { XCircleIcon } from "@heroicons/react/24/outline"; +import { Button } from "./Button"; +import { DEVICE_API, CLOUD_API } from "@/ui.config"; +import { isOnDevice } from "@/main"; +import { useUserStore } from "@/hooks/stores"; +import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore"; +import api from "@/api"; + +interface AccessDeniedOverlayProps { + show: boolean; + message?: string; + onRetry?: () => void; +} + +export default function AccessDeniedOverlay({ + show, + message = "Your session access was denied", + onRetry +}: AccessDeniedOverlayProps) { + const navigate = useNavigate(); + const setUser = useUserStore(state => state.setUser); + const { clearSession } = useSessionStore(); + const { clearNickname } = useSharedSessionStore(); + const [countdown, setCountdown] = useState(10); + + const handleLogout = 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("/"); + }; + + useEffect(() => { + if (!show) return; + + const timer = setInterval(() => { + setCountdown(prev => { + if (prev <= 1) { + clearInterval(timer); + // Auto-redirect with proper logout + handleLogout(); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, [show]); + + if (!show) 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. +

+
+ +

+ Redirecting in {countdown} seconds... +

+ +
+ {onRetry && ( +
+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index 4f79d7ed..c5d5e590 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 { @@ -18,7 +18,11 @@ 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 { useRTCStore } from "@/hooks/stores"; +import { usePermissions, Permission } from "@/hooks/usePermissions"; 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 (error) { + // 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?.readyState]); // 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); } } @@ -69,179 +103,239 @@ export default function Actionbar({ onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")} /> )} - - + {hasPermission(Permission.PASTE) && ( + + + + )} + + +
+
+ + { + 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 00000000..6d96ab76 --- /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 00000000..2c29e841 --- /dev/null +++ b/ui/src/components/SessionControlPanel.tsx @@ -0,0 +1,139 @@ +import { useSessionStore } from "@/stores/sessionStore"; +import { sessionApi } from "@/api/sessionApi"; +import { Button } from "@/components/Button"; +import { + LockClosedIcon, + LockOpenIcon, + ClockIcon +} from "@heroicons/react/16/solid"; +import clsx from "clsx"; +import { usePermissions, Permission } from "@/hooks/usePermissions"; + +interface SessionControlPanelProps { + sendFn: Function; + 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: any) { + setSessionError(error.message); + 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: any) { + setSessionError(error.message); + 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 00000000..bba58779 --- /dev/null +++ b/ui/src/components/SessionsList.tsx @@ -0,0 +1,149 @@ +import { PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import clsx from "clsx"; +import { formatters } from "@/utils"; +import { usePermissions, Permission } from "@/hooks/usePermissions"; + +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 00000000..1d1f1b6c --- /dev/null +++ b/ui/src/components/UnifiedSessionRequestDialog.tsx @@ -0,0 +1,243 @@ +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; + onClose: () => void; +} + +export default function UnifiedSessionRequestDialog({ + request, + onApprove, + onDeny, + 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); + } + }, [request?.id, request?.type]); // Only depend on stable properties + + // 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 +

+
+ )} + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 64452bf8..1187f44e 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -14,6 +14,8 @@ import { useSettingsStore, useVideoStore, } from "@/hooks/stores"; +import { useSessionStore } from "@/stores/sessionStore"; +import { usePermissions, Permission } from "@/hooks/usePermissions"; import useMouse from "@/hooks/useMouse"; import { @@ -35,6 +37,8 @@ export default function WebRTCVideo() { // Store hooks const settings = useSettingsStore(); + const { currentMode } = useSessionStore(); + const { hasPermission } = usePermissions(); const { handleKeyPress, resetKeyboardState } = useKeyboard(); const { getRelMouseMoveHandler, @@ -214,29 +218,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], - ); + }); + return (e: MouseEvent) => { + // Only allow input if user has mouse permission + if (!hasPermission(Permission.MOUSE_INPUT)) return; + handler(e); + }; + }, [currentMode, getAbsMouseMoveHandler, videoClientWidth, videoClientHeight, videoWidth, videoHeight]); - const relMouseMoveHandler = useMemo( - () => getRelMouseMoveHandler(), - [getRelMouseMoveHandler], - ); + 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); + }; + }, [currentMode, getRelMouseMoveHandler]); - const mouseWheelHandler = useMemo( - () => getMouseWheelHandler(), - [getMouseWheelHandler], - ); + 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); + }; + }, [currentMode, getMouseWheelHandler]); 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 +274,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 +284,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], + [currentMode, handleKeyPress, isKeyboardLockActive], ); 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 +308,9 @@ export default function WebRTCVideo() { return; } - console.debug(`Key up: ${hidKey}`); handleKeyPress(hidKey, false); }, - [handleKeyPress], + [currentMode, handleKeyPress], ); const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { @@ -297,7 +321,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 +579,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 00000000..a078b429 --- /dev/null +++ b/ui/src/components/popovers/SessionPopover.tsx @@ -0,0 +1,207 @@ +import { useState, useEffect } from "react"; +import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore"; +import { useJsonRpc } from "@/hooks/useJsonRpc"; +import { + UserGroupIcon, + ArrowPathIcon, + PencilIcon, +} from "@heroicons/react/20/solid"; +import clsx from "clsx"; +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 = (method: string, params: any, callback?: (response: any) => void) => { + send(method, params, (response) => { + if (callback) callback(response); + }); + }; + + 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)); + } + }, []); + + 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: any) => { + if (response.error) { + console.error("Failed to approve session:", response.error); + } else { + handleRefresh(); + } + }); + }} + onDeny={(sessionId) => { + sendRpc("denyNewSession", { sessionId }, (response: any) => { + 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/hooks/stores.ts b/ui/src/hooks/stores.ts index bfbbb26e..180fb985 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -329,6 +329,12 @@ export interface SettingsState { developerMode: boolean; setDeveloperMode: (enabled: boolean) => void; + requireSessionNickname: boolean; + setRequireSessionNickname: (required: boolean) => void; + + requireSessionApproval: boolean; + setRequireSessionApproval: (required: boolean) => void; + displayRotation: string; setDisplayRotation: (rotation: string) => void; @@ -369,6 +375,12 @@ 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 }), + displayRotation: "270", setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }), diff --git a/ui/src/hooks/useJsonRpc.ts b/ui/src/hooks/useJsonRpc.ts index 5c52d59c..91965c74 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 00000000..ca4fb5ff --- /dev/null +++ b/ui/src/hooks/usePermissions.ts @@ -0,0 +1,164 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useJsonRpc } from "@/hooks/useJsonRpc"; +import { useSessionStore } from "@/stores/sessionStore"; +import { useRTCStore } from "@/hooks/stores"; + +// Permission types matching backend +export enum Permission { + // Video/Display permissions + VIDEO_VIEW = "video.view", + + // Input permissions + KEYBOARD_INPUT = "keyboard.input", + MOUSE_INPUT = "mouse.input", + PASTE = "clipboard.paste", + + // Session management permissions + SESSION_TRANSFER = "session.transfer", + SESSION_APPROVE = "session.approve", + SESSION_KICK = "session.kick", + SESSION_REQUEST_PRIMARY = "session.request_primary", + SESSION_RELEASE_PRIMARY = "session.release_primary", + SESSION_MANAGE = "session.manage", + + // Mount/Media permissions + MOUNT_MEDIA = "mount.media", + UNMOUNT_MEDIA = "mount.unmedia", + MOUNT_LIST = "mount.list", + + // Extension permissions + EXTENSION_MANAGE = "extension.manage", + EXTENSION_ATX = "extension.atx", + EXTENSION_DC = "extension.dc", + EXTENSION_SERIAL = "extension.serial", + EXTENSION_WOL = "extension.wol", + + // Settings permissions + SETTINGS_READ = "settings.read", + SETTINGS_WRITE = "settings.write", + SETTINGS_ACCESS = "settings.access", + + // System permissions + SYSTEM_REBOOT = "system.reboot", + SYSTEM_UPDATE = "system.update", + SYSTEM_NETWORK = "system.network", + + // Power/USB control permissions + POWER_CONTROL = "power.control", + USB_CONTROL = "usb.control", + + // Terminal/Serial permissions + TERMINAL_ACCESS = "terminal.access", + SERIAL_ACCESS = "serial.access", +} + +interface PermissionsResponse { + mode: string; + permissions: Record; +} + +export function usePermissions() { + const { currentMode } = useSessionStore(); + const { setRpcHidProtocolVersion, rpcHidChannel } = useRTCStore(); + const [permissions, setPermissions] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + const previousCanControl = useRef(false); + + // Function to poll permissions + const pollPermissions = useCallback((send: any) => { + if (!send) return; + + setIsLoading(true); + send("getPermissions", {}, (response: any) => { + if (!response.error && response.result) { + const result = response.result as PermissionsResponse; + setPermissions(result.permissions); + } + setIsLoading(false); + }); + }, []); + + // Handle connectionModeChanged events that require WebRTC reconnection + const handleRpcRequest = useCallback((request: any) => { + if (request.method === "connectionModeChanged") { + console.info("Connection mode changed, WebRTC reconnection required", request.params); + + // For session promotion that requires reconnection, refresh the page + // This ensures WebRTC connection is re-established with proper mode + if (request.params?.action === "reconnect_required" && request.params?.reason === "session_promotion") { + console.info("Session promoted, refreshing page to re-establish WebRTC connection"); + // Small delay to ensure all state updates are processed + setTimeout(() => { + window.location.reload(); + }, 500); + } + } + }, []); + + const { send } = useJsonRpc(handleRpcRequest); + + useEffect(() => { + pollPermissions(send); + }, [send, currentMode, pollPermissions]); + + // Monitor permission changes and re-initialize HID when gaining control + useEffect(() => { + const currentCanControl = hasPermission(Permission.KEYBOARD_INPUT) && hasPermission(Permission.MOUSE_INPUT); + const hadControl = previousCanControl.current; + + // If we just gained control permissions, re-initialize HID + if (currentCanControl && !hadControl && rpcHidChannel?.readyState === "open") { + console.info("Gained control permissions, re-initializing HID"); + + // Reset protocol version to force re-handshake + setRpcHidProtocolVersion(null); + + // Import handshake functionality dynamically + import("./hidRpc").then(({ HID_RPC_VERSION, HandshakeMessage }) => { + // Send handshake after a small delay + setTimeout(() => { + if (rpcHidChannel?.readyState === "open") { + const handshakeMessage = new HandshakeMessage(HID_RPC_VERSION); + try { + const data = handshakeMessage.marshal(); + rpcHidChannel.send(data as unknown as ArrayBuffer); + console.info("Sent HID handshake after permission change"); + } catch (e) { + console.error("Failed to send HID handshake", e); + } + } + }, 100); + }); + } + + previousCanControl.current = currentCanControl; + }, [permissions, rpcHidChannel, setRpcHidProtocolVersion]); + + const hasPermission = (permission: Permission): boolean => { + return permissions[permission] === true; + }; + + const hasAnyPermission = (...perms: Permission[]): boolean => { + return perms.some(perm => hasPermission(perm)); + }; + + const hasAllPermissions = (...perms: Permission[]): boolean => { + return perms.every(perm => hasPermission(perm)); + }; + + // Session mode helpers + const isPrimary = () => currentMode === "primary"; + const isObserver = () => currentMode === "observer"; + const isPending = () => currentMode === "pending"; + + return { + permissions, + isLoading, + hasPermission, + hasAnyPermission, + hasAllPermissions, + isPrimary, + isObserver, + isPending, + }; +} \ No newline at end of file diff --git a/ui/src/hooks/useSessionEvents.ts b/ui/src/hooks/useSessionEvents.ts new file mode 100644 index 00000000..393ff1c8 --- /dev/null +++ b/ui/src/hooks/useSessionEvents.ts @@ -0,0 +1,153 @@ +import { useEffect, useRef } from "react"; +import { useSessionStore } from "@/stores/sessionStore"; +import { useRTCStore } from "@/hooks/stores"; +import { sessionApi } from "@/api/sessionApi"; +import { notify } from "@/notifications"; + +interface SessionEventData { + sessions: any[]; + yourMode: string; +} + +interface ModeChangedData { + mode: string; +} + +export function useSessionEvents(sendFn: Function | null) { + const { + currentMode, + setSessions, + updateSessionMode, + setSessionError + } = useSessionStore(); + + const sendFnRef = useRef(sendFn); + sendFnRef.current = sendFn; + + // Handle session-related RPC events + const handleSessionEvent = (method: string, params: any) => { + switch (method) { + case "sessionsUpdated": + handleSessionsUpdated(params as SessionEventData); + break; + case "modeChanged": + handleModeChanged(params as ModeChangedData); + 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 any); + } + }; + + // 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 any); + + // Clear requesting state when mode changes from queued + if (previousMode === "queued" && data.mode !== "queued") { + const { setRequestingPrimary } = useSessionStore.getState(); + setRequestingPrimary(false); + } + + // 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 handleHidReadyForPrimary = () => { + // Backend signals that HID system is ready for primary session re-initialization + const { rpcHidChannel } = useRTCStore.getState(); + if (rpcHidChannel?.readyState === "open") { + // Trigger HID re-handshake + rpcHidChannel.dispatchEvent(new Event("open")); + } + }; + + const handleOtherSessionConnected = () => { + // Another session is trying to connect + notify.warning("Another session is connecting", { + duration: 5000 + }); + }; + + // Fetch initial sessions when component mounts + useEffect(() => { + if (!sendFnRef.current) return; + + 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]); + + // Set up periodic session refresh + useEffect(() => { + if (!sendFnRef.current) return; + + const intervalId = setInterval(async () => { + if (!sendFnRef.current) return; + + try { + const sessions = await sessionApi.getSessions(sendFnRef.current); + setSessions(sessions); + } catch (error) { + // 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 00000000..42078412 --- /dev/null +++ b/ui/src/hooks/useSessionManagement.ts @@ -0,0 +1,177 @@ +import { useEffect, useCallback, useState } from "react"; +import { useSessionStore } from "@/stores/sessionStore"; +import { useSessionEvents } from "@/hooks/useSessionEvents"; +import { useSettingsStore } from "@/hooks/stores"; +import { usePermissions, Permission } from "@/hooks/usePermissions"; + +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: Function | null) { + const { + setCurrentSession, + clearSession + } = useSessionStore(); + + const { hasPermission } = usePermissions(); + + const { requireSessionApproval } = useSettingsStore(); + const { handleSessionEvent } = useSessionEvents(sendFn); + const [primaryControlRequest, setPrimaryControlRequest] = useState(null); + const [newSessionRequest, setNewSessionRequest] = useState(null); + + // Handle session info from WebRTC answer + const handleSessionResponse = useCallback((response: SessionResponse) => { + if (response.sessionId && response.mode) { + setCurrentSession(response.sessionId, response.mode as any); + } + }, [setCurrentSession]); + + // Handle approval of primary control request + const handleApprovePrimaryRequest = useCallback(async (requestId: string) => { + if (!sendFn) return; + + return new Promise((resolve, reject) => { + sendFn("approvePrimaryRequest", { requesterID: requestId }, (response: any) => { + 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]); + + // Handle denial of primary control request + const handleDenyPrimaryRequest = useCallback(async (requestId: string) => { + if (!sendFn) return; + + return new Promise((resolve, reject) => { + sendFn("denyPrimaryRequest", { requesterID: requestId }, (response: any) => { + 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]); + + // Handle approval of new session + const handleApproveNewSession = useCallback(async (sessionId: string) => { + if (!sendFn) return; + + return new Promise((resolve, reject) => { + sendFn("approveNewSession", { sessionId }, (response: any) => { + 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]); + + // Handle denial of new session + const handleDenyNewSession = useCallback(async (sessionId: string) => { + if (!sendFn) return; + + return new Promise((resolve, reject) => { + sendFn("denyNewSession", { sessionId }, (response: any) => { + 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]); + + // Handle RPC events + const handleRpcEvent = useCallback((method: string, params: any) => { + // Pass session events to the session event handler + if (method === "sessionsUpdated" || + method === "modeChanged" || + method === "otherSessionConnected") { + handleSessionEvent(method, params); + } + + // Handle new session approval request (only if approval is required and user has permission) + if (method === "newSessionPending" && requireSessionApproval && hasPermission(Permission.SESSION_APPROVE)) { + setNewSessionRequest(params); + } + + // Handle primary control request + if (method === "primaryControlRequested") { + setPrimaryControlRequest(params); + } + + // Handle approval/denial responses + if (method === "primaryControlApproved") { + // Clear requesting state in store + const { setRequestingPrimary } = useSessionStore.getState(); + setRequestingPrimary(false); + } + + if (method === "primaryControlDenied") { + // Clear requesting state and show error + const { setRequestingPrimary, setSessionError } = useSessionStore.getState(); + setRequestingPrimary(false); + setSessionError("Your primary control request was denied"); + } + + // Handle session access denial (when your new session is denied) + if (method === "sessionAccessDenied") { + const { clearSession, setSessionError } = useSessionStore.getState(); + setSessionError(params.message || "Session access was denied by the primary session"); + // Clear session data as we're being disconnected + setTimeout(() => { + clearSession(); + }, 3000); // Give user time to see the error + } + }, [handleSessionEvent]); + + // Cleanup on unmount + 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 79ca6717..f05d92f7 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 5158d8d3..a10e63a3 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/routes/devices.$id.settings.access._index.tsx b/ui/src/routes/devices.$id.settings.access._index.tsx index f30bfef1..18a680a4 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 +60,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 00000000..fab886e1 --- /dev/null +++ b/ui/src/routes/devices.$id.settings.multi-session.tsx @@ -0,0 +1,262 @@ +import { useEffect, useState } from "react"; +import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc"; +import { usePermissions, Permission } from "@/hooks/usePermissions"; +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"; +import { + UserGroupIcon, +} from "@heroicons/react/16/solid"; + +export default function SessionsSettings() { + const { send } = useJsonRpc(); + const { hasPermission } = usePermissions(); + const canModifySettings = hasPermission(Permission.SETTINGS_WRITE); + + const { + requireSessionNickname, + setRequireSessionNickname, + requireSessionApproval, + setRequireSessionApproval + } = useSettingsStore(); + + const [reconnectGrace, setReconnectGrace] = useState(10); + const [primaryTimeout, setPrimaryTimeout] = useState(300); + const [privateKeystrokes, setPrivateKeystrokes] = useState(false); + + 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 + }; + 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); + } + } + }); + }, [send, setRequireSessionApproval, setRequireSessionNickname]); + + const updateSessionSettings = (updates: Partial<{ + requireApproval: boolean; + requireNickname: boolean; + reconnectGrace: number; + primaryTimeout: number; + privateKeystrokes: boolean; + }>) => { + 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, + ...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) || 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 = 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 338beb97..23bc4c2f 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -12,19 +12,33 @@ import { LuPalette, LuCommand, LuNetwork, + LuUsers, } from "react-icons/lu"; import { useResizeObserver } from "usehooks-ts"; +import { useNavigate } from "react-router"; import { cx } from "@/cva.config"; 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, Permission } from "@/hooks/usePermissions"; /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ export default function SettingsRoute() { 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("/devices/local", { 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')); @@ -50,6 +56,9 @@ import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider"; 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 +131,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 +150,20 @@ 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 { hasPermission } = usePermissions(); const [loadingMessage, setLoadingMessage] = useState("Connecting to device..."); const cleanupAndStopReconnecting = useCallback( function cleanupAndStopReconnecting() { - console.log("Closing peer connection"); setConnectionFailed(true); if (peerConnection) { @@ -186,7 +201,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 +218,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) { @@ -218,10 +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,6 @@ 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"); }, onMessage: message => { @@ -285,27 +290,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 +346,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 +389,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 +406,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 +426,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 +444,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 +454,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 +468,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 +479,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 +491,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 +517,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 +536,6 @@ export default function KvmIdRoute() { legacyHTTPSignaling(pc); } } else if (pc.iceGatheringState === "gathering") { - console.debug("ICE Gathering Started"); setLoadingMessage("Gathering ICE candidates..."); } }; @@ -480,6 +549,44 @@ export default function KvmIdRoute() { const rpcDataChannel = pc.createDataChannel("rpc"); rpcDataChannel.onopen = () => { setRpcDataChannel(rpcDataChannel); + + // Fetch global session settings + const fetchSettings = () => { + // Only fetch settings if user has permission to read settings + if (!hasPermission(Permission.SETTINGS_READ)) { + return; + } + + const id = Math.random().toString(36).substring(2); + const message = JSON.stringify({ jsonrpc: "2.0", method: "getSessionSettings", params: {}, id }); + + const handler = (event: MessageEvent) => { + try { + const response = JSON.parse(event.data); + if (response.id === id) { + rpcDataChannel.removeEventListener("message", handler); + if (response.result) { + setGlobalSessionSettings(response.result); + // Also update the settings store for approval handling + setRequireSessionApproval(response.result.requireApproval); + setRequireSessionNickname(response.result.requireNickname); + } + } + } catch (error) { + // Ignore parse errors + } + }; + + rpcDataChannel.addEventListener("message", handler); + rpcDataChannel.send(message); + + // Clean up after timeout + setTimeout(() => { + rpcDataChannel.removeEventListener("message", handler); + }, 5000); + }; + + fetchSettings(); }; const rpcHidChannel = pc.createDataChannel("hidrpc"); @@ -609,42 +716,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 === "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); + } + + // Keep legacy behavior for otherSessionConnected + 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,13 +789,24 @@ export default function KvmIdRoute() { const { send } = useJsonRpc(onJsonRpcRequest); + const { + handleSessionResponse, + handleRpcEvent, + primaryControlRequest, + handleApprovePrimaryRequest, + handleDenyPrimaryRequest, + closePrimaryControlRequest, + newSessionRequest, + handleApproveNewSession, + handleDenyNewSession, + closeNewSessionRequest + } = useSessionManagement(send); + useEffect(() => { if (rpcDataChannel?.readyState !== "open") return; - console.log("Requesting video state"); 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]); @@ -687,7 +817,6 @@ export default function KvmIdRoute() { useEffect(() => { if (rpcDataChannel?.readyState !== "open") return; if (!needLedState) return; - console.log("Requesting keyboard led state"); send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { @@ -695,7 +824,6 @@ export default function KvmIdRoute() { return; } else { const ledState = resp.result as KeyboardLedState; - console.debug("Keyboard led state: ", ledState); setKeyboardLedState(ledState); } setNeedLedState(false); @@ -708,7 +836,6 @@ export default function KvmIdRoute() { useEffect(() => { if (rpcDataChannel?.readyState !== "open") return; if (!needKeyDownState) return; - console.log("Requesting keys down state"); send("getKeyDownState", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { @@ -722,7 +849,6 @@ export default function KvmIdRoute() { } } else { const downState = resp.result as KeysDownState; - console.debug("Keyboard key down state", downState); setKeysDownState(downState); } setNeedKeyDownState(false); @@ -840,16 +966,29 @@ export default function KvmIdRoute() { kvmName={deviceName ?? "JetKVM Device"} /> +
- -
-
- {!!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 +1009,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,6 +1039,60 @@ export default function KvmIdRoute() { {serialConsole && ( )} + + {/* Unified Session Request Dialog */} + {(primaryControlRequest || newSessionRequest) && ( + + )} + + { + setAccessDenied(false); + // Attempt to reconnect + window.location.reload(); + }} + /> + + ); } diff --git a/ui/src/stores/sessionStore.ts b/ui/src/stores/sessionStore.ts new file mode 100644 index 00000000..3e7a57b9 --- /dev/null +++ b/ui/src/stores/sessionStore.ts @@ -0,0 +1,160 @@ +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; + + // 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; + + // 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, + + // 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 + }); + }, + + // 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/utils/nicknameGenerator.ts b/ui/src/utils/nicknameGenerator.ts new file mode 100644 index 00000000..3ce270fa --- /dev/null +++ b/ui/src/utils/nicknameGenerator.ts @@ -0,0 +1,33 @@ +// Nickname generation using backend API for consistency + +// Main function that uses backend generation +export async function generateNickname(sendFn?: Function): 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: any) => { + if (response && !response.error && response.result?.nickname) { + resolve(response.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 af57692f..87f54966 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 3460440b..76eca8e4 100644 --- a/video.go +++ b/video.go @@ -8,7 +8,7 @@ var lastVideoState native.VideoState 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 45253579..55a6d992 100644 --- a/web.go +++ b/web.go @@ -35,9 +35,21 @@ var staticFiles embed.FS type WebRTCSessionRequest struct { 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 } type SetPasswordRequest struct { @@ -158,32 +170,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 +194,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 +202,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 +242,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 @@ -412,14 +388,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 +406,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 +428,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 +462,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 +480,10 @@ 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 - } - - // Clear the auth cookie + // Only 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) // Clear session ID cookie too c.JSON(http.StatusOK, gin.H{"message": "Logout successful"}) } @@ -519,6 +505,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 +609,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 41f532ea..5d04b031 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 a0a8473b..8e6121bd 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 @@ -39,6 +65,30 @@ type Session struct { keysDownStateQueue chan usbgadget.KeysDownState } +// CheckRPCRateLimit checks if the session has exceeded RPC rate limits (DoS protection) +func (s *Session) CheckRPCRateLimit() bool { + const ( + maxRPCPerSecond = 20 + 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 + } + + s.rpcRateLimit++ + if s.rpcRateLimit > maxRPCPerSecond { + return false // Rate limit exceeded + } + return true // Within limits +} + func (s *Session) resetKeepAliveTime() { s.keepAliveJitterLock.Lock() defer s.keepAliveJitterLock.Unlock() @@ -55,6 +105,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 } @@ -106,7 +157,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) + } } } @@ -218,7 +276,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() @@ -226,7 +287,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) } }() @@ -262,9 +332,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) @@ -297,9 +367,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") @@ -307,8 +391,88 @@ func newSession(config SessionConfig) (*Session, error) { } }) + // 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") + } + } + 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 + } + + // 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 isConnected { + isConnected = false + actionSessions-- + onActiveSessionsChanged() + if actionSessions == 0 { + onLastSessionDisconnected() + } + } + } + peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) { - scopedLogger.Info().Str("connectionState", connectionState.String()).Msg("ICE Connection State has changed") + scopedLogger.Info(). + Str("sessionID", session.ID). + Str("connectionState", connectionState.String()). + Msg("ICE Connection State has changed") + if connectionState == webrtc.ICEConnectionStateConnected { if !isConnected { isConnected = true @@ -319,46 +483,27 @@ func newSession(config SessionConfig) (*Session, error) { } } } - //state changes on closing browser tab disconnected->failed, we need to manually close it + + // 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.Debug().Msg("ICE Connection State is failed, closing peerConnection") + 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.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++ { - close(session.hidQueue[i]) - session.hidQueue[i] = 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 isConnected { - isConnected = false - actionSessions-- - onActiveSessionsChanged() - if actionSessions == 0 { - onLastSessionDisconnected() - } - } + scopedLogger.Info(). + Str("sessionID", session.ID). + Msg("ICE Connection State is closed, cleaning up") + cleanupSession("ice-closed") } }) return session, nil