Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
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
97 changes: 83 additions & 14 deletions cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
Expand All @@ -453,6 +474,7 @@ func handleSessionRequest(
IsCloud: isCloudConnection,
LocalIP: req.IP,
ICEServers: req.ICEServers,
UserAgent: req.UserAgent,
Logger: scopedLogger,
})
if err != nil {
Expand All @@ -462,26 +484,73 @@ func handleSessionRequest(

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

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

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

cloudLogger.Info().Interface("session", session).Msg("new session accepted")
cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
if sessionManager == nil {
scopedLogger.Error().Msg("sessionManager is nil")
_ = wsjson.Write(context.Background(), c, gin.H{"error": "session manager not initialized"})
return fmt.Errorf("session manager not initialized")
}

// Cancel any ongoing keyboard macro when session changes
cancelKeyboardMacro()
err = sessionManager.AddSession(session, req.SessionSettings)
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to add session to session manager")
if err == ErrMaxSessionsReached {
_ = wsjson.Write(context.Background(), c, gin.H{"error": "maximum sessions reached"})
} else {
_ = wsjson.Write(context.Background(), c, gin.H{"error": err.Error()})
}
return err
}

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

requireNickname := false
requireApproval := false
if currentSessionSettings != nil {
requireNickname = currentSessionSettings.RequireNickname
requireApproval = currentSessionSettings.RequireApproval
}

currentSession = session
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
err = wsjson.Write(context.Background(), c, gin.H{
"type": "answer",
"data": sd,
"sessionId": session.ID,
"mode": session.Mode,
"nickname": session.Nickname,
"requireNickname": requireNickname,
"requireApproval": requireApproval,
})
if err != nil {
return err
}

if session.flushCandidates != nil {
session.flushCandidates()
}
return nil
}

Expand Down
35 changes: 30 additions & 5 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -104,6 +114,7 @@ type Config struct {
UsbDevices *usbgadget.Devices `json:"usb_devices"`
NetworkConfig *network.NetworkConfig `json:"network_config"`
DefaultLogLevel string `json:"default_log_level"`
SessionSettings *SessionSettings `json:"session_settings"`
VideoSleepAfterSec int `json:"video_sleep_after_sec"`
}

Expand All @@ -129,17 +140,31 @@ func (c *Config) SetDisplayRotation(rotation string) error {
const configPath = "/userdata/kvm_config.json"

var defaultConfig = &Config{
CloudURL: "https://api.jetkvm.com",
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
ActiveExtension: "",
CloudURL: "https://api.jetkvm.com",
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
ActiveExtension: "",
MultiSession: &MultiSessionConfig{
Enabled: true, // Enable by default for new features
MaxSessions: 10, // Reasonable default
PrimaryTimeout: 300, // 5 minutes
AllowCloudOverride: true, // Cloud sessions can take control
RequireAuthTransfer: false, // Don't require auth by default
},
KeyboardMacros: []KeyboardMacro{},
DisplayRotation: "270",
KeyboardLayout: "en-US",
DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes
JigglerEnabled: false,
SessionSettings: &SessionSettings{
RequireApproval: false,
RequireNickname: false,
ReconnectGrace: 10,
PrivateKeystrokes: false,
MaxRejectionAttempts: 3,
},
JigglerEnabled: false,
// This is the "Standard" jiggler option in the UI
JigglerConfig: &JigglerConfig{
InactivityLimitSeconds: 60,
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) {})
}
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")
)
20 changes: 19 additions & 1 deletion hidrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,27 +27,45 @@ 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")
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) {
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")
Expand Down
Loading