Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
cd70efb
feat: multi-session support with role-based permissions
pennycoders Oct 8, 2025
b322255
fix: resolve all Go and TypeScript linting issues
pennycoders Oct 8, 2025
b0494e8
security: prevent video access for pending/denied sessions
pennycoders Oct 8, 2025
a1548fe
feat: improve session approval workflow with re-request and rejection…
pennycoders Oct 8, 2025
ffc4a2a
fix: prevent getLocalVersion call for sessions without video permission
pennycoders Oct 8, 2025
f9ebd6a
feat: add strict observer-to-primary promotion controls and immediate…
pennycoders Oct 8, 2025
541d2bd
fix: correct grace period protection during primary reconnection
pennycoders Oct 8, 2025
ba8caf3
debug: add detailed logging to trace session addition flow
pennycoders Oct 9, 2025
7901677
fix: increase RPC rate limit from 20 to 100 per second
pennycoders Oct 9, 2025
b388bc3
fix: reduce observer promotion delay from ~40s to ~11s
pennycoders Oct 9, 2025
57f4be2
fix: clear transfer blacklist on primary disconnect to enable grace p…
pennycoders Oct 9, 2025
c8b456b
fix: handle intentional logout to trigger immediate observer promotion
pennycoders Oct 9, 2025
ce1cbe1
fix: move nil check before accessing session.ID to satisfy staticcheck
pennycoders Oct 9, 2025
8dbd98b
Merge branch 'dev' into feat/multisession-support
pennycoders Oct 9, 2025
309126b
[WIP] Bugfixes: session promotion
pennycoders Oct 10, 2025
8252992
fix: correct grace period protection during primary reconnection
pennycoders Oct 10, 2025
821675c
security: fix critical race conditions and add validation to session …
pennycoders Oct 10, 2025
f90c255
fix: prevent unnecessary RPC calls for pending sessions and increase …
pennycoders Oct 10, 2025
00e6edb
fix: prevent infinite getLocalVersion RPC calls on refresh
pennycoders Oct 10, 2025
f9e190f
fix: prevent multiple getPermissions RPC calls on page load
pennycoders Oct 10, 2025
335c6ee
refactor: centralize permissions with context provider and remove red…
pennycoders Oct 10, 2025
f27c2f4
fix: prevent RPC calls before session approval
pennycoders Oct 10, 2025
554b43f
Cleanup: Remove accidentally removed file
pennycoders Oct 10, 2025
1650918
fix: use permission-based guards for RPC initialization calls
pennycoders Oct 10, 2025
5a01004
feat: add configurable max sessions and observer cleanup timeout
pennycoders Oct 11, 2025
8d51aaa
fix: prevent session timeout when jiggler is active
pennycoders Oct 13, 2025
827decf
fix: resolve intermittent mouse control loss and add permission logging
pennycoders Oct 14, 2025
64a6a1a
fix: resolve intermittent mouse control loss and add permission logging
pennycoders Oct 14, 2025
0e040a9
Merge branch 'dev' into feat/multisession-support
pennycoders Oct 16, 2025
8f17bbd
[WIP] Optimizations: code readiness optimizations
pennycoders Oct 17, 2025
da85b54
[WIP] Optimizations: code readiness optimizations
pennycoders Oct 17, 2025
846caf7
refactor: improve code maintainability with focused handler functions
pennycoders Oct 17, 2025
9a10d3e
refactor: revert unrelated USB gadget type changes
pennycoders Oct 17, 2025
40ccecc
fix: address critical race conditions and security issues in multi-se…
pennycoders Oct 17, 2025
711f781
Cleanup: remove unnecessary md file
pennycoders Oct 17, 2025
c9d8dcb
fix: primary session timeout not triggering due to reconnection resets
pennycoders Oct 17, 2025
f2431e9
fix: jiggler should not prevent primary session timeout
pennycoders Oct 17, 2025
08b0dd0
chore: restore jiggler.go from dev branch
pennycoders Oct 17, 2025
ba2fa34
fix: address critical issues in multi-session management
pennycoders Oct 17, 2025
8dc013d
Merge branch 'dev' into feat/multisession-support
pennycoders Oct 17, 2025
c8808ee
fix: resolve React hooks violation in hardware settings
pennycoders Oct 17, 2025
8189861
fix: version info not loading in feature flags and settings
pennycoders Oct 17, 2025
f56e148
build: allow VERSION and VERSION_DEV to be overridden via environment
pennycoders Oct 17, 2025
6f82e86
[WIP] Optimizations: code readiness optimizations
pennycoders Oct 22, 2025
1671a77
[WIP] Optimizations: code readiness optimizations
pennycoders Oct 22, 2025
2e4a49f
[WIP] Optimizations: code readiness optimizations
pennycoders Oct 22, 2025
1b007b7
fix: resolve critical concurrency and safety issues in session manage…
pennycoders Oct 23, 2025
8c1ebe3
fix: primary session timeout not tracking RPC activity
pennycoders Oct 23, 2025
15963d3
fix: primary session timeout promoting wrong session
pennycoders Oct 23, 2025
587f8b5
fix: resolve critical security and stability issues
pennycoders Oct 23, 2025
79b5ec3
fix: resolve activity tracking and UI race conditions
pennycoders Oct 23, 2025
94d24e7
fix: add missing strings import in jsonrpc.go
pennycoders Oct 23, 2025
192a470
fix: display error message instead of [Object object] in version info
pennycoders Oct 23, 2025
906c5cf
fix: defer version check until session is approved
pennycoders Oct 23, 2025
d7a37b5
fix: prevent timeout ping-pong loop in emergency promotions
pennycoders Oct 23, 2025
6898ede
refactor: deduplicate nickname validation logic
pennycoders Oct 23, 2025
f7a5ed6
fix: only auto-remove disconnected pending sessions
pennycoders Oct 23, 2025
e7bdabb
fix: suppress expected datachannel closure errors during logout
pennycoders Oct 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE := $(shell date -u +%FT%T%z)
BUILDTS := $(shell date -u +%s)
REVISION := $(shell git rev-parse HEAD)
VERSION_DEV := 0.4.9-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.4.8
VERSION_DEV ?= 0.4.9-dev$(shell date +%Y%m%d%H%M)
VERSION ?= 0.4.8

PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := github.com/jetkvm/kvm
Expand Down
101 changes: 87 additions & 14 deletions cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,24 @@ func wsResetMetrics(established bool, sourceType string, source string) {
}

func handleCloudRegister(c *gin.Context) {
sessionID, _ := c.Cookie("sessionId")
authToken, _ := c.Cookie("authToken")

// Require authentication for this endpoint
if authToken == "" || authToken != config.LocalAuthToken {
c.JSON(401, gin.H{"error": "Authentication required"})
return
}

// Check session permissions if session exists
if sessionID != "" {
session := sessionManager.GetSession(sessionID)
if session != nil && !session.HasPermission(PermissionSettingsWrite) {
c.JSON(403, gin.H{"error": "Permission denied: settings modify permission required"})
return
}
}

var req CloudRegisterRequest

if err := c.ShouldBindJSON(&req); err != nil {
Expand Down Expand Up @@ -426,8 +444,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"
Expand All @@ -453,6 +478,7 @@ func handleSessionRequest(
IsCloud: isCloudConnection,
LocalIP: req.IP,
ICEServers: req.ICEServers,
UserAgent: req.UserAgent,
Logger: scopedLogger,
})
if err != nil {
Expand All @@ -462,26 +488,73 @@ func handleSessionRequest(

sd, err := session.ExchangeOffer(req.Sd)
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to exchange offer")
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
return err
}
if currentSession != nil {
writeJSONRPCEvent("otherSessionConnected", nil, currentSession)
peerConn := currentSession.peerConnection
go func() {
time.Sleep(1 * time.Second)
_ = peerConn.Close()
}()
session.Source = source

if isCloudConnection && req.OidcGoogle != "" {
session.Identity = config.GoogleIdentity

// Use client-provided sessionId for reconnection, otherwise generate new one
// This enables multi-tab support while preserving reconnection on refresh
if req.SessionId != "" {
session.ID = req.SessionId
scopedLogger.Info().Str("sessionId", session.ID).Msg("Cloud session reconnecting with client-provided ID")
} else {
session.ID = connectionID
scopedLogger.Info().Str("sessionId", session.ID).Msg("New cloud session established")
}
} else {
session.ID = connectionID
scopedLogger.Info().Str("sessionId", session.ID).Msg("Local session established")
}

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
}

if session.HasPermission(PermissionPaste) {
cancelKeyboardMacro()
}

cloudLogger.Info().Interface("session", session).Msg("new session accepted")
cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
requireNickname := false
requireApproval := false
if currentSessionSettings != nil {
requireNickname = currentSessionSettings.RequireNickname
requireApproval = currentSessionSettings.RequireApproval
}

// Cancel any ongoing keyboard macro when session changes
cancelKeyboardMacro()
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
}

currentSession = session
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
if session.flushCandidates != nil {
session.flushCandidates()
}
return nil
}

