Compare commits

...

9 Commits

Author SHA1 Message Date
Alex b8bf5f8c60
Merge c8b456bf6a into b144d9926f 2025-10-09 09:57:06 +00:00
Alex P c8b456bf6a fix: handle intentional logout to trigger immediate observer promotion
When a user explicitly logs out via the logout button, the session should
be removed immediately without grace period, allowing observers to be
promoted right away instead of waiting for the grace period to expire.

Changes:
- Close WebRTC connection immediately on logout
- Clear grace period marker for intentional logout detection
- Add logging to track logout vs disconnect differentiation

This complements the accidental disconnect handling which uses grace period.
2025-10-09 12:56:57 +03:00
Alex P 57f4be2846 fix: clear transfer blacklist on primary disconnect to enable grace period promotion
When a primary session disconnects accidentally (not intentional logout), the
60-second transfer blacklist from previous role transfers was blocking observer
sessions from being promoted after the grace period expires (~10s).

The blacklist is intended to prevent immediate re-promotion during manual
transfers (user-initiated), but should not interfere with emergency promotion
after accidental disconnects (system-initiated).

Changes:
- Clear all transfer blacklist entries when primary enters grace period
- Add logging to track blacklist clearing for debugging
- Preserve blacklist during intentional logout to maintain manual transfer protection

This ensures observers are promoted after grace period (~10s) instead of
waiting for blacklist expiration (~40-60s).
2025-10-09 12:55:25 +03:00
Alex P b388bc3c62 fix: reduce observer promotion delay from ~40s to ~11s
1. Terminal access permission check:
   - Add Permission.TERMINAL_ACCESS check to Web Terminal button
   - Prevents observer sessions from accessing terminal

2. Immediate websocket cleanup:
   - Close peer connection immediately when websocket errors
   - Previously waited 24+ seconds for ICE to transition from disconnected to failed
   - Now triggers session cleanup immediately on tab close

3. Immediate grace period validation:
   - Trigger validateSinglePrimary() immediately when grace period expires
   - Previously waited up to 10 seconds for next periodic validation
   - Eliminates unnecessary delay in observer promotion

Timeline improvement:
Before: Tab close → 6s (ICE disconnect) → 24s (ICE fail) → RemoveSession → 10s grace → up to 10s validation = ~50s total
After: Tab close → immediate peerConnection.Close() → immediate RemoveSession → 10s grace → immediate validation = ~11s total
2025-10-09 11:39:00 +03:00
Alex P 7901677551 fix: increase RPC rate limit from 20 to 100 per second
The previous limit of 20 RPC/second per session was too aggressive for
multi-session scenarios. During normal operation with multiple sessions,
legitimate RPC calls would frequently hit the rate limit, especially
during page refreshes or reconnections when sessions make bursts of calls
like getSessions, getPermissions, getLocalVersion, and getVideoState.

Increased the limit to 100 RPC/second per session, which still provides
DoS protection while accommodating legitimate multi-session usage patterns.
2025-10-09 11:19:10 +03:00
Alex P ba8caf3448 debug: add detailed logging to trace session addition flow
Add comprehensive logging to identify why sessions fail to be added to
the session manager:
- Log entry/exit points in AddSession
- Track reconnection path execution
- Log max sessions limit checks
- Trace AddSession call and return in handleSessionRequest

