Compare commits

..

1 Commits

Author SHA1 Message Date
Alex 50e1cf06b8
Merge 6f82e8642c into 2444817455 2025-10-22 23:37:07 +02:00
3 changed files with 242 additions and 291 deletions

View File

@ -96,11 +96,11 @@ func handleRequestSessionApprovalRPC(session *Session) (any, error) {
}
func validateNickname(nickname string) error {
if len(nickname) < minNicknameLength {
return fmt.Errorf("nickname must be at least %d characters", minNicknameLength)
if len(nickname) < 2 {
return errors.New("nickname must be at least 2 characters")
}
if len(nickname) > maxNicknameLength {
return fmt.Errorf("nickname must be %d characters or less", maxNicknameLength)
if len(nickname) > 30 {
return errors.New("nickname must be 30 characters or less")
}
if !isValidNickname(nickname) {
return errors.New("nickname can only contain letters, numbers, spaces, and - _ . @")

View File

@ -27,7 +27,6 @@ const (
// Broadcast throttling (DoS protection)
globalBroadcastDelay = 100 * time.Millisecond // Minimum time between global session broadcasts
sessionBroadcastDelay = 50 * time.Millisecond // Minimum time between broadcasts to a single session
broadcastQueueCapacity = 100 // Maximum pending broadcasts before drops occur
// Session timeout defaults
defaultPendingSessionTimeout = 1 * time.Minute // Timeout for pending sessions (DoS protection)
@ -155,7 +154,7 @@ func NewSessionManager(logger *zerolog.Logger) *SessionManager {
logger: logger,
maxSessions: maxSessions,
primaryTimeout: primaryTimeout,
broadcastQueue: make(chan struct{}, broadcastQueueCapacity),
broadcastQueue: make(chan struct{}, 100),
}
ctx, cancel := context.WithCancel(context.Background())
@ -167,11 +166,13 @@ func NewSessionManager(logger *zerolog.Logger) *SessionManager {
}
func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSettings) error {
// Basic input validation
if session == nil {
sm.logger.Error().Msg("AddSession: session is nil")
return errors.New("session cannot be nil")
}
// Validate nickname if provided (matching frontend validation)
if session.Nickname != "" {
if len(session.Nickname) < minNicknameLength {
return fmt.Errorf("nickname must be at least %d characters", minNicknameLength)
@ -179,6 +180,7 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
if len(session.Nickname) > maxNicknameLength {
return fmt.Errorf("nickname must be %d characters or less", maxNicknameLength)
}
// Note: Pattern validation is done in RPC layer, not here for performance
}
if len(session.Identity) > maxIdentityLength {
return fmt.Errorf("identity too long (max %d characters)", maxIdentityLength)
@ -222,25 +224,30 @@ 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 {
if existing.Identity != session.Identity || existing.Source != session.Source {
return fmt.Errorf("session ID already in use by different user (identity mismatch)")
}
// Close old connection to prevent multiple active connections for same session ID
if existing.peerConnection != nil {
existing.peerConnection.Close()
}
// Update the existing session with new connection details
existing.peerConnection = session.peerConnection
existing.VideoTrack = session.VideoTrack
existing.ControlChannel = session.ControlChannel
existing.RPCChannel = session.RPCChannel
existing.HidChannel = session.HidChannel
existing.flushCandidates = session.flushCandidates
// Preserve 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)
if !nicknameReserved && session.Nickname != "" {
@ -249,15 +256,17 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
sm.sessions[session.ID] = session
// If this was the primary, try to restore primary status
if existing.Mode == SessionModePrimary {
isBlacklisted := sm.isSessionBlacklisted(session.ID)
// SECURITY: Prevent dual-primary - only restore if no other primary exists
// SECURITY: Prevent dual-primary window - only restore if no other primary exists
primaryExists := sm.primarySessionID != "" && sm.sessions[sm.primarySessionID] != nil
if sm.lastPrimaryID == session.ID && !isBlacklisted && !primaryExists {
sm.primarySessionID = session.ID
sm.lastPrimaryID = ""
delete(sm.reconnectGrace, session.ID)
} else {
// Grace period expired, another session took over, or primary already exists
session.Mode = SessionModeObserver
}
}
@ -270,18 +279,22 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
return ErrMaxSessionsReached
}
// Generate ID if not set
if session.ID == "" {
session.ID = uuid.New().String()
}
// Set nickname from client settings if provided
if clientSettings != nil && clientSettings.Nickname != "" {
session.Nickname = clientSettings.Nickname
}
// Use global settings for requirements (not client-provided)
globalSettings := currentSessionSettings
primaryExists := sm.primarySessionID != "" && sm.sessions[sm.primarySessionID] != nil
// Check if there's an active grace period for a primary session (different from this session)
hasActivePrimaryGracePeriod := false
if sm.lastPrimaryID != "" && sm.lastPrimaryID != session.ID {
if graceTime, exists := sm.reconnectGrace[sm.lastPrimaryID]; exists {
@ -298,6 +311,7 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
isBlacklisted := sm.isSessionBlacklisted(session.ID)
isOnlySession := len(sm.sessions) == 0
// Determine if this session should become primary
canBecomePrimary := !primaryExists && !hasActivePrimaryGracePeriod
isReconnectingPrimary := wasWithinGracePeriod && wasPreviouslyPrimary
isNewEligibleSession := !wasWithinGracePeriod && (!isBlacklisted || isOnlySession)
@ -310,7 +324,7 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
sm.primarySessionID = session.ID
sm.lastPrimaryID = ""
// Clear grace periods when new primary is established
// Clear all existing grace periods when a new primary is established
for oldSessionID := range sm.reconnectGrace {
delete(sm.reconnectGrace, oldSessionID)
}
@ -332,6 +346,7 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
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{}{
@ -342,8 +357,12 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
}, 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
}
@ -605,10 +624,12 @@ func (sm *SessionManager) SetPrimarySession(sessionID string) error {
// 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
}

View File

@ -42,18 +42,11 @@ import NicknameModal from "@components/NicknameModal";
import AccessDeniedOverlay from "@components/AccessDeniedOverlay";
import PendingApprovalOverlay from "@components/PendingApprovalOverlay";
import DashboardNavbar from "@components/Header";
const ConnectionStatsSidebar = lazy(() => import("@/components/sidebar/connectionStats"));
const Terminal = lazy(() => import("@components/Terminal"));
const UpdateInProgressStatusCard = lazy(
() => import("@/components/UpdateInProgressStatusCard"),
);
const ConnectionStatsSidebar = lazy(() => import('@/components/sidebar/connectionStats'));
const Terminal = lazy(() => import('@components/Terminal'));
const UpdateInProgressStatusCard = lazy(() => import("@/components/UpdateInProgressStatusCard"));
import Modal from "@/components/Modal";
import {
JsonRpcRequest,
JsonRpcResponse,
RpcMethodNotFound,
useJsonRpc,
} from "@/hooks/useJsonRpc";
import { JsonRpcRequest, JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@/hooks/useJsonRpc";
import {
ConnectionFailedOverlay,
LoadingConnectionOverlay,
@ -142,25 +135,15 @@ export default function KvmIdRoute() {
const authMode = "authMode" in loaderResp ? loaderResp.authMode : null;
const params = useParams() as { id: string };
const {
sidebarView,
setSidebarView,
disableVideoFocusTrap,
setDisableVideoFocusTrap,
rebootState,
setRebootState,
} = useUiStore();
const { sidebarView, setSidebarView, disableVideoFocusTrap, setDisableVideoFocusTrap, rebootState, setRebootState } = useUiStore();
const [queryParams, setQueryParams] = useSearchParams();
const {
peerConnection,
setPeerConnection,
peerConnectionState,
setPeerConnectionState,
peerConnection, setPeerConnection,
peerConnectionState, setPeerConnectionState,
setMediaStream,
setRpcDataChannel,
isTurnServerInUse,
setTurnServerInUse,
isTurnServerInUse, setTurnServerInUse,
rpcDataChannel,
setTransceiver,
setRpcHidChannel,
@ -179,14 +162,12 @@ export default function KvmIdRoute() {
const { currentSessionId, currentMode, setCurrentSession } = useSessionStore();
const { nickname, setNickname } = useSharedSessionStore();
const { setRequireSessionApproval, setRequireSessionNickname } = useSettingsStore();
const [globalSessionSettings, setGlobalSessionSettings] = useState<{
requireApproval: boolean;
requireNickname: boolean;
} | null>(null);
const [globalSessionSettings, setGlobalSessionSettings] = useState<{requireApproval: boolean, requireNickname: boolean} | null>(null);
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
const cleanupAndStopReconnecting = useCallback(
function cleanupAndStopReconnecting() {
setConnectionFailed(true);
if (peerConnection) {
setPeerConnectionState(peerConnection.connectionState);
@ -283,7 +264,7 @@ export default function KvmIdRoute() {
},
onClose(_event) {
// Handled by onReconnectStop instead
// We don't want to close everything down, we wait for the reconnect to stop instead
},
onError(event) {
@ -328,7 +309,7 @@ export default function KvmIdRoute() {
if (sessionSettings) {
setGlobalSessionSettings({
requireNickname: sessionSettings.requireNickname || false,
requireApproval: sessionSettings.requireApproval || false,
requireApproval: sessionSettings.requireApproval || false
});
// Also update the settings store for approval handling
setRequireSessionApproval(sessionSettings.requireApproval || false);
@ -337,6 +318,7 @@ export default function KvmIdRoute() {
// If the device version is not set, we can assume the device is using the legacy signaling
if (!deviceVersion) {
// 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;
@ -360,10 +342,7 @@ export default function KvmIdRoute() {
}
if (!peerConnection) {
console.warn(
"[Websocket] Ignoring message because peerConnection is not ready:",
parsedMessage.type,
);
console.warn("[Websocket] Ignoring message because peerConnection is not ready:", parsedMessage.type);
return;
}
if (parsedMessage.type === "answer") {
@ -387,18 +366,15 @@ export default function KvmIdRoute() {
if (parsedMessage.sessionId && parsedMessage.mode) {
handleSessionResponse({
sessionId: parsedMessage.sessionId,
mode: parsedMessage.mode,
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
) {
if (parsedMessage.requireNickname !== undefined && parsedMessage.requireApproval !== undefined) {
setGlobalSessionSettings({
requireNickname: parsedMessage.requireNickname,
requireApproval: parsedMessage.requireApproval,
requireApproval: parsedMessage.requireApproval
});
// Also update the settings store for approval handling
setRequireSessionApproval(parsedMessage.requireApproval);
@ -409,10 +385,8 @@ export default function KvmIdRoute() {
// 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;
const hasNickname = parsedMessage.nickname && parsedMessage.nickname.length > 0;
const requiresNickname = parsedMessage.requireNickname || globalSessionSettings?.requireNickname;
if (requiresNickname && !hasNickname) {
setShowNicknameModal(true);
@ -453,22 +427,18 @@ export default function KvmIdRoute() {
peerConnection?.iceConnectionState === "connected";
if (!isConnectionHealthy) {
console.log(
`[Websocket] Mode changed to ${newMode}, connection unhealthy, reconnecting...`,
);
console.log(`[Websocket] Mode changed to ${newMode}, connection unhealthy, reconnecting...`);
setTimeout(() => {
peerConnection?.close();
setupPeerConnection();
}, 500);
} else {
console.log(
`[Websocket] Mode changed to ${newMode}, connection healthy, skipping reconnect`,
);
console.log(`[Websocket] Mode changed to ${newMode}, connection healthy, skipping reconnect`);
}
}
}
},
},
}
);
const sendWebRTCSignal = useCallback(
@ -570,8 +540,8 @@ export default function KvmIdRoute() {
sessionId: storeSessionId || undefined,
userAgent: navigator.userAgent,
sessionSettings: {
nickname: storeNickname || undefined,
},
nickname: storeNickname || undefined
}
});
}
} catch (e) {
@ -635,13 +605,10 @@ export default function KvmIdRoute() {
setRpcHidUnreliableChannel(rpcHidUnreliableChannel);
};
const rpcHidUnreliableNonOrderedChannel = pc.createDataChannel(
"hidrpc-unreliable-nonordered",
{
const rpcHidUnreliableNonOrderedChannel = pc.createDataChannel("hidrpc-unreliable-nonordered", {
ordered: false,
maxRetransmits: 0,
},
);
});
rpcHidUnreliableNonOrderedChannel.binaryType = "arraybuffer";
rpcHidUnreliableNonOrderedChannel.onopen = () => {
setRpcHidUnreliableNonOrderedChannel(rpcHidUnreliableNonOrderedChannel);
@ -732,12 +699,10 @@ export default function KvmIdRoute() {
}
// Fire and forget
api
.POST(`${CLOUD_API}/webrtc/turn_activity`, {
api.POST(`${CLOUD_API}/webrtc/turn_activity`, {
bytesReceived: bytesReceivedDelta,
bytesSent: bytesSentDelta,
})
.catch(() => {
}).catch(() => {
// we don't care about errors here, but we don't want unhandled promise rejections
});
}, 10000);
@ -745,11 +710,8 @@ export default function KvmIdRoute() {
const { setNetworkState } = useNetworkStateStore();
const { setHdmiState } = useVideoStore();
const {
keyboardLedState,
setKeyboardLedState,
keysDownState,
setKeysDownState,
setUsbState,
keyboardLedState, setKeyboardLedState,
keysDownState, setKeysDownState, setUsbState,
} = useHidStore();
const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled);
@ -758,8 +720,7 @@ export default function KvmIdRoute() {
function onJsonRpcRequest(resp: JsonRpcRequest) {
// Handle session-related events
if (
resp.method === "sessionsUpdated" ||
if (resp.method === "sessionsUpdated" ||
resp.method === "modeChanged" ||
resp.method === "connectionModeChanged" ||
resp.method === "otherSessionConnected" ||
@ -767,8 +728,7 @@ export default function KvmIdRoute() {
resp.method === "primaryControlApproved" ||
resp.method === "primaryControlDenied" ||
resp.method === "newSessionPending" ||
resp.method === "sessionAccessDenied"
) {
resp.method === "sessionAccessDenied") {
handleRpcEvent(resp.method, resp.params);
// Show access denied overlay if our session was denied
@ -849,7 +809,7 @@ export default function KvmIdRoute() {
newSessionRequest,
handleApproveNewSession,
handleDenyNewSession,
closeNewSessionRequest,
closeNewSessionRequest
} = useSessionManagement(send);
const { hasPermission, isLoading: isLoadingPermissions } = usePermissions();
@ -863,13 +823,7 @@ export default function KvmIdRoute() {
const hdmiState = resp.result as Parameters<VideoState["setHdmiState"]>[0];
setHdmiState(hdmiState);
});
}, [
rpcDataChannel?.readyState,
hasPermission,
isLoadingPermissions,
send,
setHdmiState,
]);
}, [rpcDataChannel?.readyState, hasPermission, isLoadingPermissions, send, setHdmiState]);
const [needLedState, setNeedLedState] = useState(true);
@ -888,15 +842,7 @@ export default function KvmIdRoute() {
}
setNeedLedState(false);
});
}, [
rpcDataChannel?.readyState,
send,
setKeyboardLedState,
keyboardLedState,
needLedState,
hasPermission,
isLoadingPermissions,
]);
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState, hasPermission, isLoadingPermissions]);
const [needKeyDownState, setNeedKeyDownState] = useState(true);
@ -908,10 +854,7 @@ export default function KvmIdRoute() {
send("getKeyDownState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
if (resp.error.code === RpcMethodNotFound) {
console.warn(
"Failed to get key down state, switching to old-school",
resp.error,
);
console.warn("Failed to get key down state, switching to old-school", resp.error);
setHidRpcDisabled(true);
} else {
console.error("Failed to get key down state", resp.error);
@ -922,16 +865,7 @@ export default function KvmIdRoute() {
}
setNeedKeyDownState(false);
});
}, [
keysDownState,
needKeyDownState,
rpcDataChannel?.readyState,
send,
setKeysDownState,
setHidRpcDisabled,
hasPermission,
isLoadingPermissions,
]);
}, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeysDownState, setHidRpcDisabled, hasPermission, isLoadingPermissions]);
// When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => {
@ -976,9 +910,7 @@ export default function KvmIdRoute() {
// Rebooting takes priority over connection status
if (rebootState?.isRebooting) {
return (
<RebootingOverlay show={true} postRebootAction={rebootState.postRebootAction} />
);
return <RebootingOverlay show={true} postRebootAction={rebootState.postRebootAction} />;
}
const hasConnectionFailed =
@ -1005,16 +937,7 @@ export default function KvmIdRoute() {
}
return null;
}, [
location.pathname,
rebootState?.isRebooting,
rebootState?.postRebootAction,
connectionFailed,
peerConnectionState,
peerConnection,
setupPeerConnection,
loadingMessage,
]);
}, [location.pathname, rebootState?.isRebooting, rebootState?.postRebootAction, connectionFailed, peerConnectionState, peerConnection, setupPeerConnection, loadingMessage]);
return (
<PermissionsProvider>
@ -1048,9 +971,7 @@ export default function KvmIdRoute() {
<div className="grid h-full grid-rows-(--grid-headerBody) select-none">
<DashboardNavbar
primaryLinks={
isOnDevice ? [] : [{ title: "Cloud Devices", to: "/devices" }]
}
primaryLinks={isOnDevice ? [] : [{ title: "Cloud Devices", to: "/devices" }]}
showConnectionStatus={true}
isLoggedIn={authMode === "password" || !!user}
userEmail={user?.email}
@ -1058,14 +979,15 @@ export default function KvmIdRoute() {
kvmName={deviceName ?? "JetKVM Device"}
/>
<div className="relative flex h-full w-full overflow-hidden">
{/* Only show video feed if nickname is set (when required) and not pending approval */}
{!showNicknameModal && currentMode !== "pending" ? (
{(!showNicknameModal && currentMode !== "pending") ? (
<>
<WebRTCVideo hasConnectionIssues={!!ConnectionStatusElement} />
<div
style={{ animationDuration: "500ms" }}
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center p-4"
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4"
>
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
{!!ConnectionStatusElement && ConnectionStatusElement}
@ -1073,8 +995,8 @@ export default function KvmIdRoute() {
</div>
</>
) : (
<div className="flex flex-1 items-center justify-center bg-slate-900">
<div className="text-center text-slate-400">
<div className="flex-1 bg-slate-900 flex items-center justify-center">
<div className="text-slate-400 text-center">
{showNicknameModal && <p>Please set your nickname to continue</p>}
{currentMode === "pending" && <p>Waiting for session approval...</p>}
</div>
@ -1103,7 +1025,7 @@ export default function KvmIdRoute() {
<NicknameModal
isOpen={showNicknameModal}
onSubmit={async nickname => {
onSubmit={async (nickname) => {
setNickname(nickname);
setShowNicknameModal(false);
setDisableVideoFocusTrap(false);
@ -1159,13 +1081,19 @@ export default function KvmIdRoute() {
: handleApproveNewSession
}
onDeny={
primaryControlRequest ? handleDenyPrimaryRequest : handleDenyNewSession
primaryControlRequest
? handleDenyPrimaryRequest
: handleDenyNewSession
}
onDismiss={
primaryControlRequest ? closePrimaryControlRequest : closeNewSessionRequest
primaryControlRequest
? closePrimaryControlRequest
: closeNewSessionRequest
}
onClose={
primaryControlRequest ? closePrimaryControlRequest : closeNewSessionRequest
primaryControlRequest
? closePrimaryControlRequest
: closeNewSessionRequest
}
/>
)}
@ -1184,7 +1112,9 @@ export default function KvmIdRoute() {
}}
/>
<PendingApprovalOverlay show={currentMode === "pending"} />
<PendingApprovalOverlay
show={currentMode === "pending"}
/>
</FeatureFlagProvider>
</PermissionsProvider>
);