Expand Down
43 changes: 38 additions & 5 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,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"`
Expand All @@ -105,6 +115,7 @@ type Config struct {
UsbDevices *usbgadget.Devices `json:"usb_devices"`
NetworkConfig *types.NetworkConfig `json:"network_config"`
DefaultLogLevel string `json:"default_log_level"`
SessionSettings *SessionSettings `json:"session_settings"`
VideoSleepAfterSec int `json:"video_sleep_after_sec"`
VideoQualityFactor float64 `json:"video_quality_factor"`
}
Expand Down Expand Up @@ -156,17 +167,31 @@ var (

func getDefaultConfig() Config {
return Config{
CloudURL: "https://api.jetkvm.com",
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
ActiveExtension: "",
CloudURL: "https://api.jetkvm.com",
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
ActiveExtension: "",
MultiSession: &MultiSessionConfig{
Enabled: true, // Enable by default for new features
MaxSessions: 10, // Reasonable default
PrimaryTimeout: 300, // 5 minutes
AllowCloudOverride: true, // Cloud sessions can take control
RequireAuthTransfer: false, // Don't require auth by default
},
KeyboardMacros: []KeyboardMacro{},
DisplayRotation: "270",
KeyboardLayout: "en-US",
DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes
JigglerEnabled: false,
SessionSettings: &SessionSettings{
RequireApproval: false,
RequireNickname: false,
ReconnectGrace: 10,
PrivateKeystrokes: false,
MaxRejectionAttempts: 3,
},
JigglerEnabled: false,
// This is the "Standard" jiggler option in the UI
JigglerConfig: func() *JigglerConfig { c := defaultJigglerConfig; return &c }(),
TLSMode: "",
Expand Down Expand Up @@ -248,6 +273,14 @@ func LoadConfig() {
loadedConfig.JigglerConfig = getDefaultConfig().JigglerConfig
}

if loadedConfig.MultiSession == nil {
loadedConfig.MultiSession = getDefaultConfig().MultiSession
}

if loadedConfig.SessionSettings == nil {
loadedConfig.SessionSettings = getDefaultConfig().SessionSettings
}

// fixup old keyboard layout value
if loadedConfig.KeyboardLayout == "en_US" {
loadedConfig.KeyboardLayout = "en-US"
Expand Down
11 changes: 11 additions & 0 deletions datachannel_helpers.go
Original file line number Diff line number Diff line change
@@ -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) {})
}
2 changes: 1 addition & 1 deletion display.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func updateDisplay() {
nativeInstance.UpdateLabelIfChanged("hdmi_status_label", "Disconnected")
_, _ = nativeInstance.UIObjClearState("hdmi_status_label", "LV_STATE_CHECKED")
}
nativeInstance.UpdateLabelIfChanged("cloud_status_label", fmt.Sprintf("%d active", actionSessions))
nativeInstance.UpdateLabelIfChanged("cloud_status_label", fmt.Sprintf("%d active", getActiveSessions()))

if networkManager != nil && networkManager.IsUp() {
nativeInstance.UISetVar("main_screen", "home_screen")
Expand Down
10 changes: 10 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
@@ -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")
)
44 changes: 41 additions & 3 deletions hidrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {

switch message.Type() {
case hidrpc.TypeHandshake:
if !session.HasPermission(PermissionVideoView) {
logger.Debug().
Str("sessionID", session.ID).
Str("mode", string(session.Mode)).
Msg("handshake blocked: session lacks PermissionVideoView")
return
}
message, err := hidrpc.NewHandshakeMessage().Marshal()
if err != nil {
logger.Warn().Err(err).Msg("failed to marshal handshake message")
Expand All @@ -27,27 +34,57 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
}
session.hidRPCAvailable = true
case hidrpc.TypeKeypressReport, hidrpc.TypeKeyboardReport:
if !session.HasPermission(PermissionKeyboardInput) {
logger.Debug().
Str("sessionID", session.ID).
Str("mode", string(session.Mode)).
Msg("keyboard input blocked: session lacks 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")
return
}
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) {
logger.Debug().
Str("sessionID", session.ID).
Str("mode", string(session.Mode)).
Msg("pointer report blocked: session lacks 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)
case hidrpc.TypeMouseReport:
if !session.HasPermission(PermissionMouseInput) {
logger.Debug().
Str("sessionID", session.ID).
Str("mode", string(session.Mode)).
Msg("mouse report blocked: session lacks PermissionMouseInput")
return
}
mouseReport, err := message.MouseReport()
if err != nil {
logger.Warn().Err(err).Msg("failed to get mouse report")
Expand Down Expand Up @@ -116,14 +153,15 @@ const baseExtension = expectedRate + maxLateness // 100ms extension on perfect t
const maxStaleness = 225 * time.Millisecond // discard ancient packets outright

func handleHidRPCKeypressKeepAlive(session *Session) error {
// NOTE: Do NOT update LastActive here - jiggler keep-alives are automated,
// not human input. Only actual keyboard/mouse input should prevent timeout.

session.keepAliveJitterLock.Lock()
defer session.keepAliveJitterLock.Unlock()

now := time.Now()

// 1) Staleness guard: ensures packets that arrive far beyond the life of a valid key hold
// (e.g. after a network stall, retransmit burst, or machine sleep) are ignored outright.
// This prevents “zombie” keepalives from reviving a key that should already be released.
// Staleness guard: discard ancient packets after network stall/machine sleep
if !session.lastTimerResetTime.IsZero() && now.Sub(session.lastTimerResetTime) > maxStaleness {
return nil
}
Expand Down
Loading