diff --git a/agents/k8s-agent/agent_handlers.go b/agents/k8s-agent/agent_handlers.go index f67af55d..6f3b7d17 100644 --- a/agents/k8s-agent/agent_handlers.go +++ b/agents/k8s-agent/agent_handlers.go @@ -139,14 +139,6 @@ func (h *StartSessionHandler) Handle(cmd *CommandMessage) (*CommandResult, error // Don't fail the command - Session CRD is informational } - // Initialize VNC tunnel for this session - if h.agent != nil { - if err := h.agent.initVNCTunnelForSession(sessionID); err != nil { - log.Printf("[StartSessionHandler] Warning: Failed to init VNC tunnel: %v", err) - // Don't fail the command - VNC can be established later - } - } - // v2.0 ARCHITECTURE: Update database via API (source of truth) // Send session update message to Control Plane to update database if h.agent != nil { @@ -160,14 +152,14 @@ func (h *StartSessionHandler) Handle(cmd *CommandMessage) (*CommandResult, error return &CommandResult{ Success: true, Data: map[string]interface{}{ - "sessionId": sessionID, - "deployment": deployment.Name, - "service": service.Name, - "pvc": pvcName, - "podName": podName, - "podIP": podIP, - "vncPort": 3000, // Default VNC port - "state": "running", + "sessionId": sessionID, + "deployment": deployment.Name, + "service": service.Name, + "pvc": pvcName, + "podName": podName, + "podIP": podIP, + "streamingPort": 8080, // Selkies-GStreamer default + "state": "running", }, }, nil } @@ -209,13 +201,6 @@ func (h *StopSessionHandler) Handle(cmd *CommandMessage) (*CommandResult, error) log.Printf("[StopSessionHandler] Deleting resources for session %s (deletePVC: %v)", sessionID, shouldDeletePVC) - // Close VNC tunnel for this session - if h.agent != nil && h.agent.vncManager != nil { - if err := h.agent.vncManager.CloseTunnel(sessionID); err != nil { - log.Printf("[StopSessionHandler] Warning: Failed to close VNC tunnel: %v", err) - } - } - // Delete Deployment if err := deleteDeployment(h.kubeClient, h.config.Namespace, sessionID); err != nil { log.Printf("[StopSessionHandler] Warning: Failed to delete deployment: %v", err) @@ -340,11 +325,11 @@ func (h *WakeSessionHandler) Handle(cmd *CommandMessage) (*CommandResult, error) return &CommandResult{ Success: true, Data: map[string]interface{}{ - "sessionId": sessionID, - "podName": podName, - "podIP": podIP, - "vncPort": 3000, - "state": "running", + "sessionId": sessionID, + "podName": podName, + "podIP": podIP, + "streamingPort": 8080, + "state": "running", }, }, nil } diff --git a/agents/k8s-agent/agent_message_handler.go b/agents/k8s-agent/agent_message_handler.go index 92469baf..07f3c2fc 100644 --- a/agents/k8s-agent/agent_message_handler.go +++ b/agents/k8s-agent/agent_message_handler.go @@ -52,12 +52,6 @@ func (a *K8sAgent) handleMessage(messageBytes []byte) error { case "shutdown": return a.handleShutdownMessage(msg.Payload) - case "vnc_data": - return a.handleVNCDataMessage(msg.Payload) - - case "vnc_close": - return a.handleVNCCloseMessage(msg.Payload) - default: log.Printf("[K8sAgent] Unknown message type: %s", msg.Type) return nil diff --git a/agents/k8s-agent/agent_vnc_handler.go b/agents/k8s-agent/agent_vnc_handler.go deleted file mode 100644 index a7e12eda..00000000 --- a/agents/k8s-agent/agent_vnc_handler.go +++ /dev/null @@ -1,172 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "log" - "time" -) - -// VNC message types (matching Control Plane protocol) -const ( - vncMessageTypeData = "vnc_data" - vncMessageTypeClose = "vnc_close" - vncMessageTypeReady = "vnc_ready" - vncMessageTypeError = "vnc_error" -) - -// VNCDataMessage represents VNC data being tunneled. -type VNCDataMessage struct { - SessionID string `json:"sessionId"` - Data string `json:"data"` // base64-encoded -} - -// VNCCloseMessage represents a request to close a VNC tunnel. -type VNCCloseMessage struct { - SessionID string `json:"sessionId"` - Reason string `json:"reason,omitempty"` -} - -// VNCReadyMessage indicates a VNC tunnel is ready. -type VNCReadyMessage struct { - SessionID string `json:"sessionId"` - VNCPort int `json:"vncPort"` - PodName string `json:"podName,omitempty"` -} - -// VNCErrorMessage reports a VNC tunnel error. -type VNCErrorMessage struct { - SessionID string `json:"sessionId"` - Error string `json:"error"` -} - -// handleVNCDataMessage processes incoming VNC data from Control Plane. -// -// The data is relayed to the pod via the VNC tunnel (port-forward). -func (a *K8sAgent) handleVNCDataMessage(payload json.RawMessage) error { - var msg VNCDataMessage - if err := json.Unmarshal(payload, &msg); err != nil { - return fmt.Errorf("failed to parse VNC data message: %w", err) - } - - if a.vncManager == nil { - return fmt.Errorf("VNC manager not initialized") - } - - // Send data to pod via tunnel - if err := a.vncManager.SendData(msg.SessionID, msg.Data); err != nil { - log.Printf("[VNCHandler] Failed to send data to pod for session %s: %v", msg.SessionID, err) - return err - } - - return nil -} - -// handleVNCCloseMessage processes a VNC tunnel close request. -// -// This is sent when the client disconnects from the VNC session. -func (a *K8sAgent) handleVNCCloseMessage(payload json.RawMessage) error { - var msg VNCCloseMessage - if err := json.Unmarshal(payload, &msg); err != nil { - return fmt.Errorf("failed to parse VNC close message: %w", err) - } - - log.Printf("[VNCHandler] Closing VNC tunnel for session %s (reason: %s)", msg.SessionID, msg.Reason) - - if a.vncManager == nil { - return fmt.Errorf("VNC manager not initialized") - } - - // Close the tunnel - if err := a.vncManager.CloseTunnel(msg.SessionID); err != nil { - log.Printf("[VNCHandler] Failed to close tunnel: %v", err) - return err - } - - return nil -} - -// sendVNCReady sends a VNC ready notification to Control Plane. -// -// This is called when the VNC tunnel is established and ready for connections. -func (a *K8sAgent) sendVNCReady(sessionID string, vncPort int, podName string) error { - ready := map[string]interface{}{ - "type": vncMessageTypeReady, - "timestamp": time.Now(), - "payload": VNCReadyMessage{ - SessionID: sessionID, - VNCPort: vncPort, - PodName: podName, - }, - } - - if err := a.sendMessage(ready); err != nil { - log.Printf("[VNCHandler] Failed to send VNC ready for session %s: %v", sessionID, err) - return err - } - - log.Printf("[VNCHandler] Sent VNC ready for session %s", sessionID) - return nil -} - -// sendVNCData sends VNC data from pod to Control Plane. -// -// The data is base64-encoded for transport over JSON WebSocket. -func (a *K8sAgent) sendVNCData(sessionID string, base64Data string) error { - data := map[string]interface{}{ - "type": vncMessageTypeData, - "timestamp": time.Now(), - "payload": VNCDataMessage{ - SessionID: sessionID, - Data: base64Data, - }, - } - - return a.sendMessage(data) -} - -// sendVNCError sends a VNC error notification to Control Plane. -// -// This is called when the VNC tunnel encounters an error. -func (a *K8sAgent) sendVNCError(sessionID string, errorMsg string) error { - errMsg := map[string]interface{}{ - "type": vncMessageTypeError, - "timestamp": time.Now(), - "payload": VNCErrorMessage{ - SessionID: sessionID, - Error: errorMsg, - }, - } - - if err := a.sendMessage(errMsg); err != nil { - log.Printf("[VNCHandler] Failed to send VNC error for session %s: %v", sessionID, err) - return err - } - - log.Printf("[VNCHandler] Sent VNC error for session %s: %s", sessionID, errorMsg) - return nil -} - -// initVNCTunnelForSession creates a VNC tunnel when a session starts. -// -// This is called automatically after a session is started successfully. -func (a *K8sAgent) initVNCTunnelForSession(sessionID string) error { - if a.vncManager == nil { - return fmt.Errorf("VNC manager not initialized") - } - - log.Printf("[VNCHandler] Initializing VNC tunnel for session %s", sessionID) - - // Create the tunnel in a goroutine to avoid blocking command completion - go func() { - // Wait a bit for pod to be fully ready - time.Sleep(2 * time.Second) - - if err := a.vncManager.CreateTunnel(sessionID); err != nil { - log.Printf("[VNCHandler] Failed to create VNC tunnel for session %s: %v", sessionID, err) - _ = a.sendVNCError(sessionID, err.Error()) - } - }() - - return nil -} diff --git a/agents/k8s-agent/agent_vnc_tunnel.go b/agents/k8s-agent/agent_vnc_tunnel.go deleted file mode 100644 index 05ba52a8..00000000 --- a/agents/k8s-agent/agent_vnc_tunnel.go +++ /dev/null @@ -1,396 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/base64" - "fmt" - "io" - "log" - "net" - "net/http" - "sync" - "time" - - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" - "k8s.io/client-go/tools/portforward" - "k8s.io/client-go/transport/spdy" -) - -// VNCTunnelManager manages VNC tunnels for sessions. -// -// Each VNC tunnel consists of: -// - Port-forward from agent to pod's VNC port (5900 or 3000) -// - Data relay between port-forward and WebSocket -// - Connection lifecycle management -// -// Multiple tunnels can run concurrently, one per session. -type VNCTunnelManager struct { - // kubeClient is the Kubernetes API client - kubeClient *kubernetes.Clientset - - // config is the REST config for port-forward - restConfig *rest.Config - - // namespace is the Kubernetes namespace for sessions - namespace string - - // tunnels maps sessionID -> active tunnel - tunnels map[string]*VNCTunnel - mutex sync.RWMutex - - // agent is the parent K8s agent (for sending VNC messages) - agent *K8sAgent -} - -// VNCTunnel represents a single VNC tunnel to a pod. -// -// The tunnel consists of a Kubernetes port-forward and data relay. -type VNCTunnel struct { - // sessionID identifies the session - sessionID string - - // podName is the name of the pod - podName string - - // vncPort is the pod's VNC port (5900 or 3000) - vncPort int - - // localPort is the locally forwarded port - localPort int - - // conn is the local connection to the forwarded port - conn net.Conn - - // stopChan signals the tunnel to stop - stopChan chan struct{} - - // readyChan signals when the tunnel is ready - readyChan chan bool - - // portForwarder is the Kubernetes port-forward - portForwarder *portforward.PortForwarder -} - -// NewVNCTunnelManager creates a new VNC tunnel manager. -func NewVNCTunnelManager(kubeClient *kubernetes.Clientset, restConfig *rest.Config, namespace string, agent *K8sAgent) *VNCTunnelManager { - return &VNCTunnelManager{ - kubeClient: kubeClient, - restConfig: restConfig, - namespace: namespace, - tunnels: make(map[string]*VNCTunnel), - agent: agent, - } -} - -// CreateTunnel creates a VNC tunnel to a session's pod. -// -// Steps: -// 1. Find the pod for the session -// 2. Create port-forward to pod's VNC port -// 3. Wait for port-forward to be ready -// 4. Connect to local forwarded port -// 5. Start data relay goroutine -// 6. Notify Control Plane that VNC is ready -func (m *VNCTunnelManager) CreateTunnel(sessionID string) error { - log.Printf("[VNCTunnel] Creating tunnel for session: %s", sessionID) - - // Check if tunnel already exists - m.mutex.Lock() - if _, exists := m.tunnels[sessionID]; exists { - m.mutex.Unlock() - return fmt.Errorf("tunnel already exists for session %s", sessionID) - } - m.mutex.Unlock() - - // Find the pod for this session - podName, vncPort, err := m.findSessionPod(sessionID) - if err != nil { - return fmt.Errorf("failed to find pod: %w", err) - } - - log.Printf("[VNCTunnel] Found pod %s with VNC port %d", podName, vncPort) - - // Create tunnel - tunnel := &VNCTunnel{ - sessionID: sessionID, - podName: podName, - vncPort: vncPort, - stopChan: make(chan struct{}), - readyChan: make(chan bool, 1), - } - - // Start port-forward - if err := m.startPortForward(tunnel); err != nil { - return fmt.Errorf("failed to start port-forward: %w", err) - } - - // Wait for port-forward to be ready (with timeout) - select { - case <-tunnel.readyChan: - log.Printf("[VNCTunnel] Port-forward ready for session %s", sessionID) - case <-time.After(30 * time.Second): - close(tunnel.stopChan) - return fmt.Errorf("timeout waiting for port-forward") - } - - // Connect to local forwarded port - if err := m.connectToForwardedPort(tunnel); err != nil { - close(tunnel.stopChan) - return fmt.Errorf("failed to connect to forwarded port: %w", err) - } - - // Store tunnel - m.mutex.Lock() - m.tunnels[sessionID] = tunnel - m.mutex.Unlock() - - // Start data relay - go m.relayData(tunnel) - - // Notify Control Plane that VNC is ready - _ = m.agent.sendVNCReady(sessionID, vncPort, podName) - - log.Printf("[VNCTunnel] Tunnel created successfully for session %s (local port: %d)", sessionID, tunnel.localPort) - return nil -} - -// CloseTunnel closes a VNC tunnel. -func (m *VNCTunnelManager) CloseTunnel(sessionID string) error { - m.mutex.Lock() - tunnel, exists := m.tunnels[sessionID] - if !exists { - m.mutex.Unlock() - return fmt.Errorf("tunnel not found for session %s", sessionID) - } - delete(m.tunnels, sessionID) - m.mutex.Unlock() - - log.Printf("[VNCTunnel] Closing tunnel for session %s", sessionID) - - // Stop port-forward - close(tunnel.stopChan) - - // Close connection - if tunnel.conn != nil { - _ = tunnel.conn.Close() - } - - log.Printf("[VNCTunnel] Tunnel closed for session %s", sessionID) - return nil -} - -// SendData sends VNC data from Control Plane to pod. -// -// The data is base64-decoded and written to the port-forward connection. -func (m *VNCTunnelManager) SendData(sessionID string, base64Data string) error { - m.mutex.RLock() - tunnel, exists := m.tunnels[sessionID] - m.mutex.RUnlock() - - if !exists { - return fmt.Errorf("tunnel not found for session %s", sessionID) - } - - // Decode base64 data - data, err := base64.StdEncoding.DecodeString(base64Data) - if err != nil { - return fmt.Errorf("failed to decode base64: %w", err) - } - - // Write to connection - if tunnel.conn == nil { - return fmt.Errorf("connection not established") - } - - _, err = tunnel.conn.Write(data) - if err != nil { - log.Printf("[VNCTunnel] Write error for session %s: %v", sessionID, err) - // Close tunnel on write error - go func() { _ = m.CloseTunnel(sessionID) }() - return err - } - - return nil -} - -// findSessionPod finds the pod name and VNC port for a session. -func (m *VNCTunnelManager) findSessionPod(sessionID string) (string, int, error) { - ctx := context.Background() - - // List pods with session label - pods, err := m.kubeClient.CoreV1().Pods(m.namespace).List(ctx, metav1.ListOptions{ - LabelSelector: fmt.Sprintf("session=%s", sessionID), - }) - if err != nil { - return "", 0, err - } - - if len(pods.Items) == 0 { - return "", 0, fmt.Errorf("no pod found for session %s", sessionID) - } - - pod := pods.Items[0] - - // Check if pod is running - if pod.Status.Phase != corev1.PodRunning { - return "", 0, fmt.Errorf("pod not running (phase: %s)", pod.Status.Phase) - } - - // Find VNC port (usually 3000 for LinuxServer.io images, or 5900 for standard VNC) - vncPort := 3000 // Default - for _, container := range pod.Spec.Containers { - for _, port := range container.Ports { - if port.Name == "vnc" { - vncPort = int(port.ContainerPort) - break - } - } - } - - return pod.Name, vncPort, nil -} - -// startPortForward starts a Kubernetes port-forward to the pod. -func (m *VNCTunnelManager) startPortForward(tunnel *VNCTunnel) error { - // Build URL for port-forward - req := m.kubeClient.CoreV1().RESTClient().Post(). - Resource("pods"). - Namespace(m.namespace). - Name(tunnel.podName). - SubResource("portforward") - - // Use a local ephemeral port (0 = auto-assign) - ports := []string{fmt.Sprintf("0:%d", tunnel.vncPort)} - - // Create SPDY transport - transport, upgrader, err := spdy.RoundTripperFor(m.restConfig) - if err != nil { - return err - } - - // Create port-forwarder - dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, "POST", req.URL()) - - stopChan := make(chan struct{}, 1) - readyChan := make(chan struct{}) - - outBuf := new(bytes.Buffer) - errBuf := new(bytes.Buffer) - - pf, err := portforward.New(dialer, ports, stopChan, readyChan, outBuf, errBuf) - if err != nil { - return err - } - - tunnel.portForwarder = pf - - // Start port-forward in goroutine - go func() { - if err := pf.ForwardPorts(); err != nil { - log.Printf("[VNCTunnel] Port-forward error for %s: %v", tunnel.sessionID, err) - log.Printf("[VNCTunnel] Stdout: %s", outBuf.String()) - log.Printf("[VNCTunnel] Stderr: %s", errBuf.String()) - } - }() - - // Wait for ready signal - go func() { - <-readyChan - - // Get the actual local port - forwardedPorts, err := pf.GetPorts() - if err != nil || len(forwardedPorts) == 0 { - log.Printf("[VNCTunnel] Failed to get forwarded ports: %v", err) - tunnel.readyChan <- false - return - } - - tunnel.localPort = int(forwardedPorts[0].Local) - log.Printf("[VNCTunnel] Port-forward established: localhost:%d -> %s:%d", - tunnel.localPort, tunnel.podName, tunnel.vncPort) - - tunnel.readyChan <- true - }() - - return nil -} - -// connectToForwardedPort connects to the locally forwarded port. -func (m *VNCTunnelManager) connectToForwardedPort(tunnel *VNCTunnel) error { - // Connect to localhost:localPort - conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", tunnel.localPort)) - if err != nil { - return err - } - - tunnel.conn = conn - log.Printf("[VNCTunnel] Connected to forwarded port %d", tunnel.localPort) - return nil -} - -// relayData relays data from pod to Control Plane. -// -// Reads from the port-forward connection and sends to Control Plane via WebSocket. -func (m *VNCTunnelManager) relayData(tunnel *VNCTunnel) { - defer func() { - log.Printf("[VNCTunnel] Data relay stopped for session %s", tunnel.sessionID) - _ = m.CloseTunnel(tunnel.sessionID) - }() - - buffer := make([]byte, 32*1024) // 32KB buffer - - for { - select { - case <-tunnel.stopChan: - return - - default: - // Set read deadline to allow checking stopChan - _ = tunnel.conn.SetReadDeadline(time.Now().Add(1 * time.Second)) - - n, err := tunnel.conn.Read(buffer) - if err != nil { - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - // Timeout is expected, continue - continue - } - if err != io.EOF { - log.Printf("[VNCTunnel] Read error for session %s: %v", tunnel.sessionID, err) - _ = m.agent.sendVNCError(tunnel.sessionID, err.Error()) - } - return - } - - if n > 0 { - // Base64-encode data for JSON transport - base64Data := base64.StdEncoding.EncodeToString(buffer[:n]) - - // Send to Control Plane - if err := m.agent.sendVNCData(tunnel.sessionID, base64Data); err != nil { - log.Printf("[VNCTunnel] Failed to send VNC data for session %s: %v", tunnel.sessionID, err) - return - } - } - } - } -} - -// CloseAll closes all active tunnels (for agent shutdown). -func (m *VNCTunnelManager) CloseAll() { - m.mutex.Lock() - sessionIDs := make([]string, 0, len(m.tunnels)) - for sessionID := range m.tunnels { - sessionIDs = append(sessionIDs, sessionID) - } - m.mutex.Unlock() - - for _, sessionID := range sessionIDs { - _ = m.CloseTunnel(sessionID) - } - - log.Println("[VNCTunnel] All tunnels closed") -} diff --git a/agents/k8s-agent/main.go b/agents/k8s-agent/main.go index c319d441..0291e419 100644 --- a/agents/k8s-agent/main.go +++ b/agents/k8s-agent/main.go @@ -73,9 +73,6 @@ type K8sAgent struct { // restConfig is the REST config for Kubernetes API (needed for port-forward) restConfig *rest.Config - // vncManager manages VNC tunnels for sessions - vncManager *VNCTunnelManager - // wsConn is the WebSocket connection to Control Plane wsConn *websocket.Conn @@ -122,9 +119,6 @@ func NewK8sAgent(config *config.AgentConfig) (*K8sAgent, error) { doneChan: make(chan struct{}), } - // Initialize VNC tunnel manager - agent.vncManager = NewVNCTunnelManager(kubeClient, restConfig, config.Namespace, agent) - // Initialize command handlers agent.initCommandHandlers() @@ -217,12 +211,6 @@ func (a *K8sAgent) WaitForShutdown() { // // FIX P0-AGENT-001: Properly closes write channel to prevent goroutine leaks. func (a *K8sAgent) shutdown() { - // Close all VNC tunnels - if a.vncManager != nil { - log.Println("[K8sAgent] Closing all VNC tunnels...") - a.vncManager.CloseAll() - } - // Close write channel to signal writePump to drain and exit // Note: stopChan was already closed by caller, so writePump will exit close(a.writeChan) @@ -749,7 +737,7 @@ func (a *K8sAgent) sendHeartbeat() error { // // This follows the agent protocol defined in api/internal/models/agent_protocol.go: // - Type: "status" (models.MessageTypeStatus) -// - Payload: StatusMessage with sessionId, state, vncReady, vncPort, platformMetadata +// - Payload: StatusMessage with sessionId, state, streamingReady, streamingPort, platformMetadata func (a *K8sAgent) sendSessionUpdate(sessionID, state, podName, podIP string) error { // Build platform metadata with pod information platformMetadata := map[string]interface{}{ @@ -763,8 +751,8 @@ func (a *K8sAgent) sendSessionUpdate(sessionID, state, podName, podIP string) er payload := map[string]interface{}{ "sessionId": sessionID, "state": state, - "vncReady": state == "running", // VNC is ready when session is running - "vncPort": 3000, // Standard VNC port in session pods + "streamingReady": state == "running", // Selkies endpoint is ready when session is running + "streamingPort": 8080, // Selkies-GStreamer default port in session pods "platformMetadata": platformMetadata, } @@ -774,7 +762,7 @@ func (a *K8sAgent) sendSessionUpdate(sessionID, state, podName, podIP string) er "payload": payload, } - log.Printf("[K8sAgent] Sending session status update: %s -> %s (pod: %s, vncReady: %v)", + log.Printf("[K8sAgent] Sending session status update: %s -> %s (pod: %s, streamingReady: %v)", sessionID, state, podName, state == "running") return a.sendMessage(update) } diff --git a/api/Dockerfile b/api/Dockerfile index 97f6eb86..be2d5e59 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -22,7 +22,6 @@ RUN go mod download # Copy source code COPY cmd/ cmd/ COPY internal/ internal/ -COPY static/ static/ # Tidy modules to ensure go.mod and go.sum are up to date RUN go mod tidy @@ -49,9 +48,6 @@ WORKDIR /app # Copy binary from builder COPY --from=builder /workspace/api-server . -# Copy static files for VNC viewer -COPY --from=builder /workspace/static ./static - # Create directory for repository clones RUN mkdir -p /tmp/streamspace-repos && chmod 777 /tmp/streamspace-repos diff --git a/api/cmd/main.go b/api/cmd/main.go index 64472735..4fff1d28 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -393,7 +393,6 @@ func main() { recordingHandler := handlers.NewRecordingHandler(database) agentHandler := handlers.NewAgentHandler(database, agentHub, commandDispatcher) agentWebSocketHandler := handlers.NewAgentWebSocketHandler(agentHub, database) - vncProxyHandler := handlers.NewVNCProxyHandler(database, agentHub) selkiesProxyHandler := handlers.NewSelkiesProxyHandler(database, agentHub, "streamspace") // SECURITY: Initialize webhook authentication @@ -404,7 +403,7 @@ func main() { } // Setup routes - setupRoutes(router, apiHandler, userHandler, groupHandler, authHandler, activityHandler, catalogHandler, sharingHandler, pluginHandler, dashboardHandler, sessionActivityHandler, apiKeyHandler, teamHandler, preferencesHandler, notificationsHandler, searchHandler, sessionTemplatesHandler, batchHandler, monitoringHandler, quotasHandler, nodeHandler, wsManager, consoleHandler, collaborationHandler, integrationsHandler, loadBalancingHandler, schedulingHandler, securityHandler, templateVersioningHandler, setupHandler, applicationHandler, auditHandler, configurationHandler, licenseHandler, recordingHandler, agentHandler, agentWebSocketHandler, vncProxyHandler, selkiesProxyHandler, jwtManager, userDB, database, redisCache, webhookSecret, rateLimitEnabled, rateLimitRPM) + setupRoutes(router, apiHandler, userHandler, groupHandler, authHandler, activityHandler, catalogHandler, sharingHandler, pluginHandler, dashboardHandler, sessionActivityHandler, apiKeyHandler, teamHandler, preferencesHandler, notificationsHandler, searchHandler, sessionTemplatesHandler, batchHandler, monitoringHandler, quotasHandler, nodeHandler, wsManager, consoleHandler, collaborationHandler, integrationsHandler, loadBalancingHandler, schedulingHandler, securityHandler, templateVersioningHandler, setupHandler, applicationHandler, auditHandler, configurationHandler, licenseHandler, recordingHandler, agentHandler, agentWebSocketHandler, selkiesProxyHandler, jwtManager, userDB, database, redisCache, webhookSecret, rateLimitEnabled, rateLimitRPM) // SECURITY: Configure mTLS for agent authentication (optional) var tlsConfig *tls.Config @@ -549,7 +548,7 @@ func main() { log.Println("Graceful shutdown completed") } -func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserHandler, groupHandler *handlers.GroupHandler, authHandler *auth.AuthHandler, activityHandler *handlers.ActivityHandler, catalogHandler *handlers.CatalogHandler, sharingHandler *handlers.SharingHandler, pluginHandler *handlers.PluginHandler, dashboardHandler *handlers.DashboardHandler, sessionActivityHandler *handlers.SessionActivityHandler, apiKeyHandler *handlers.APIKeyHandler, teamHandler *handlers.TeamHandler, preferencesHandler *handlers.PreferencesHandler, notificationsHandler *handlers.NotificationsHandler, searchHandler *handlers.SearchHandler, sessionTemplatesHandler *handlers.SessionTemplatesHandler, batchHandler *handlers.BatchHandler, monitoringHandler *handlers.MonitoringHandler, quotasHandler *handlers.QuotasHandler, nodeHandler *handlers.NodeHandler, wsManager *internalWebsocket.Manager, consoleHandler *handlers.ConsoleHandler, collaborationHandler *handlers.CollaborationHandler, integrationsHandler *handlers.IntegrationsHandler, loadBalancingHandler *handlers.LoadBalancingHandler, schedulingHandler *handlers.SchedulingHandler, securityHandler *handlers.SecurityHandler, templateVersioningHandler *handlers.TemplateVersioningHandler, setupHandler *handlers.SetupHandler, applicationHandler *handlers.ApplicationHandler, auditHandler *handlers.AuditHandler, configurationHandler *handlers.ConfigurationHandler, licenseHandler *handlers.LicenseHandler, recordingHandler *handlers.RecordingHandler, agentHandler *handlers.AgentHandler, agentWebSocketHandler *handlers.AgentWebSocketHandler, vncProxyHandler *handlers.VNCProxyHandler, selkiesProxyHandler *handlers.SelkiesProxyHandler, jwtManager *auth.JWTManager, userDB *db.UserDB, database *db.Database, redisCache *cache.Cache, webhookSecret string, rateLimitEnabled bool, rateLimitRPM int) { +func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserHandler, groupHandler *handlers.GroupHandler, authHandler *auth.AuthHandler, activityHandler *handlers.ActivityHandler, catalogHandler *handlers.CatalogHandler, sharingHandler *handlers.SharingHandler, pluginHandler *handlers.PluginHandler, dashboardHandler *handlers.DashboardHandler, sessionActivityHandler *handlers.SessionActivityHandler, apiKeyHandler *handlers.APIKeyHandler, teamHandler *handlers.TeamHandler, preferencesHandler *handlers.PreferencesHandler, notificationsHandler *handlers.NotificationsHandler, searchHandler *handlers.SearchHandler, sessionTemplatesHandler *handlers.SessionTemplatesHandler, batchHandler *handlers.BatchHandler, monitoringHandler *handlers.MonitoringHandler, quotasHandler *handlers.QuotasHandler, nodeHandler *handlers.NodeHandler, wsManager *internalWebsocket.Manager, consoleHandler *handlers.ConsoleHandler, collaborationHandler *handlers.CollaborationHandler, integrationsHandler *handlers.IntegrationsHandler, loadBalancingHandler *handlers.LoadBalancingHandler, schedulingHandler *handlers.SchedulingHandler, securityHandler *handlers.SecurityHandler, templateVersioningHandler *handlers.TemplateVersioningHandler, setupHandler *handlers.SetupHandler, applicationHandler *handlers.ApplicationHandler, auditHandler *handlers.AuditHandler, configurationHandler *handlers.ConfigurationHandler, licenseHandler *handlers.LicenseHandler, recordingHandler *handlers.RecordingHandler, agentHandler *handlers.AgentHandler, agentWebSocketHandler *handlers.AgentWebSocketHandler, selkiesProxyHandler *handlers.SelkiesProxyHandler, jwtManager *auth.JWTManager, userDB *db.UserDB, database *db.Database, redisCache *cache.Cache, webhookSecret string, rateLimitEnabled bool, rateLimitRPM int) { // SECURITY: Create authentication middleware authMiddleware := auth.Middleware(jwtManager, userDB) adminMiddleware := auth.RequireRole("admin") @@ -631,19 +630,10 @@ func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserH } - // VNC Proxy (v2.0 multi-platform architecture - authenticated users) - // Provides VNC WebSocket connections from UI to session desktops via agents - vncProxyHandler.RegisterRoutes(protected) - - // VNC Viewer (noVNC static HTML page) - // Serves the noVNC client that connects to the Control Plane VNC proxy - protected.GET("/vnc-viewer/:sessionId", func(c *gin.Context) { - c.File("./static/vnc-viewer.html") - }) - - // Selkies/HTTP Proxy (v2.0 multi-protocol - authenticated users) - // Provides HTTP/WebSocket proxy for Selkies, Guacamole, Kasm sessions - // Proxies requests from UI to session Services (in-cluster access) + // Selkies/HTTP Proxy (Selkies-only streaming pipeline) + // Provides HTTP/WebSocket proxy from UI to session Services (in-cluster access). + // VNC code path was removed; Selkies-GStreamer (WebRTC) is the only + // supported protocol. See ADR-008 for the historical proxy architecture. selkiesProxyHandler.RegisterRoutes(protected) // NOTE: Data Loss Prevention (DLP) is now handled by the streamspace-dlp plugin diff --git a/api/internal/auth/middleware.go b/api/internal/auth/middleware.go index 41f1704b..977c0007 100644 --- a/api/internal/auth/middleware.go +++ b/api/internal/auth/middleware.go @@ -163,24 +163,20 @@ func Middleware(jwtManager *JWTManager, userDB *db.UserDB) gin.HandlerFunc { var tokenString string - // Check if this is a VNC/HTTP proxy path (iframes can't send Authorization headers) + // Check if this is a streaming-proxy path (iframes can't send Authorization headers) path := c.Request.URL.Path - isVNCProxy := strings.HasPrefix(path, "/api/v1/http/") || - strings.HasPrefix(path, "/api/v1/vnc/") || - strings.HasPrefix(path, "/api/v1/vnc-viewer/") || - strings.HasPrefix(path, "/api/v1/websockify/") + isStreamProxy := strings.HasPrefix(path, "/api/v1/http/") - // For WebSocket connections or VNC proxy paths, try query parameter first + // For WebSocket connections or streaming-proxy paths, try query parameter first // (browsers can't send custom headers in iframes or WebSocket upgrades) - if isWebSocket || isVNCProxy { + if isWebSocket || isStreamProxy { tokenString = c.Query("token") // If token provided in query, set a session cookie for subsequent requests - // This allows asset/sub-resource requests (which don't include ?token) to authenticate + // (asset/sub-resource requests don't include ?token but need to authenticate). + // SameSite=Lax allows same-origin requests including iframes. Not HttpOnly so + // the cookie works in iframe context. if tokenString != "" { - // Set cookie for all /api/v1 paths (covers http, vnc, websockify) - // Using SameSite=Lax (default) which allows same-origin requests including iframes - // Note: Not using HttpOnly so the cookie works properly in iframe context c.SetCookie("streamspace_proxy_token", tokenString, 900, "/api/v1", "", false, false) } diff --git a/api/internal/db/sessions.go b/api/internal/db/sessions.go index 1b8775e5..83ae0d22 100644 --- a/api/internal/db/sessions.go +++ b/api/internal/db/sessions.go @@ -45,7 +45,7 @@ type Session struct { LastConnection *time.Time `json:"last_connection,omitempty"` LastDisconnect *time.Time `json:"last_disconnect,omitempty"` LastActivity *time.Time `json:"last_activity,omitempty"` - StreamingProtocol string `json:"streaming_protocol"` // vnc, selkies, guacamole, x2go, rdp + StreamingProtocol string `json:"streaming_protocol"` // selkies (only supported protocol) StreamingPort int `json:"streaming_port"` // Port for streaming service StreamingPath string `json:"streaming_path,omitempty"` // URL path for HTTP-based protocols } @@ -125,7 +125,7 @@ func (s *SessionDB) GetSession(ctx context.Context, sessionID string) (*Session, COALESCE(idle_timeout, ''), COALESCE(max_session_duration, ''), COALESCE(tags, ARRAY[]::TEXT[]), created_at, updated_at, last_connection, last_disconnect, last_activity, - COALESCE(streaming_protocol, 'vnc'), COALESCE(streaming_port, 5900), COALESCE(streaming_path, '') + COALESCE(streaming_protocol, 'selkies'), COALESCE(streaming_port, 8080), COALESCE(streaming_path, '') FROM sessions WHERE id = $1 ` @@ -162,7 +162,7 @@ func (s *SessionDB) GetSessionByOrg(ctx context.Context, sessionID, orgID string COALESCE(idle_timeout, ''), COALESCE(max_session_duration, ''), COALESCE(tags, ARRAY[]::TEXT[]), created_at, updated_at, last_connection, last_disconnect, last_activity, - COALESCE(streaming_protocol, 'vnc'), COALESCE(streaming_port, 5900), COALESCE(streaming_path, '') + COALESCE(streaming_protocol, 'selkies'), COALESCE(streaming_port, 8080), COALESCE(streaming_path, '') FROM sessions WHERE id = $1 AND org_id = $2 ` @@ -197,7 +197,7 @@ func (s *SessionDB) ListSessions(ctx context.Context) ([]*Session, error) { COALESCE(idle_timeout, ''), COALESCE(max_session_duration, ''), COALESCE(tags, ARRAY[]::TEXT[]), created_at, updated_at, last_connection, last_disconnect, last_activity, - COALESCE(streaming_protocol, 'vnc'), COALESCE(streaming_port, 5900), COALESCE(streaming_path, '') + COALESCE(streaming_protocol, 'selkies'), COALESCE(streaming_port, 8080), COALESCE(streaming_path, '') FROM sessions WHERE state != 'deleted' ORDER BY created_at DESC @@ -218,7 +218,7 @@ func (s *SessionDB) ListSessionsByOrg(ctx context.Context, orgID string) ([]*Ses COALESCE(idle_timeout, ''), COALESCE(max_session_duration, ''), COALESCE(tags, ARRAY[]::TEXT[]), created_at, updated_at, last_connection, last_disconnect, last_activity, - COALESCE(streaming_protocol, 'vnc'), COALESCE(streaming_port, 5900), COALESCE(streaming_path, '') + COALESCE(streaming_protocol, 'selkies'), COALESCE(streaming_port, 8080), COALESCE(streaming_path, '') FROM sessions WHERE org_id = $1 AND state != 'deleted' ORDER BY created_at DESC @@ -249,7 +249,7 @@ func (s *SessionDB) ListSessionsByUser(ctx context.Context, userID string) ([]*S COALESCE(idle_timeout, ''), COALESCE(max_session_duration, ''), COALESCE(tags, ARRAY[]::TEXT[]), created_at, updated_at, last_connection, last_disconnect, last_activity, - COALESCE(streaming_protocol, 'vnc'), COALESCE(streaming_port, 5900), COALESCE(streaming_path, '') + COALESCE(streaming_protocol, 'selkies'), COALESCE(streaming_port, 8080), COALESCE(streaming_path, '') FROM sessions WHERE user_id = $1 AND state != 'deleted' ORDER BY created_at DESC @@ -280,7 +280,7 @@ func (s *SessionDB) ListSessionsByUserAndOrg(ctx context.Context, userID, orgID COALESCE(idle_timeout, ''), COALESCE(max_session_duration, ''), COALESCE(tags, ARRAY[]::TEXT[]), created_at, updated_at, last_connection, last_disconnect, last_activity, - COALESCE(streaming_protocol, 'vnc'), COALESCE(streaming_port, 5900), COALESCE(streaming_path, '') + COALESCE(streaming_protocol, 'selkies'), COALESCE(streaming_port, 8080), COALESCE(streaming_path, '') FROM sessions WHERE user_id = $1 AND org_id = $2 AND state != 'deleted' ORDER BY created_at DESC @@ -310,7 +310,7 @@ func (s *SessionDB) ListSessionsByState(ctx context.Context, state string) ([]*S COALESCE(idle_timeout, ''), COALESCE(max_session_duration, ''), COALESCE(tags, ARRAY[]::TEXT[]), created_at, updated_at, last_connection, last_disconnect, last_activity, - COALESCE(streaming_protocol, 'vnc'), COALESCE(streaming_port, 5900), COALESCE(streaming_path, '') + COALESCE(streaming_protocol, 'selkies'), COALESCE(streaming_port, 8080), COALESCE(streaming_path, '') FROM sessions WHERE state = $1 ORDER BY created_at DESC @@ -341,7 +341,7 @@ func (s *SessionDB) ListSessionsByStateAndOrg(ctx context.Context, state, orgID COALESCE(idle_timeout, ''), COALESCE(max_session_duration, ''), COALESCE(tags, ARRAY[]::TEXT[]), created_at, updated_at, last_connection, last_disconnect, last_activity, - COALESCE(streaming_protocol, 'vnc'), COALESCE(streaming_port, 5900), COALESCE(streaming_path, '') + COALESCE(streaming_protocol, 'selkies'), COALESCE(streaming_port, 8080), COALESCE(streaming_path, '') FROM sessions WHERE state = $1 AND org_id = $2 ORDER BY created_at DESC diff --git a/api/internal/events/stub.go b/api/internal/events/stub.go index 68382b1d..10aadbff 100644 --- a/api/internal/events/stub.go +++ b/api/internal/events/stub.go @@ -48,10 +48,10 @@ type ResourceSpec struct { } type TemplateConfig struct { - Image string - VNCPort int - DisplayName string - Env map[string]string + Image string + StreamingPort int + DisplayName string + Env map[string]string } type SessionCreateEvent struct { diff --git a/api/internal/handlers/agent_websocket.go b/api/internal/handlers/agent_websocket.go index 0f387a45..dd862809 100644 --- a/api/internal/handlers/agent_websocket.go +++ b/api/internal/handlers/agent_websocket.go @@ -285,15 +285,6 @@ func (h *AgentWebSocketHandler) readPump(conn *wsocket.AgentConnection) { case models.MessageTypeStatus: h.handleStatus(conn, agentMsg) - case models.MessageTypeVNCReady, models.MessageTypeVNCData, models.MessageTypeVNCError: - // Forward VNC messages to Receive channel for VNC proxy - select { - case conn.Receive <- messageBytes: - // Message forwarded to VNC proxy - default: - log.Printf("[AgentWebSocket] VNC receive buffer full for agent %s", conn.AgentID) - } - default: log.Printf("[AgentWebSocket] Unknown message type from agent %s: %s", conn.AgentID, agentMsg.Type) } @@ -476,8 +467,8 @@ func (h *AgentWebSocketHandler) handleStatus(conn *wsocket.AgentConnection, msg } // Log the status update - log.Printf("[AgentWebSocket] Agent %s status update for session %s: state=%s, vncReady=%v, vncPort=%d", - conn.AgentID, status.SessionID, status.State, status.VNCReady, status.VNCPort) + log.Printf("[AgentWebSocket] Agent %s status update for session %s: state=%s, streamingReady=%v, streamingPort=%d", + conn.AgentID, status.SessionID, status.State, status.StreamingReady, status.StreamingPort) // Update session in database now := time.Now() @@ -490,12 +481,11 @@ func (h *AgentWebSocketHandler) handleStatus(conn *wsocket.AgentConnection, msg } } - // Construct VNC URL if VNC is ready - vncURL := "" - if status.VNCReady && status.VNCPort > 0 { - // VNC URL will be proxied through the API server - // Format: /api/v1/sessions/{sessionID}/vnc - vncURL = "/api/v1/sessions/" + status.SessionID + "/vnc" + // Construct streaming URL when the streaming endpoint is ready. + // The URL is proxied through the API server's Selkies HTTP proxy. + streamingURL := "" + if status.StreamingReady && status.StreamingPort > 0 { + streamingURL = "/api/v1/http/" + status.SessionID + "/" } query := ` @@ -504,12 +494,12 @@ func (h *AgentWebSocketHandler) handleStatus(conn *wsocket.AgentConnection, msg WHERE id = $5 ` - _, err := h.database.DB().Exec(query, status.State, podName, vncURL, now, status.SessionID) + _, err := h.database.DB().Exec(query, status.State, podName, streamingURL, now, status.SessionID) if err != nil { log.Printf("[AgentWebSocket] Failed to update session %s status: %v", status.SessionID, err) return } log.Printf("[AgentWebSocket] Updated session %s: state=%s, pod=%s, url=%s", - status.SessionID, status.State, podName, vncURL) + status.SessionID, status.State, podName, streamingURL) } diff --git a/api/internal/handlers/agent_websocket_test.go b/api/internal/handlers/agent_websocket_test.go index c931996c..80ddbc05 100644 --- a/api/internal/handlers/agent_websocket_test.go +++ b/api/internal/handlers/agent_websocket_test.go @@ -487,10 +487,10 @@ func TestHandleStatus(t *testing.T) { // Create status message status := models.StatusMessage{ - SessionID: sessionID, - State: "running", - VNCReady: true, - VNCPort: 5900, + SessionID: sessionID, + State: "running", + StreamingReady: true, + StreamingPort: 8080, } statusBytes, _ := json.Marshal(status) diff --git a/api/internal/handlers/sessiontemplates.go b/api/internal/handlers/sessiontemplates.go index 16151e54..6584afe0 100644 --- a/api/internal/handlers/sessiontemplates.go +++ b/api/internal/handlers/sessiontemplates.go @@ -622,9 +622,12 @@ func (h *SessionTemplatesHandler) UseSessionTemplate(c *gin.Context) { // Add template configuration for Docker controller if k8sTemplate != nil { - vncPort := 3000 // Default VNC port + // Selkies-GStreamer defaults to port 8080. Templates still carry a + // legacy VNC.Port field for backwards-compat with old fixtures; honor + // it when set so existing custom templates keep working. + streamingPort := 8080 if k8sTemplate.VNC != nil && k8sTemplate.VNC.Port > 0 { - vncPort = int(k8sTemplate.VNC.Port) + streamingPort = int(k8sTemplate.VNC.Port) } // Convert env vars to map @@ -634,10 +637,10 @@ func (h *SessionTemplatesHandler) UseSessionTemplate(c *gin.Context) { } createEvent.TemplateConfig = &events.TemplateConfig{ - Image: k8sTemplate.BaseImage, - VNCPort: vncPort, - DisplayName: k8sTemplate.DisplayName, - Env: envMap, + Image: k8sTemplate.BaseImage, + StreamingPort: streamingPort, + DisplayName: k8sTemplate.DisplayName, + Env: envMap, } } diff --git a/api/internal/handlers/vnc_proxy.go b/api/internal/handlers/vnc_proxy.go deleted file mode 100644 index bced9880..00000000 --- a/api/internal/handlers/vnc_proxy.go +++ /dev/null @@ -1,580 +0,0 @@ -// Package handlers provides HTTP request handlers for the StreamSpace API. -// -// This file implements the VNC proxy handler for v2.0 multi-platform architecture. -// -// VNC Traffic Flow (v2.0): -// UI Client → Control Plane VNC Proxy → Agent → Pod -// -// The VNC proxy: -// 1. Receives WebSocket connections from UI clients -// 2. Looks up which agent is hosting the session -// 3. Routes VNC traffic between UI and agent over WebSocket -// 4. Handles bidirectional VNC data relay -// -// Protocol: -// - UI sends/receives raw VNC binary data (base64-encoded) -// - Proxy wraps VNC data in agent protocol messages (vnc_data, vnc_close) -// - Agent unwraps and relays to/from pod via port-forward -// -// Security: -// - Requires valid JWT token -// - Verifies user has access to the session -// - Only one active VNC connection per session (prevents hijacking) -// -// Example: -// UI connects to: ws://control-plane/api/v1/vnc/sess-123?token= -// Proxy routes to: agent "k8s-prod-us-east-1" via WebSocket -// Agent tunnels to: pod "sess-123-abc" via port-forward -package handlers - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "sync" - "time" - - "github.com/gin-gonic/gin" - "github.com/gorilla/websocket" - "github.com/streamspace-dev/streamspace/api/internal/db" - "github.com/streamspace-dev/streamspace/api/internal/models" - ws "github.com/streamspace-dev/streamspace/api/internal/websocket" -) - -const ( - // VNC WebSocket timeout constants - // These are longer than agent timeouts since VNC sessions may be idle - vncPongWait = 120 * time.Second // Time to wait for pong from UI - vncPingPeriod = 54 * time.Second // Send pings to UI at this interval - vncWriteWait = 10 * time.Second // Time allowed to write a message - vncActivityHeartbeat = 30 * time.Second // Update last_activity every 30 seconds -) - -// VNCProxyHandler manages VNC WebSocket connections from UI clients. -// -// It proxies VNC traffic between UI clients and platform agents, enabling -// remote access to session desktops through the Control Plane. -type VNCProxyHandler struct { - // db is the database connection - db *db.Database - - // agentHub manages agent WebSocket connections - agentHub *ws.AgentHub - - // upgrader upgrades HTTP connections to WebSocket - upgrader websocket.Upgrader - - // activeConnections tracks active VNC connections (sessionID -> client conn) - activeConnections map[string]*websocket.Conn - connMutex sync.RWMutex -} - -// NewVNCProxyHandler creates a new VNC proxy handler. -// -// Example: -// -// handler := NewVNCProxyHandler(database, agentHub) -// router.GET("/vnc/:sessionId", handler.HandleVNCConnection) -func NewVNCProxyHandler(database *db.Database, agentHub *ws.AgentHub) *VNCProxyHandler { - return &VNCProxyHandler{ - db: database, - agentHub: agentHub, - activeConnections: make(map[string]*websocket.Conn), - upgrader: websocket.Upgrader{ - ReadBufferSize: 32 * 1024, // 32KB for VNC data - WriteBufferSize: 32 * 1024, - CheckOrigin: func(r *http.Request) bool { - // TODO: Implement proper CORS validation - return true - }, - }, - } -} - -// HandleVNCConnection handles VNC WebSocket connections from UI clients. -// -// Endpoint: GET /api/v1/vnc/:sessionId -// -// Query Parameters: -// - token: JWT authentication token (required) -// -// Flow: -// 1. Authenticate user via JWT -// 2. Verify user has access to session -// 3. Look up agent hosting the session -// 4. Verify agent is connected -// 5. Upgrade HTTP to WebSocket -// 6. Proxy VNC traffic bidirectionally -// -// Example: -// -// ws://control-plane/api/v1/vnc/sess-123?token=eyJhbGc... -func (h *VNCProxyHandler) HandleVNCConnection(c *gin.Context) { - sessionID := c.Param("sessionId") - if sessionID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "sessionId is required"}) - return - } - - // Get user from JWT (set by auth middleware) - // FIX: Auth middleware sets "userID" not "user_id" - userIDInterface, exists := c.Get("userID") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - userID := userIDInterface.(string) - - // Look up session in database (including streaming protocol metadata) - var agentID string - var sessionState string - var sessionOwner string - var streamingProtocol string - var streamingPort int - var streamingPath string - err := h.db.DB().QueryRow(` - SELECT agent_id, state, user_id, - COALESCE(streaming_protocol, 'vnc'), - COALESCE(streaming_port, 5900), - COALESCE(streaming_path, '') - FROM sessions - WHERE id = $1 - `, sessionID).Scan(&agentID, &sessionState, &sessionOwner, &streamingProtocol, &streamingPort, &streamingPath) - - if err != nil { - log.Printf("[VNCProxy] Session %s not found: %v", sessionID, err) - c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) - return - } - - log.Printf("[VNCProxy] Session %s uses streaming protocol: %s (port: %d, path: %s)", - sessionID, streamingProtocol, streamingPort, streamingPath) - - // Verify user has access to session - if sessionOwner != userID { - // TODO: Check if user is admin or has shared access - log.Printf("[VNCProxy] User %s denied access to session %s (owner: %s)", userID, sessionID, sessionOwner) - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) - return - } - - // Verify session is running - if sessionState != "running" { - log.Printf("[VNCProxy] Session %s is not running (state: %s)", sessionID, sessionState) - c.JSON(http.StatusConflict, gin.H{ - "error": fmt.Sprintf("Session is not running (state: %s)", sessionState), - }) - return - } - - // Verify agent_id is set - if agentID == "" { - log.Printf("[VNCProxy] Session %s has no agent assigned", sessionID) - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Session has no agent assigned"}) - return - } - - // Verify agent is connected - if !h.agentHub.IsAgentConnected(agentID) { - log.Printf("[VNCProxy] Agent %s is not connected", agentID) - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": fmt.Sprintf("Agent %s is not connected", agentID), - }) - return - } - - // Route to appropriate proxy handler based on streaming protocol - // VNC: Use WebSocket-based VNC proxy (current implementation) - // Selkies/HTTP-based: Return session info for direct HTTP access - if streamingProtocol == "selkies" || streamingProtocol == "guacamole" || streamingProtocol == "kasm" { - // HTTP-based streaming protocols (Selkies, Kasm, Guacamole, etc.) - log.Printf("[VNCProxy] Session %s uses HTTP-based protocol (%s), returning session access info", - sessionID, streamingProtocol) - - // For HTTP-based protocols, the UI should access the pod directly via the session URL - // The agent exposes the pod's HTTP port, and the URL field contains the access URL - // - // FUTURE: Implement HTTP/WebSocket proxy for additional security/isolation - // For now, direct access is simpler and works with any HTTP-based streaming protocol - - // Fetch session URL from database - var sessionURL string - err := h.db.DB().QueryRow(`SELECT COALESCE(url, '') FROM sessions WHERE id = $1`, sessionID).Scan(&sessionURL) - if err != nil || sessionURL == "" { - log.Printf("[VNCProxy] Session %s has no URL set (agent may still be starting pod)", sessionID) - c.JSON(http.StatusAccepted, gin.H{ - "error": "Session URL not yet available", - "message": "The agent is still starting the session. Please wait and try again.", - "session_id": sessionID, - "protocol": streamingProtocol, - "retry_after": 5, // Suggest retry after 5 seconds - }) - return - } - - // Return session access information for HTTP-based protocol - c.JSON(http.StatusOK, gin.H{ - "type": "http_session", - "session_id": sessionID, - "protocol": streamingProtocol, - "url": sessionURL, - "port": streamingPort, - "path": streamingPath, - "message": "Access this session via the provided URL", - }) - return - } - - // VNC protocol: Continue with existing VNC WebSocket proxy - log.Printf("[VNCProxy] Session %s uses VNC protocol, using WebSocket proxy", sessionID) - - // Check for existing VNC connection - h.connMutex.RLock() - if existingConn, exists := h.activeConnections[sessionID]; exists { - h.connMutex.RUnlock() - // Close existing connection (only one VNC connection allowed per session) - log.Printf("[VNCProxy] Closing existing VNC connection for session %s", sessionID) - existingConn.Close() - h.connMutex.Lock() - delete(h.activeConnections, sessionID) - h.connMutex.Unlock() - } else { - h.connMutex.RUnlock() - } - - // Upgrade HTTP connection to WebSocket - wsConn, err := h.upgrader.Upgrade(c.Writer, c.Request, nil) - if err != nil { - log.Printf("[VNCProxy] Failed to upgrade connection: %v", err) - return - } - - // Register active connection - h.connMutex.Lock() - h.activeConnections[sessionID] = wsConn - h.connMutex.Unlock() - - log.Printf("[VNCProxy] VNC connection established for session %s (agent: %s, user: %s)", - sessionID, agentID, userID) - - // Update session active_connections count and last_activity (Issue #239) - now := time.Now() - _, _ = h.db.DB().Exec(` - UPDATE sessions - SET active_connections = active_connections + 1, - last_connection = $1, - last_activity = $1 - WHERE id = $2 - `, now, sessionID) - - log.Printf("[VNCProxy] Updated last_activity for session %s on connect", sessionID) - - // Start bidirectional VNC data relay - go h.relayVNCData(sessionID, agentID, wsConn) -} - -// relayVNCData relays VNC data bidirectionally between UI and agent. -// -// Flow: -// - UI → Proxy: Read VNC data from UI WebSocket -// - Proxy → Agent: Send vnc_data message to agent -// - Agent → Proxy: Receive vnc_data message from agent -// - Proxy → UI: Write VNC data to UI WebSocket -// -// FIX: Added ping/pong keep-alive to prevent timeout during idle VNC sessions -func (h *VNCProxyHandler) relayVNCData(sessionID string, agentID string, uiConn *websocket.Conn) { - defer func() { - // Cleanup on disconnect - uiConn.Close() - - h.connMutex.Lock() - delete(h.activeConnections, sessionID) - h.connMutex.Unlock() - - // Update session active_connections count - _, _ = h.db.DB().Exec(` - UPDATE sessions - SET active_connections = active_connections - 1, - last_disconnect = $1 - WHERE id = $2 AND active_connections > 0 - `, time.Now(), sessionID) - - // Send vnc_close to agent - _ = h.sendVNCCloseToAgent(agentID, sessionID, "client_disconnect") - - log.Printf("[VNCProxy] VNC connection closed for session %s", sessionID) - }() - - // FIX: Set up ping/pong handlers to keep connection alive during idle VNC sessions - // Set initial read deadline - _ = uiConn.SetReadDeadline(time.Now().Add(vncPongWait)) - - // Handle pong messages from UI (extends read deadline) - uiConn.SetPongHandler(func(string) error { - _ = uiConn.SetReadDeadline(time.Now().Add(vncPongWait)) - return nil - }) - - // Handle ping messages from UI (respond with pong) - uiConn.SetPingHandler(func(appData string) error { - _ = uiConn.SetReadDeadline(time.Now().Add(vncPongWait)) - _ = uiConn.SetWriteDeadline(time.Now().Add(vncWriteWait)) - if err := uiConn.WriteMessage(websocket.PongMessage, []byte(appData)); err != nil { - return err - } - return nil - }) - - // Get agent connection to receive vnc_data messages - agentConn := h.agentHub.GetConnection(agentID) - if agentConn == nil { - log.Printf("[VNCProxy] Agent %s connection lost", agentID) - return - } - - // Channel to signal goroutine termination - stopChan := make(chan struct{}) - defer close(stopChan) - - // FIX: Goroutine 3: Send periodic pings to UI to keep connection alive - go func() { - ticker := time.NewTicker(vncPingPeriod) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - _ = uiConn.SetWriteDeadline(time.Now().Add(vncWriteWait)) - if err := uiConn.WriteMessage(websocket.PingMessage, nil); err != nil { - log.Printf("[VNCProxy] Ping error for session %s: %v", sessionID, err) - stopChan <- struct{}{} - return - } - case <-stopChan: - return - } - } - }() - - // Issue #239: Goroutine 4: Update last_activity every 30 seconds during active VNC connection - go func() { - ticker := time.NewTicker(vncActivityHeartbeat) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - if err := h.updateSessionActivity(sessionID); err != nil { - log.Printf("[VNCProxy] Activity update error for session %s: %v", sessionID, err) - } - case <-stopChan: - return - } - } - }() - - // Goroutine 1: UI → Agent (read from UI, send to agent) - go func() { - for { - select { - case <-stopChan: - return - default: - // Read VNC data from UI - messageType, data, err := uiConn.ReadMessage() - if err != nil { - log.Printf("[VNCProxy] Error reading from UI: %v", err) - stopChan <- struct{}{} - return - } - - // FIX: Reset read deadline on successful read (activity detected) - _ = uiConn.SetReadDeadline(time.Now().Add(vncPongWait)) - - // Only process binary or text messages - if messageType != websocket.BinaryMessage && messageType != websocket.TextMessage { - continue - } - - // Send vnc_data to agent - if err := h.sendVNCDataToAgent(agentID, sessionID, data); err != nil { - log.Printf("[VNCProxy] Error sending to agent: %v", err) - stopChan <- struct{}{} - return - } - } - } - }() - - // Goroutine 2: Agent → UI (read from agent Receive channel, send to UI) - for { - select { - case <-stopChan: - return - case msgBytes, ok := <-agentConn.Receive: - if !ok { - log.Printf("[VNCProxy] Agent %s receive channel closed", agentID) - return - } - - // Parse agent message - var agentMsg models.AgentMessage - if err := json.Unmarshal(msgBytes, &agentMsg); err != nil { - log.Printf("[VNCProxy] Failed to parse agent message: %v", err) - continue - } - - // Only process vnc_data messages for this session - if agentMsg.Type == models.MessageTypeVNCData { - var vncData models.VNCDataMessage - if err := json.Unmarshal(agentMsg.Payload, &vncData); err != nil { - log.Printf("[VNCProxy] Failed to parse vnc_data: %v", err) - continue - } - - // Only relay if it's for this session - if vncData.SessionID == sessionID { - // Decode base64 VNC data - // Actually, for WebSocket we can send base64 directly - // The UI will decode it, or we send binary - // For simplicity, send the base64 string as text - if err := uiConn.WriteMessage(websocket.TextMessage, []byte(vncData.Data)); err != nil { - log.Printf("[VNCProxy] Error writing to UI: %v", err) - return - } - } - } else if agentMsg.Type == models.MessageTypeVNCError { - // VNC tunnel error from agent - var vncError models.VNCErrorMessage - if err := json.Unmarshal(agentMsg.Payload, &vncError); err == nil { - if vncError.SessionID == sessionID { - log.Printf("[VNCProxy] VNC error from agent: %s", vncError.Error) - // Close UI connection - return - } - } - } - } - } -} - -// sendVNCDataToAgent sends VNC data to the agent. -func (h *VNCProxyHandler) sendVNCDataToAgent(agentID, sessionID string, data []byte) error { - // Base64-encode the data for JSON transport - // Actually, if data is already base64 from UI, we can use it directly - // For now, assume we receive raw binary and need to encode - // base64Data := base64.StdEncoding.EncodeToString(data) - - // Create vnc_data message - vncData := models.VNCDataMessage{ - SessionID: sessionID, - Data: string(data), // Assuming UI sends base64-encoded data - } - - vncDataBytes, err := json.Marshal(vncData) - if err != nil { - return fmt.Errorf("failed to marshal vnc_data: %w", err) - } - - agentMsg := models.AgentMessage{ - Type: models.MessageTypeVNCData, - Timestamp: time.Now(), - Payload: vncDataBytes, - } - - msgBytes, err := json.Marshal(agentMsg) - if err != nil { - return fmt.Errorf("failed to marshal agent message: %w", err) - } - - // Send to agent via AgentHub - agentConn := h.agentHub.GetConnection(agentID) - if agentConn == nil { - return fmt.Errorf("agent %s not connected", agentID) - } - - select { - case agentConn.Send <- msgBytes: - return nil - default: - return fmt.Errorf("agent %s send buffer full", agentID) - } -} - -// sendVNCCloseToAgent sends a vnc_close message to the agent. -func (h *VNCProxyHandler) sendVNCCloseToAgent(agentID, sessionID, reason string) error { - closeMsg := models.VNCCloseMessage{ - SessionID: sessionID, - Reason: reason, - } - - closeMsgBytes, err := json.Marshal(closeMsg) - if err != nil { - return fmt.Errorf("failed to marshal vnc_close: %w", err) - } - - agentMsg := models.AgentMessage{ - Type: models.MessageTypeVNCClose, - Timestamp: time.Now(), - Payload: closeMsgBytes, - } - - msgBytes, err := json.Marshal(agentMsg) - if err != nil { - return fmt.Errorf("failed to marshal agent message: %w", err) - } - - // Send to agent via AgentHub - agentConn := h.agentHub.GetConnection(agentID) - if agentConn == nil { - return fmt.Errorf("agent %s not connected", agentID) - } - - select { - case agentConn.Send <- msgBytes: - log.Printf("[VNCProxy] Sent vnc_close to agent %s for session %s", agentID, sessionID) - return nil - default: - return fmt.Errorf("agent %s send buffer full", agentID) - } -} - -// RegisterRoutes registers the VNC proxy routes. -// -// Routes: -// - GET /vnc/:sessionId - VNC WebSocket connection -// -// Example: -// -// vncProxyHandler.RegisterRoutes(router) -func (h *VNCProxyHandler) RegisterRoutes(router *gin.RouterGroup) { - router.GET("/vnc/:sessionId", h.HandleVNCConnection) -} - -// GetActiveConnections returns the number of active VNC connections. -func (h *VNCProxyHandler) GetActiveConnections() int { - h.connMutex.RLock() - defer h.connMutex.RUnlock() - return len(h.activeConnections) -} - -// updateSessionActivity updates the last_activity timestamp for a session. -// This is called periodically during active VNC connections to track user activity. -// Issue #239: VNC Activity Tracking -func (h *VNCProxyHandler) updateSessionActivity(sessionID string) error { - result, err := h.db.DB().Exec(` - UPDATE sessions - SET last_activity = $1 - WHERE id = $2 - `, time.Now(), sessionID) - if err != nil { - return fmt.Errorf("failed to update last_activity: %w", err) - } - - rowsAffected, _ := result.RowsAffected() - if rowsAffected > 0 { - log.Printf("[VNCProxy] Updated last_activity for session %s (heartbeat)", sessionID) - } - return nil -} diff --git a/api/internal/handlers/vnc_proxy_test.go b/api/internal/handlers/vnc_proxy_test.go deleted file mode 100644 index 076d782e..00000000 --- a/api/internal/handlers/vnc_proxy_test.go +++ /dev/null @@ -1,599 +0,0 @@ -// Package handlers provides HTTP request handlers for the StreamSpace API. -// -// This file contains comprehensive tests for the VNC proxy handler (v2.0 multi-platform architecture). -// -// Test Coverage: -// - HandleVNCConnection validation logic (sessionId, auth, permissions, state) -// - Session lookup and access control -// - Agent connectivity verification -// - Existing connection handling -// - Error cases and edge conditions -// - GetActiveConnections counter -// -// Note: WebSocket relay logic (relayVNCData) requires integration tests with actual -// WebSocket connections and is tested separately in integration test suite. -package handlers - -import ( - "database/sql" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/gin-gonic/gin" - "github.com/gorilla/websocket" - "github.com/streamspace-dev/streamspace/api/internal/db" - ws "github.com/streamspace-dev/streamspace/api/internal/websocket" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// testAgentHub wraps a real AgentHub for testing -type testAgentHub struct { - *ws.AgentHub -} - -func newTestAgentHub(database *db.Database) *testAgentHub { - hub := ws.NewAgentHub(database) - return &testAgentHub{AgentHub: hub} -} - -func (h *testAgentHub) AddMockAgent(agentID string) { - conn := &ws.AgentConnection{ - AgentID: agentID, - Send: make(chan []byte, 256), - Receive: make(chan []byte, 256), - Conn: nil, // Not needed for these tests - } - _ = h.RegisterAgent(conn) - - // Give hub time to process registration (async operation) - // Poll until agent is connected or timeout - for i := 0; i < 10; i++ { - if h.IsAgentConnected(agentID) { - return - } - time.Sleep(10 * time.Millisecond) - } -} - -func (h *testAgentHub) RemoveMockAgent(agentID string) { - h.UnregisterAgent(agentID) -} - -// setupVNCProxyTest creates a test setup with mock database and agent hub -func setupVNCProxyTest(t *testing.T) (*VNCProxyHandler, sqlmock.Sqlmock, *testAgentHub, func()) { - // Create mock database - mockDB, mock, err := sqlmock.New() - require.NoError(t, err, "Failed to create mock database") - - database := db.NewDatabaseForTesting(mockDB) - - // Create test agent hub (uses real AgentHub internally) - hub := newTestAgentHub(database) - - // Start the hub (required for it to function) - go hub.Run() - - // Create handler - handler := NewVNCProxyHandler(database, hub.AgentHub) - - // Cleanup function - cleanup := func() { - hub.Stop() - mockDB.Close() - } - - return handler, mock, hub, cleanup -} - -// createTestContext creates a Gin test context with optional userID -func createTestContext(sessionID string, userID string) (*gin.Context, *httptest.ResponseRecorder) { - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest("GET", fmt.Sprintf("/api/v1/vnc/%s", sessionID), nil) - c.Params = []gin.Param{{Key: "sessionId", Value: sessionID}} - - if userID != "" { - // Handler expects "userID" (camelCase) from auth middleware - c.Set("userID", userID) - } - - return c, w -} - -// TestNewVNCProxyHandler tests handler creation -func TestNewVNCProxyHandler(t *testing.T) { - mockDB, _, err := sqlmock.New() - require.NoError(t, err) - defer mockDB.Close() - - database := db.NewDatabaseForTesting(mockDB) - hub := ws.NewAgentHub(database) - - handler := NewVNCProxyHandler(database, hub) - - assert.NotNil(t, handler, "Handler should not be nil") - assert.NotNil(t, handler.db, "Database should be set") - assert.NotNil(t, handler.agentHub, "AgentHub should be set") - assert.NotNil(t, handler.activeConnections, "Active connections map should be initialized") - assert.Equal(t, 32*1024, handler.upgrader.ReadBufferSize, "Read buffer should be 32KB") - assert.Equal(t, 32*1024, handler.upgrader.WriteBufferSize, "Write buffer should be 32KB") -} - -// TestHandleVNCConnection_MissingSessionID tests missing sessionId parameter -func TestHandleVNCConnection_MissingSessionID(t *testing.T) { - handler, _, _, cleanup := setupVNCProxyTest(t) - defer cleanup() - - c, w := createTestContext("", "user123") - - handler.HandleVNCConnection(c) - - assert.Equal(t, http.StatusBadRequest, w.Code, "Should return 400 for missing sessionId") - - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err, "Response should be valid JSON") - assert.Contains(t, response["error"], "sessionId is required", "Error message should mention sessionId") -} - -// TestHandleVNCConnection_Unauthorized tests missing JWT authentication -func TestHandleVNCConnection_Unauthorized(t *testing.T) { - handler, _, _, cleanup := setupVNCProxyTest(t) - defer cleanup() - - // Create context without user_id (no JWT token) - c, w := createTestContext("sess-123", "") - - handler.HandleVNCConnection(c) - - assert.Equal(t, http.StatusUnauthorized, w.Code, "Should return 401 for missing authentication") - - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err, "Response should be valid JSON") - assert.Equal(t, "Unauthorized", response["error"], "Error message should be 'Unauthorized'") -} - -// TestHandleVNCConnection_SessionNotFound tests session not found in database -func TestHandleVNCConnection_SessionNotFound(t *testing.T) { - handler, mock, _, cleanup := setupVNCProxyTest(t) - defer cleanup() - - sessionID := "sess-nonexistent" - userID := "user123" - - // Mock database query to return no rows - mock.ExpectQuery(`SELECT agent_id, state, user_id, COALESCE.*FROM sessions.*WHERE id = \$1`). - WithArgs(sessionID). - WillReturnError(sql.ErrNoRows) - - c, w := createTestContext(sessionID, userID) - - handler.HandleVNCConnection(c) - - assert.Equal(t, http.StatusNotFound, w.Code, "Should return 404 for session not found") - - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err, "Response should be valid JSON") - assert.Equal(t, "Session not found", response["error"], "Error message should mention session not found") - - assert.NoError(t, mock.ExpectationsWereMet(), "All database expectations should be met") -} - -// TestHandleVNCConnection_DatabaseError tests database query failure -func TestHandleVNCConnection_DatabaseError(t *testing.T) { - handler, mock, _, cleanup := setupVNCProxyTest(t) - defer cleanup() - - sessionID := "sess-123" - userID := "user123" - - // Mock database query to return error - mock.ExpectQuery(`SELECT agent_id, state, user_id, COALESCE.*FROM sessions.*WHERE id = \$1`). - WithArgs(sessionID). - WillReturnError(fmt.Errorf("database connection lost")) - - c, w := createTestContext(sessionID, userID) - - handler.HandleVNCConnection(c) - - assert.Equal(t, http.StatusNotFound, w.Code, "Should return 404 for database error") - - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err, "Response should be valid JSON") - assert.Equal(t, "Session not found", response["error"], "Error message should mention session not found") - - assert.NoError(t, mock.ExpectationsWereMet(), "All database expectations should be met") -} - -// TestHandleVNCConnection_AccessDenied tests access control -func TestHandleVNCConnection_AccessDenied(t *testing.T) { - handler, mock, _, cleanup := setupVNCProxyTest(t) - defer cleanup() - - sessionID := "sess-123" - userID := "user123" - sessionOwner := "user456" // Different user - - // Mock database query to return session owned by different user - rows := sqlmock.NewRows([]string{"agent_id", "state", "user_id", "streaming_protocol", "streaming_port", "streaming_path"}). - AddRow("agent-k8s-1", "running", sessionOwner, "vnc", 5900, "") - - mock.ExpectQuery(`SELECT agent_id, state, user_id, COALESCE.*FROM sessions.*WHERE id = \$1`). - WithArgs(sessionID). - WillReturnRows(rows) - - c, w := createTestContext(sessionID, userID) - - handler.HandleVNCConnection(c) - - assert.Equal(t, http.StatusForbidden, w.Code, "Should return 403 for access denied") - - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err, "Response should be valid JSON") - assert.Equal(t, "Access denied", response["error"], "Error message should be 'Access denied'") - - assert.NoError(t, mock.ExpectationsWereMet(), "All database expectations should be met") -} - -// TestHandleVNCConnection_SessionNotRunning tests non-running session states -func TestHandleVNCConnection_SessionNotRunning(t *testing.T) { - testCases := []struct { - name string - sessionState string - expectedMsg string - }{ - { - name: "hibernated session", - sessionState: "hibernated", - expectedMsg: "Session is not running (state: hibernated)", - }, - { - name: "terminated session", - sessionState: "terminated", - expectedMsg: "Session is not running (state: terminated)", - }, - { - name: "pending session", - sessionState: "pending", - expectedMsg: "Session is not running (state: pending)", - }, - { - name: "failed session", - sessionState: "failed", - expectedMsg: "Session is not running (state: failed)", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - handler, mock, _, cleanup := setupVNCProxyTest(t) - defer cleanup() - - sessionID := "sess-123" - userID := "user123" - - // Mock database query to return session in non-running state - rows := sqlmock.NewRows([]string{"agent_id", "state", "user_id", "streaming_protocol", "streaming_port", "streaming_path"}). - AddRow("agent-k8s-1", tc.sessionState, userID, "vnc", 5900, "") - - mock.ExpectQuery(`SELECT agent_id, state, user_id, COALESCE.*FROM sessions.*WHERE id = \$1`). - WithArgs(sessionID). - WillReturnRows(rows) - - c, w := createTestContext(sessionID, userID) - - handler.HandleVNCConnection(c) - - assert.Equal(t, http.StatusConflict, w.Code, "Should return 409 for non-running session") - - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err, "Response should be valid JSON") - assert.Equal(t, tc.expectedMsg, response["error"], "Error message should indicate session state") - - assert.NoError(t, mock.ExpectationsWereMet(), "All database expectations should be met") - }) - } -} - -// TestHandleVNCConnection_NoAgentAssigned tests session without agent -func TestHandleVNCConnection_NoAgentAssigned(t *testing.T) { - handler, mock, _, cleanup := setupVNCProxyTest(t) - defer cleanup() - - sessionID := "sess-123" - userID := "user123" - - // Mock database query to return session with no agent assigned (empty string) - rows := sqlmock.NewRows([]string{"agent_id", "state", "user_id", "streaming_protocol", "streaming_port", "streaming_path"}). - AddRow("", "running", userID, "vnc", 5900, "") - - mock.ExpectQuery(`SELECT agent_id, state, user_id, COALESCE.*FROM sessions.*WHERE id = \$1`). - WithArgs(sessionID). - WillReturnRows(rows) - - c, w := createTestContext(sessionID, userID) - - handler.HandleVNCConnection(c) - - assert.Equal(t, http.StatusServiceUnavailable, w.Code, "Should return 503 for no agent assigned") - - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err, "Response should be valid JSON") - assert.Equal(t, "Session has no agent assigned", response["error"], "Error message should mention no agent") - - assert.NoError(t, mock.ExpectationsWereMet(), "All database expectations should be met") -} - -// TestHandleVNCConnection_AgentNotConnected tests disconnected agent -func TestHandleVNCConnection_AgentNotConnected(t *testing.T) { - handler, mock, hub, cleanup := setupVNCProxyTest(t) - defer cleanup() - - sessionID := "sess-123" - userID := "user123" - agentID := "agent-k8s-1" - - // Mock database query to return session with agent - rows := sqlmock.NewRows([]string{"agent_id", "state", "user_id", "streaming_protocol", "streaming_port", "streaming_path"}). - AddRow(agentID, "running", userID, "vnc", 5900, "") - - mock.ExpectQuery(`SELECT agent_id, state, user_id, COALESCE.*FROM sessions.*WHERE id = \$1`). - WithArgs(sessionID). - WillReturnRows(rows) - - // Don't add agent to hub (agent not connected) - - c, w := createTestContext(sessionID, userID) - - handler.HandleVNCConnection(c) - - assert.Equal(t, http.StatusServiceUnavailable, w.Code, "Should return 503 for agent not connected") - - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err, "Response should be valid JSON") - assert.Contains(t, response["error"], "is not connected", "Error message should mention agent not connected") - assert.Contains(t, response["error"], agentID, "Error message should include agent ID") - - assert.NoError(t, mock.ExpectationsWereMet(), "All database expectations should be met") - - // Verify hub was queried - assert.False(t, hub.IsAgentConnected(agentID), "Agent should not be connected in hub") -} - -// TestHandleVNCConnection_ValidRequest_AgentConnected tests successful validation -// Note: This test requires integration testing with actual WebSocket connections -// Skipped in unit tests because: -// 1. RegisterAgent requires a non-nil WebSocket connection -// 2. WebSocket upgrade requires actual WebSocket handshake -// 3. This test is better suited for integration test suite -func TestHandleVNCConnection_ValidRequest_AgentConnected(t *testing.T) { - t.Skip("Requires integration test with real WebSocket connections - covered by all other validation tests") - - // All validation logic is tested separately: - // - TestHandleVNCConnection_MissingSessionID ✓ - // - TestHandleVNCConnection_Unauthorized ✓ - // - TestHandleVNCConnection_SessionNotFound ✓ - // - TestHandleVNCConnection_AccessDenied ✓ - // - TestHandleVNCConnection_SessionNotRunning ✓ - // - TestHandleVNCConnection_NoAgentAssigned ✓ - // - TestHandleVNCConnection_AgentNotConnected ✓ - // - // The only logic not tested here is the WebSocket upgrade and relay, - // which requires actual WebSocket connections in an integration test. -} - -// TestHandleVNCConnection_ExistingConnection tests closing existing connection logic -func TestHandleVNCConnection_ExistingConnection(t *testing.T) { - handler, _, hub, cleanup := setupVNCProxyTest(t) - defer cleanup() - - sessionID := "sess-123" - - // Create a mock existing WebSocket connection - existingConn := &websocket.Conn{} - handler.connMutex.Lock() - handler.activeConnections[sessionID] = existingConn - handler.connMutex.Unlock() - - // Verify existing connection is registered - assert.Equal(t, 1, handler.GetActiveConnections(), "Should have 1 active connection initially") - - // Simulate removing existing connection (what happens in HandleVNCConnection) - handler.connMutex.RLock() - if _, exists := handler.activeConnections[sessionID]; exists { - handler.connMutex.RUnlock() - // Note: In real code, Close() is called here, but we can't call it on a nil pointer - // The important part we're testing is the removal from the map - handler.connMutex.Lock() - delete(handler.activeConnections, sessionID) - handler.connMutex.Unlock() - } else { - handler.connMutex.RUnlock() - } - - // Verify existing connection was removed - handler.connMutex.RLock() - _, exists := handler.activeConnections[sessionID] - handler.connMutex.RUnlock() - assert.False(t, exists, "Existing connection should be removed") - - // Verify counter updated - assert.Equal(t, 0, handler.GetActiveConnections(), "Should have 0 connections after removal") - - // Note: Full integration test with actual WebSocket handshake and agent connection - // should be done in integration test suite, not unit tests. - _ = hub // Keep hub variable used -} - -// TestGetActiveConnections tests active connection counter -func TestGetActiveConnections(t *testing.T) { - handler, _, _, cleanup := setupVNCProxyTest(t) - defer cleanup() - - // Initially should be 0 - assert.Equal(t, 0, handler.GetActiveConnections(), "Should start with 0 connections") - - // Add mock connections - conn1 := &websocket.Conn{} - conn2 := &websocket.Conn{} - conn3 := &websocket.Conn{} - - handler.connMutex.Lock() - handler.activeConnections["sess-1"] = conn1 - handler.activeConnections["sess-2"] = conn2 - handler.activeConnections["sess-3"] = conn3 - handler.connMutex.Unlock() - - // Should return 3 - assert.Equal(t, 3, handler.GetActiveConnections(), "Should return correct connection count") - - // Remove one connection - handler.connMutex.Lock() - delete(handler.activeConnections, "sess-2") - handler.connMutex.Unlock() - - // Should return 2 - assert.Equal(t, 2, handler.GetActiveConnections(), "Should return updated connection count") - - // Clear all connections - handler.connMutex.Lock() - handler.activeConnections = make(map[string]*websocket.Conn) - handler.connMutex.Unlock() - - // Should return 0 - assert.Equal(t, 0, handler.GetActiveConnections(), "Should return 0 after clearing connections") -} - -// TestHandleVNCConnection_ConcurrentRequests tests thread safety -func TestHandleVNCConnection_ConcurrentRequests(t *testing.T) { - handler, _, _, cleanup := setupVNCProxyTest(t) - defer cleanup() - - // Test concurrent access to activeConnections map - // This tests thread safety of the map access, not the full WebSocket flow - numSessions := 10 - done := make(chan bool, numSessions) - - for i := 0; i < numSessions; i++ { - sessionID := fmt.Sprintf("sess-%d", i) - - go func(sid string) { - defer func() { done <- true }() - - // Simulate concurrent connection tracking - conn := &websocket.Conn{} - handler.connMutex.Lock() - handler.activeConnections[sid] = conn - handler.connMutex.Unlock() - - // Simulate reading - count := handler.GetActiveConnections() - _ = count - - // Simulate removal - handler.connMutex.Lock() - delete(handler.activeConnections, sid) - handler.connMutex.Unlock() - }(sessionID) - } - - // Wait for all goroutines - for i := 0; i < numSessions; i++ { - <-done - } - - // No panics = thread safety verified - assert.Equal(t, 0, handler.GetActiveConnections(), "Should have 0 connections after concurrent cleanup") -} - -// TestSendVNCDataToAgent tests sending VNC data to agent -// Note: Requires integration test with real agent connections -func TestSendVNCDataToAgent(t *testing.T) { - t.Skip("Requires integration test with real agent connections - logic verified by other tests") - - // The function sendVNCDataToAgent is tested indirectly through: - // - Error case: TestSendVNCDataToAgent_AgentNotConnected ✓ - // - Success case requires actual agent with WebSocket connection (integration test) -} - -// TestSendVNCDataToAgent_AgentNotConnected tests sending to disconnected agent -func TestSendVNCDataToAgent_AgentNotConnected(t *testing.T) { - handler, _, _, cleanup := setupVNCProxyTest(t) - defer cleanup() - - agentID := "agent-disconnected" - sessionID := "sess-123" - testData := []byte("test-data") - - // Don't add agent to hub (agent not connected) - - // Try to send VNC data - err := handler.sendVNCDataToAgent(agentID, sessionID, testData) - assert.Error(t, err, "Should return error for disconnected agent") - assert.Contains(t, err.Error(), "not connected", "Error should mention agent not connected") -} - -// TestSendVNCCloseToAgent tests sending close message to agent -// Note: Requires integration test with real agent connections -func TestSendVNCCloseToAgent(t *testing.T) { - t.Skip("Requires integration test with real agent connections - logic verified by other tests") - - // The function sendVNCCloseToAgent is tested indirectly through: - // - Error case: TestSendVNCCloseToAgent_AgentNotConnected ✓ - // - Success case requires actual agent with WebSocket connection (integration test) -} - -// TestSendVNCCloseToAgent_AgentNotConnected tests sending close to disconnected agent -func TestSendVNCCloseToAgent_AgentNotConnected(t *testing.T) { - handler, _, _, cleanup := setupVNCProxyTest(t) - defer cleanup() - - agentID := "agent-disconnected" - sessionID := "sess-123" - reason := "test_reason" - - // Don't add agent to hub - - // Try to send VNC close - err := handler.sendVNCCloseToAgent(agentID, sessionID, reason) - assert.Error(t, err, "Should return error for disconnected agent") - assert.Contains(t, err.Error(), "not connected", "Error should mention agent not connected") -} - -// TestVNCProxyRegisterRoutes tests route registration -func TestVNCProxyRegisterRoutes(t *testing.T) { - handler, _, _, cleanup := setupVNCProxyTest(t) - defer cleanup() - - gin.SetMode(gin.TestMode) - router := gin.New() - group := router.Group("/api/v1") - - handler.RegisterRoutes(group) - - // Verify route is registered - routes := router.Routes() - found := false - for _, route := range routes { - if route.Path == "/api/v1/vnc/:sessionId" && route.Method == "GET" { - found = true - break - } - } - - assert.True(t, found, "VNC proxy route should be registered") -} diff --git a/api/internal/middleware/securityheaders.go b/api/internal/middleware/securityheaders.go index 71ea6dc5..8993bae9 100644 --- a/api/internal/middleware/securityheaders.go +++ b/api/internal/middleware/securityheaders.go @@ -341,14 +341,11 @@ func SecurityHeaders() gin.HandlerFunc { c.Header("X-Content-Type-Options", "nosniff") // X-Frame-Options - // Prevents clickjacking attacks - // Allow SAMEORIGIN for VNC proxy paths (they need to be embedded in iframes) + // Prevents clickjacking attacks. Allow SAMEORIGIN for the streaming-proxy + // path because the Selkies UI is embedded in an iframe in SessionViewer. path := c.Request.URL.Path - isVNCProxy := strings.HasPrefix(path, "/api/v1/http/") || - strings.HasPrefix(path, "/api/v1/vnc/") || - strings.HasPrefix(path, "/api/v1/vnc-viewer/") || - strings.HasPrefix(path, "/api/v1/websockify/") - if isVNCProxy { + isStreamProxy := strings.HasPrefix(path, "/api/v1/http/") + if isStreamProxy { c.Header("X-Frame-Options", "SAMEORIGIN") } else { c.Header("X-Frame-Options", "DENY") @@ -360,14 +357,11 @@ func SecurityHeaders() gin.HandlerFunc { // Content-Security-Policy // IMPROVED: Uses nonce-based CSP to eliminate unsafe-inline and unsafe-eval - // This significantly improves XSS protection while maintaining functionality - // VNC/HTTP proxy paths need relaxed CSP because we're proxying third-party content - // (Selkies, Guacamole, etc.) which have their own inline scripts and styles + // for first-party content. The streaming-proxy path needs a relaxed CSP + // because we proxy content from trusted internal session pods (Selkies) + // whose inline scripts/styles we can't tag with nonces. var csp string - if isVNCProxy { - // Relaxed CSP for VNC/HTTP proxy paths - // These paths proxy content from trusted internal session pods (Selkies, etc.) - // The proxied content has its own scripts/styles that we can't add nonces to + if isStreamProxy { csp = "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; " + "style-src 'self' 'unsafe-inline'; " + diff --git a/api/internal/models/agent.go b/api/internal/models/agent.go index e9b4877d..bd9f9042 100644 --- a/api/internal/models/agent.go +++ b/api/internal/models/agent.go @@ -378,8 +378,8 @@ type AgentHeartbeatRequest struct { // { // "sessionId": "sess-456", // "state": "running", -// "vncReady": true, -// "vncPort": 5900, +// "streamingReady": true, +// "streamingPort": 8080, // "platformMetadata": { // "podName": "sess-456-abc123", // "nodeName": "worker-1" @@ -388,8 +388,8 @@ type AgentHeartbeatRequest struct { type AgentStatusUpdate struct { SessionID string `json:"sessionId" binding:"required"` State string `json:"state" binding:"required"` - VNCReady bool `json:"vncReady"` - VNCPort int `json:"vncPort,omitempty"` + StreamingReady bool `json:"streamingReady"` + StreamingPort int `json:"streamingPort,omitempty"` PlatformMetadata map[string]interface{} `json:"platformMetadata,omitempty"` } diff --git a/api/internal/models/agent_protocol.go b/api/internal/models/agent_protocol.go index c1f48b76..24282124 100644 --- a/api/internal/models/agent_protocol.go +++ b/api/internal/models/agent_protocol.go @@ -86,12 +86,6 @@ const ( // MessageTypeShutdown requests graceful agent shutdown MessageTypeShutdown = "shutdown" - - // MessageTypeVNCData carries VNC traffic from Control Plane to Agent - MessageTypeVNCData = "vnc_data" - - // MessageTypeVNCClose closes a VNC tunnel - MessageTypeVNCClose = "vnc_close" ) // Message types sent from Agent → Control Plane @@ -110,15 +104,6 @@ const ( // MessageTypeStatus reports session state changes MessageTypeStatus = "status" - - // MessageTypeVNCReady indicates VNC tunnel is ready - MessageTypeVNCReady = "vnc_ready" - - // MessageTypeVNCData carries VNC traffic from Agent to Control Plane - // (same name, direction determined by message flow) - - // MessageTypeVNCError reports VNC tunnel error - MessageTypeVNCError = "vnc_error" ) // CommandMessage is sent from Control Plane to Agent to execute a command. @@ -241,8 +226,8 @@ type FailedMessage struct { // { // "sessionId": "sess-456", // "state": "running", -// "vncReady": true, -// "vncPort": 5900, +// "streamingReady": true, +// "streamingPort": 8080, // "platformMetadata": { // "podName": "sess-456-abc123", // "nodeName": "worker-1" @@ -255,11 +240,12 @@ type StatusMessage struct { // State is the session state (pending, running, hibernated, terminated) State string `json:"state"` - // VNCReady indicates if VNC is ready for connections - VNCReady bool `json:"vncReady"` + // StreamingReady indicates if the Selkies streaming endpoint is ready. + StreamingReady bool `json:"streamingReady"` - // VNCPort is the local VNC port on the agent (for tunneling) - VNCPort int `json:"vncPort,omitempty"` + // StreamingPort is the streaming endpoint's port on the session pod + // (typically 8080 for Selkies-GStreamer). + StreamingPort int `json:"streamingPort,omitempty"` // PlatformMetadata contains platform-specific information PlatformMetadata map[string]interface{} `json:"platformMetadata,omitempty"` @@ -301,79 +287,3 @@ type ShutdownMessage struct { Reason string `json:"reason,omitempty"` } -// VNCDataMessage carries binary VNC traffic between Control Plane and Agent. -// -// VNC traffic is base64-encoded for transport over JSON WebSocket. -// The tunnelId identifies which VNC session this data belongs to. -// -// Example: -// -// { -// "sessionId": "sess-456", -// "data": "UkZCIDAwMy4wMDgK..." (base64-encoded VNC data) -// } -type VNCDataMessage struct { - // SessionID identifies which session this VNC data is for - SessionID string `json:"sessionId"` - - // Data is the base64-encoded VNC binary data - Data string `json:"data"` -} - -// VNCReadyMessage indicates a VNC tunnel is ready for connections. -// -// Sent from Agent to Control Plane when port-forward tunnel is established. -// -// Example: -// -// { -// "sessionId": "sess-456", -// "vncPort": 5900, -// "podName": "sess-456-abc123" -// } -type VNCReadyMessage struct { - // SessionID identifies which session has VNC ready - SessionID string `json:"sessionId"` - - // VNCPort is the local VNC port on the agent (typically 5900 or 3000) - VNCPort int `json:"vncPort"` - - // PodName is the name of the pod (K8s-specific metadata) - PodName string `json:"podName,omitempty"` -} - -// VNCCloseMessage requests closing a VNC tunnel. -// -// Sent from Control Plane to Agent when client disconnects. -// -// Example: -// -// { -// "sessionId": "sess-456", -// "reason": "client_disconnect" -// } -type VNCCloseMessage struct { - // SessionID identifies which session's VNC tunnel to close - SessionID string `json:"sessionId"` - - // Reason explains why the tunnel is being closed (optional) - Reason string `json:"reason,omitempty"` -} - -// VNCErrorMessage reports a VNC tunnel error. -// -// Sent from Agent to Control Plane when VNC tunnel fails. -// -// Example: -// -// { -// "sessionId": "sess-456", -// "error": "Port-forward failed: pod not found" -// } -type VNCErrorMessage struct { - // SessionID identifies which session had the error - SessionID string `json:"sessionId"` - - // Error describes what went wrong - Error string `json:"error"` -} diff --git a/api/static/vnc-viewer.html b/api/static/vnc-viewer.html deleted file mode 100644 index 021c418b..00000000 --- a/api/static/vnc-viewer.html +++ /dev/null @@ -1,238 +0,0 @@ - - - - - - StreamSpace VNC Viewer - - - - - - - - -
-
-
-
Connecting to session...
-
-
- - - - diff --git a/tests/scripts/phase1/test_1.1d_vnc_browser_access.sh b/tests/scripts/phase1/test_1.1d_vnc_browser_access.sh deleted file mode 100755 index e09b746e..00000000 --- a/tests/scripts/phase1/test_1.1d_vnc_browser_access.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash -# Test 1.1d: VNC Browser Access -# Objective: Verify VNC connection can be established and browser access works -# NOTE: This test requires manual verification of browser VNC display - -set -e - -echo "=== Test 1.1d: VNC Browser Access ===" -echo "" -echo "This test requires manual verification." -echo "" -echo "Steps:" -echo "1. Create a session" -echo "2. Open browser to session VNC URL" -echo "3. Verify desktop displays correctly" -echo "4. Verify mouse/keyboard work" -echo "" -echo "Manual test - see integration test plan for detailed procedure" -echo "" -exit 0 diff --git a/tests/scripts/phase4/test_4.3_vnc_latency.sh b/tests/scripts/phase4/test_4.3_vnc_latency.sh deleted file mode 100755 index 1d2e806b..00000000 --- a/tests/scripts/phase4/test_4.3_vnc_latency.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash -# Test 4.3: VNC Streaming Latency -# Objective: Measure VNC proxy latency -# NOTE: This test requires manual measurement tools - -set -e - -echo "=== Test 4.3: VNC Streaming Latency ===" -echo "" -echo "This test requires manual measurement with VNC latency tools." -echo "" -echo "Procedure:" -echo "1. Create a session and connect via browser" -echo "2. Use browser DevTools Network tab to measure WebSocket latency" -echo "3. Measure frame time in VNC stream" -echo "" -echo "Acceptance Criteria:" -echo " - WebSocket latency < 50ms (local)" -echo " - Frame delivery < 100ms" -echo " - Responsive mouse/keyboard (subjective)" -echo "" -echo "Manual test - see integration test plan for detailed procedure" -echo "" -exit 0 diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 8336dc26..726d24b7 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -45,9 +45,9 @@ export interface Session { agent_id?: string; // ID of the agent running this session platform?: string; // Platform type (kubernetes, docker, vm, cloud) region?: string; // Region where session is running - // Multi-protocol streaming support - streamingProtocol?: string; // Streaming protocol: vnc, selkies, guacamole, x2go, rdp - streamingPort?: number; // Port for streaming service + // Selkies-only streaming + streamingProtocol?: string; // Always "selkies" — kept for forward-compat + streamingPort?: number; // Port for the streaming service (default 8080) streamingPath?: string; // URL path for HTTP-based protocols } diff --git a/ui/src/mocks/handlers.ts b/ui/src/mocks/handlers.ts index dd405a96..961a10c1 100644 --- a/ui/src/mocks/handlers.ts +++ b/ui/src/mocks/handlers.ts @@ -50,12 +50,12 @@ export const MOCK_SESSIONS = { hibernated: { name: 'test-session-hibernated', user: 'admin', - template: 'firefox', + template: 'chrome-selkies', state: 'hibernated', platform: 'kubernetes', agent_id: 'k8s-agent-1', - streamingProtocol: 'vnc', - streamingPort: 5900, + streamingProtocol: 'selkies', + streamingPort: 8080, status: { phase: 'Hibernated', }, @@ -64,25 +64,6 @@ export const MOCK_SESSIONS = { created_at: new Date().toISOString(), last_activity: new Date().toISOString(), }, - vnc: { - name: 'test-session-vnc', - user: 'admin', - template: 'firefox', - state: 'running', - platform: 'kubernetes', - agent_id: 'k8s-agent-1', - streamingProtocol: 'vnc', - streamingPort: 5900, - status: { - phase: 'Running', - url: 'http://test-session-vnc.streamspace.svc.cluster.local:5900', - podName: 'test-session-vnc-def456', - }, - activeConnections: 1, - resources: { cpu: '500m', memory: '2Gi' }, - created_at: new Date().toISOString(), - last_activity: new Date().toISOString(), - }, }; export const MOCK_TEMPLATES = [ @@ -271,46 +252,7 @@ export const handlers = [ return HttpResponse.json(MOCK_AGENTS); }), - // VNC proxy (returns session info for HTTP-based protocols) - http.get('/api/v1/vnc/:sessionId', ({ params, request }) => { - const url = new URL(request.url); - const token = url.searchParams.get('token'); - - if (!token) { - return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { sessionId } = params; - const session = Object.values(MOCK_SESSIONS).find(s => s.name === sessionId); - - if (!session) { - return HttpResponse.json({ error: 'Session not found' }, { status: 404 }); - } - - if (session.state !== 'running') { - return HttpResponse.json( - { error: `Session is not running (state: ${session.state})` }, - { status: 409 } - ); - } - - // For HTTP-based protocols, return session info - if (['selkies', 'kasm', 'guacamole'].includes(session.streamingProtocol || '')) { - return HttpResponse.json({ - type: 'http_session', - session_id: sessionId, - protocol: session.streamingProtocol, - url: session.status.url, - port: session.streamingPort, - path: session.streamingPath, - }); - } - - // For VNC, we'd normally upgrade to WebSocket - return HttpResponse.json({ error: 'WebSocket upgrade required' }, { status: 426 }); - }), - - // HTTP proxy for Selkies/Kasm/Guacamole + // HTTP proxy for Selkies http.all('/api/v1/http/:sessionId/*', ({ params, request }) => { const url = new URL(request.url); const token = url.searchParams.get('token'); diff --git a/ui/src/pages/SessionViewer.tsx b/ui/src/pages/SessionViewer.tsx index 5282a1e8..165e4ceb 100644 --- a/ui/src/pages/SessionViewer.tsx +++ b/ui/src/pages/SessionViewer.tsx @@ -433,15 +433,12 @@ export default function SessionViewer() { - {/* Multi-protocol streaming support */} - {/* VNC: Load noVNC viewer through control plane proxy */} - {/* Selkies/HTTP-based: Load through control plane HTTP proxy */} - {/* Token is passed as query param for iframe auth (iframes can't send Authorization headers) */} + {/* Selkies-only streaming: load the session UI through the control-plane + HTTP proxy. Token is passed as a query param because iframes can't + send Authorization headers. */}