This will help diagnose why sessions get stuck at ICE checking state
without being properly registered in the session manager.
2025-10-09 10:58:06 +03:00
Marc Brooks b144d9926f
Remove the temporary directory after extracting buildkit (#874) 2025-10-07 11:57:26 +02:00
Aylen e755a6e1b1
Update openSUSE image reference to Leap 16.0 (#865) 2025-10-07 11:57:10 +02:00
Marc Brooks 99a8c2711c
Add podman support (#875)
Reimplement #141 since we've changed everything since
2025-10-07 11:43:25 +02:00
9 changed files with 209 additions and 51 deletions

View File

@ -1,5 +1,5 @@
{
"name": "JetKVM",
"name": "JetKVM docker devcontainer",
"image": "mcr.microsoft.com/devcontainers/go:1.25-trixie",
"features": {
"ghcr.io/devcontainers/features/node:1": {

View File

@ -32,4 +32,5 @@ wget https://github.com/jetkvm/rv1106-system/releases/download/${BUILDKIT_VERSIO
sudo mkdir -p /opt/jetkvm-native-buildkit && \
sudo tar --use-compress-program="unzstd --long=31" -xvf buildkit.tar.zst -C /opt/jetkvm-native-buildkit && \
rm buildkit.tar.zst
popd
popd
rm -rf "${BUILDKIT_TMPDIR}"

View File

@ -0,0 +1,19 @@
{
"name": "JetKVM podman devcontainer",
"image": "mcr.microsoft.com/devcontainers/go:1.25-trixie",
"features": {
"ghcr.io/devcontainers/features/node:1": {
// Should match what is defined in ui/package.json
"version": "22.19.0"
}
},
"runArgs": [
"--userns=keep-id",
"--security-opt=label=disable",
"--security-opt=label=nested"
],
"containerUser": "vscode",
"containerEnv": {
"HOME": "/home/vscode"
}
}

View File

@ -512,7 +512,12 @@ func handleSessionRequest(
_ = wsjson.Write(context.Background(), c, gin.H{"error": "session manager not initialized"})
return fmt.Errorf("session manager not initialized")
}
scopedLogger.Debug().Msg("About to call AddSession")
err = sessionManager.AddSession(session, req.SessionSettings)
scopedLogger.Debug().
Bool("addSessionSucceeded", err == nil).
Str("error", fmt.Sprintf("%v", err)).
Msg("AddSession returned")
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to add session to session manager")
if err == ErrMaxSessionsReached {
@ -522,6 +527,7 @@ func handleSessionRequest(
}
return err
}
scopedLogger.Debug().Msg("AddSession completed successfully, continuing")
if session.HasPermission(PermissionPaste) {
cancelKeyboardMacro()

View File

@ -126,8 +126,13 @@ func NewSessionManager(logger *zerolog.Logger) *SessionManager {
}
func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSettings) error {
sm.logger.Debug().
Str("sessionID", session.ID).
Msg("AddSession ENTRY")
// Basic input validation
if session == nil {
sm.logger.Error().Msg("AddSession: session is nil")
return errors.New("session cannot be nil")
}
// Validate nickname if provided (matching frontend validation)
@ -163,6 +168,10 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
// Check if a session with this ID already exists (reconnection)
if existing, exists := sm.sessions[session.ID]; exists {
sm.logger.Debug().
Str("sessionID", session.ID).
Msg("AddSession: session ID already exists - RECONNECTION PATH")
// SECURITY: Verify identity matches to prevent session hijacking
if existing.Identity != session.Identity || existing.Source != session.Source {
return fmt.Errorf("session ID already in use by different user (identity mismatch)")
@ -220,11 +229,18 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
// NOTE: Skip validation during reconnection to preserve grace period
// validateSinglePrimary() would clear primary slot during reconnection window
sm.logger.Debug().
Str("sessionID", session.ID).
Msg("AddSession: RETURNING from reconnection path")
go sm.broadcastSessionListUpdate()
return nil
}
if len(sm.sessions) >= sm.maxSessions {
sm.logger.Warn().
Int("currentSessions", len(sm.sessions)).
Int("maxSessions", sm.maxSessions).
Msg("AddSession: MAX SESSIONS REACHED")
return ErrMaxSessionsReached
}
@ -412,60 +428,101 @@ func (sm *SessionManager) RemoveSession(sessionID string) {
// 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
// Check if this session was marked for immediate removal (intentional logout)
isIntentionalLogout := false
if graceTime, exists := sm.reconnectGrace[sessionID]; exists {
// If grace period is already expired, this was intentional logout
if time.Now().After(graceTime) {
isIntentionalLogout = true
sm.logger.Info().
Str("sessionID", sessionID).
Msg("Detected intentional logout - skipping grace period")
delete(sm.reconnectGrace, sessionID)
delete(sm.reconnectInfo, sessionID)
}
}
// Determine grace period duration (used for logging even if intentional logout)
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
// Only add grace period if this is NOT an intentional logout
if !isIntentionalLogout {
// Add a grace period for reconnection for all sessions
// Limit grace period entries to prevent memory exhaustion (DoS protection)
const maxGraceEntries = 10 // Reduced from 20 to limit memory usage
for len(sm.reconnectGrace) >= maxGraceEntries {
// Find and remove the oldest grace period entry
var oldestID string
var oldestTime time.Time
for id, graceTime := range sm.reconnectGrace {
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
}
}
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,
}
}
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).
Int("remainingSessions", len(sm.sessions)).
Msg("Primary session removed, grace period active")
if isIntentionalLogout {
// Intentional logout: clear immediately and promote right away
sm.primarySessionID = ""
sm.lastPrimaryID = ""
sm.logger.Info().
Str("sessionID", sessionID).
Int("remainingSessions", len(sm.sessions)).
Msg("Primary session removed via intentional logout - immediate promotion")
} else {
// Accidental disconnect: use grace period
sm.lastPrimaryID = sessionID // Remember this was the primary for grace period
sm.primarySessionID = "" // Clear primary slot so other sessions can be promoted
// Immediate promotion check: if there are observers waiting, trigger validation
// This allows immediate promotion while still respecting grace period protection
// Clear all blacklists to allow emergency promotion after grace period expires
// The blacklist is meant to prevent immediate re-promotion during manual transfers,
// but should not block emergency promotion after accidental disconnects
if len(sm.transferBlacklist) > 0 {
sm.logger.Info().
Int("clearedBlacklistEntries", len(sm.transferBlacklist)).
Str("disconnectedPrimaryID", sessionID).
Msg("Clearing transfer blacklist to allow grace period promotion")
sm.transferBlacklist = make([]TransferBlacklistEntry, 0)
}
sm.logger.Info().
Str("sessionID", sessionID).
Dur("gracePeriod", time.Duration(gracePeriod)*time.Second).
Int("remainingSessions", len(sm.sessions)).
Msg("Primary session removed, grace period active")
}
// Trigger validation for potential promotion
if len(sm.sessions) > 0 {
sm.logger.Debug().
Str("removedPrimaryID", sessionID).
Bool("intentionalLogout", isIntentionalLogout).
Int("remainingSessions", len(sm.sessions)).
Msg("Triggering immediate validation for potential promotion")
sm.validateSinglePrimary()
@ -509,6 +566,28 @@ func (sm *SessionManager) IsInGracePeriod(sessionID string) bool {
return false
}
// ClearGracePeriod removes the grace period for a session (for intentional logout/disconnect)
// This marks the session for immediate removal without grace period protection
// Actual promotion will happen in RemoveSession when it detects no grace period
func (sm *SessionManager) ClearGracePeriod(sessionID string) {
sm.mu.Lock()
defer sm.mu.Unlock()
// Clear grace period and reconnect info to prevent grace period from being added
delete(sm.reconnectGrace, sessionID)
delete(sm.reconnectInfo, sessionID)
// Mark this session with a special "immediate removal" grace period (already expired)
// This signals to RemoveSession that this was intentional and should skip grace period
sm.reconnectGrace[sessionID] = time.Now().Add(-1 * time.Second) // Already expired
sm.logger.Info().
Str("sessionID", sessionID).
Str("lastPrimaryID", sm.lastPrimaryID).
Str("primarySessionID", sm.primarySessionID).
Msg("Marked session for immediate removal (intentional logout)")
}
// isSessionBlacklisted checks if a session was recently demoted via transfer and should not become primary
func (sm *SessionManager) isSessionBlacklisted(sessionID string) bool {
now := time.Now()
@ -1293,6 +1372,7 @@ func (sm *SessionManager) findMostTrustedSessionForEmergency() string {
bestSessionID := ""
bestScore := -1
// First pass: try to find observers or queued sessions (preferred)
for sessionID, session := range sm.sessions {
// Skip if blacklisted, primary, or not eligible modes
if sm.isSessionBlacklisted(sessionID) ||
@ -1308,6 +1388,23 @@ func (sm *SessionManager) findMostTrustedSessionForEmergency() string {
}
}
// If no observers/queued found, try pending sessions as last resort
if bestSessionID == "" {
for sessionID, session := range sm.sessions {
if sm.isSessionBlacklisted(sessionID) || session.Mode == SessionModePrimary {
continue
}
if session.Mode == SessionModePending {
score := sm.getSessionTrustScore(sessionID)
if score > bestScore {
bestScore = score
bestSessionID = sessionID
}
}
}
}
// Log the selection decision for audit trail
if bestSessionID != "" {
sm.logger.Info().
@ -1504,9 +1601,11 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
needsBroadcast := false
// Check for expired grace periods and promote if needed
gracePeriodExpired := false
for sessionID, graceTime := range sm.reconnectGrace {
if now.After(graceTime) {
delete(sm.reconnectGrace, sessionID)
gracePeriodExpired = true
wasHoldingPrimarySlot := (sm.lastPrimaryID == sessionID)
@ -1719,12 +1818,18 @@ func (sm *SessionManager) cleanupInactiveSessions(ctx context.Context) {
}
}
// 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")
// Run validation immediately if a grace period expired, otherwise run periodically
if gracePeriodExpired {
sm.logger.Debug().Msg("Running immediate validation after grace period expiration")
sm.validateSinglePrimary()
} else {
// Periodic validateSinglePrimary to catch deadlock states
validationCounter++
if validationCounter >= 10 { // Every 10 seconds
validationCounter = 0
sm.logger.Debug().Msg("Running periodic session validation to catch deadlock states")
sm.validateSinglePrimary()
}
}
sm.mu.Unlock()

View File

@ -93,7 +93,7 @@ export default function Actionbar({
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-1.5"
>
<div className="relative flex flex-wrap items-center gap-x-2 gap-y-2">
{developerMode && (
{developerMode && hasPermission(Permission.TERMINAL_ACCESS) && (
<Button
size="XS"
theme="light"

View File

@ -374,8 +374,8 @@ function UrlView({
icon: FedoraIcon,
},
{
name: "openSUSE Leap 15.6",
url: "https://download.opensuse.org/distribution/leap/15.6/iso/openSUSE-Leap-15.6-NET-x86_64-Media.iso",
name: "openSUSE Leap 16.0",
url: "https://download.opensuse.org/distribution/leap/16.0/offline/Leap-16.0-online-installer-x86_64.install.iso",
icon: OpenSUSEIcon,
},
{

31
web.go
View File

@ -357,6 +357,13 @@ func handleWebRTCSignalWsMessages(
typ, msg, err := wsCon.Read(runCtx)
if err != nil {
l.Warn().Str("error", err.Error()).Msg("websocket read error")
// Clean up session when websocket closes
if session := sessionManager.GetSession(connectionID); session != nil && session.peerConnection != nil {
l.Info().
Str("sessionID", session.ID).
Msg("Closing peer connection due to websocket error")
_ = session.peerConnection.Close()
}
return err
}
if typ != websocket.MessageText {
@ -481,10 +488,30 @@ func handleLogin(c *gin.Context) {
}
func handleLogout(c *gin.Context) {
// Only clear the cookies for this session, don't invalidate the token
// Get session ID from cookie before clearing
sessionID, _ := c.Cookie("sessionId")
// Close the WebRTC session immediately for intentional logout
if sessionID != "" {
if session := sessionManager.GetSession(sessionID); session != nil {
websocketLogger.Info().
Str("sessionID", sessionID).
Msg("Closing session due to intentional logout - no grace period")
// Close peer connection (will trigger cleanupSession)
if session.peerConnection != nil {
_ = session.peerConnection.Close()
}
// Clear grace period for intentional logout - observer should be promoted immediately
sessionManager.ClearGracePeriod(sessionID)
}
}
// Clear the 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.SetCookie("sessionId", "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"message": "Logout successful"})
}

View File

@ -68,7 +68,7 @@ type Session struct {
// CheckRPCRateLimit checks if the session has exceeded RPC rate limits (DoS protection)
func (s *Session) CheckRPCRateLimit() bool {
const (
maxRPCPerSecond = 20
maxRPCPerSecond = 100 // Increased from 20 to accommodate multi-session polling and reconnections
rateLimitWindow = time.Second
)