diff --git a/.gitignore b/.gitignore index b9fcd77..cf2e820 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ build/ .vscode/ .env +data/*.db diff --git a/pom.xml b/pom.xml index 68ef2fe..5d0dc58 100644 --- a/pom.xml +++ b/pom.xml @@ -91,6 +91,10 @@ 0.12.7 runtime + + org.springframework.boot + spring-boot-starter-websocket + diff --git a/src/main/java/com/litoralregas/backend_gateway/client/ClientResolver.java b/src/main/java/com/litoralregas/backend_gateway/client/ClientResolver.java index f052731..5b78921 100644 --- a/src/main/java/com/litoralregas/backend_gateway/client/ClientResolver.java +++ b/src/main/java/com/litoralregas/backend_gateway/client/ClientResolver.java @@ -15,16 +15,18 @@ public class ClientResolver { } public ClientEntity resolveCurrentClient() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null) { + if (authentication == null || !authentication.isAuthenticated()) { throw new RuntimeException("Not authenticated"); } - AuthenticatedUser user = - (AuthenticatedUser) authentication.getPrincipal(); + Object principal = authentication.getPrincipal(); + + if (!(principal instanceof AuthenticatedUser user)) { + throw new RuntimeException("Invalid authentication principal"); + } return clientRepository .findByIdAndEnabledTrue(user.clientId()) diff --git a/src/main/java/com/litoralregas/backend_gateway/config/SecurityConfig.java b/src/main/java/com/litoralregas/backend_gateway/config/SecurityConfig.java index f131673..8d88c75 100644 --- a/src/main/java/com/litoralregas/backend_gateway/config/SecurityConfig.java +++ b/src/main/java/com/litoralregas/backend_gateway/config/SecurityConfig.java @@ -20,9 +20,13 @@ public class SecurityConfig { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) + .httpBasic(httpBasic -> httpBasic.disable()) + .formLogin(formLogin -> formLogin.disable()) + .logout(logout -> logout.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/auth/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") + .requestMatchers("/ws/backend/vnc").permitAll() .requestMatchers("/api/backend/**").authenticated() .anyRequest().permitAll() ) diff --git a/src/main/java/com/litoralregas/backend_gateway/gateway/websocket/BackendWebSocketConfig.java b/src/main/java/com/litoralregas/backend_gateway/gateway/websocket/BackendWebSocketConfig.java new file mode 100644 index 0000000..5fa414e --- /dev/null +++ b/src/main/java/com/litoralregas/backend_gateway/gateway/websocket/BackendWebSocketConfig.java @@ -0,0 +1,35 @@ +package com.litoralregas.backend_gateway.gateway.websocket; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.*; +import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; + +@Configuration +@EnableWebSocket +public class BackendWebSocketConfig implements WebSocketConfigurer { + + private final VncGatewayWebSocketHandler vncGatewayWebSocketHandler; + + public BackendWebSocketConfig(VncGatewayWebSocketHandler vncGatewayWebSocketHandler) { + this.vncGatewayWebSocketHandler = vncGatewayWebSocketHandler; + } + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(vncGatewayWebSocketHandler, "/ws/backend/vnc") + .setAllowedOrigins("*"); + } + + @Bean + public ServletServerContainerFactoryBean createWebSocketContainer() { + ServletServerContainerFactoryBean container = + new ServletServerContainerFactoryBean(); + + container.setMaxBinaryMessageBufferSize(4 * 1024 * 1024); + container.setMaxTextMessageBufferSize(1024 * 1024); + container.setMaxSessionIdleTimeout(0L); + + return container; + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend_gateway/gateway/websocket/VncGatewayWebSocketHandler.java b/src/main/java/com/litoralregas/backend_gateway/gateway/websocket/VncGatewayWebSocketHandler.java new file mode 100644 index 0000000..77f07b7 --- /dev/null +++ b/src/main/java/com/litoralregas/backend_gateway/gateway/websocket/VncGatewayWebSocketHandler.java @@ -0,0 +1,479 @@ +package com.litoralregas.backend_gateway.gateway.websocket; + +import com.litoralregas.backend_gateway.client.ClientEntity; +import com.litoralregas.backend_gateway.client.ClientRepository; +import com.litoralregas.backend_gateway.security.JwtService; +import jakarta.websocket.ContainerProvider; +import jakarta.websocket.WebSocketContainer; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.*; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.handler.BinaryWebSocketHandler; + +import java.net.URI; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; + +@Component +public class VncGatewayWebSocketHandler extends BinaryWebSocketHandler { + + private final JwtService jwtService; + private final ClientRepository clientRepository; + private final StandardWebSocketClient webSocketClient; + + private final Map backendSessions = new ConcurrentHashMap<>(); + private final Map>> pendingMessages = new ConcurrentHashMap<>(); + + public VncGatewayWebSocketHandler( + JwtService jwtService, + ClientRepository clientRepository + ) { + this.jwtService = jwtService; + this.clientRepository = clientRepository; + + WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + container.setDefaultMaxBinaryMessageBufferSize(4 * 1024 * 1024); + container.setDefaultMaxTextMessageBufferSize(1024 * 1024); + + this.webSocketClient = new StandardWebSocketClient(container); + } + + @Override + public void afterConnectionEstablished(WebSocketSession frontendSession) { + log("FRONTEND CONNECTED", frontendSession); + System.out.println("[VNC-GATEWAY] Frontend URI: " + frontendSession.getUri()); + + try { + String token = extractAccessToken(frontendSession); + + if (token == null || token.isBlank()) { + System.out.println("[VNC-GATEWAY] Missing access_token"); + sendError(frontendSession, "Invalid or missing access token"); + closeSafe(frontendSession, "missing token"); + return; + } + + System.out.println("[VNC-GATEWAY] Token received. length=" + token.length()); + + if (!jwtService.isValid(token)) { + System.out.println("[VNC-GATEWAY] Token invalid"); + sendError(frontendSession, "Invalid or missing access token"); + closeSafe(frontendSession, "invalid token"); + return; + } + + String username = jwtService.extractUsername(token); + Long userId = jwtService.extractUserId(token); + Long clientId = jwtService.extractClientId(token); + String role = jwtService.extractRole(token); + + System.out.println("[VNC-GATEWAY] Token valid:"); + System.out.println("[VNC-GATEWAY] username=" + username); + System.out.println("[VNC-GATEWAY] userId=" + userId); + System.out.println("[VNC-GATEWAY] clientId=" + clientId); + System.out.println("[VNC-GATEWAY] role=" + role); + + ClientEntity client = clientRepository.findByIdAndEnabledTrue(clientId) + .orElseThrow(() -> new RuntimeException("Client not found or disabled")); + + System.out.println("[VNC-GATEWAY] Client resolved:"); + System.out.println("[VNC-GATEWAY] id=" + client.getId()); + System.out.println("[VNC-GATEWAY] name=" + client.getName()); + System.out.println("[VNC-GATEWAY] backendBaseUrl=" + client.getBackendBaseUrl()); + + String backendWebSocketUrl = toBackendVncWebSocketUrl(client.getBackendBaseUrl()); + + System.out.println("[VNC-GATEWAY] Backend WS target: " + backendWebSocketUrl); + + webSocketClient.execute( + new BackendVncBridgeHandler(frontendSession), + backendWebSocketUrl + ).whenComplete((backendSession, error) -> { + if (error != null) { + System.out.println("[VNC-GATEWAY] Backend WS connection failed"); + System.out.println("[VNC-GATEWAY] Error type: " + error.getClass().getName()); + System.out.println("[VNC-GATEWAY] Error message: " + error.getMessage()); + error.printStackTrace(); + + sendError(frontendSession, "Could not connect to backend VNC websocket"); + closeSafe(frontendSession, "backend ws connection failed"); + return; + } + + System.out.println("[VNC-GATEWAY] Backend WS connected"); + System.out.println("[VNC-GATEWAY] Backend session id=" + backendSession.getId()); + System.out.println("[VNC-GATEWAY] Backend URI=" + backendSession.getUri()); + + backendSessions.put(frontendSession.getId(), backendSession); + + Queue> queue = + pendingMessages.remove(frontendSession.getId()); + + if (queue == null || queue.isEmpty()) { + System.out.println("[VNC-GATEWAY] No queued frontend messages to flush"); + return; + } + + System.out.println("[VNC-GATEWAY] Flushing queued messages: " + queue.size()); + + int index = 0; + for (WebSocketMessage pendingMessage : queue) { + index++; + System.out.println("[VNC-GATEWAY] Flushing queued message #" + index + + " type=" + messageType(pendingMessage) + + " size=" + payloadLength(pendingMessage)); + + sendSafe(backendSession, pendingMessage, "flush queued frontend -> backend"); + } + }); + + } catch (Exception error) { + System.out.println("[VNC-GATEWAY] Frontend connection setup failed"); + System.out.println("[VNC-GATEWAY] Error type: " + error.getClass().getName()); + System.out.println("[VNC-GATEWAY] Error message: " + error.getMessage()); + error.printStackTrace(); + + sendError(frontendSession, error.getMessage()); + closeSafe(frontendSession, "setup exception"); + } + } + + @Override + protected void handleTextMessage( + WebSocketSession frontendSession, + TextMessage message + ) { + System.out.println("[VNC-GATEWAY] Frontend TEXT message:" + + " session=" + frontendSession.getId() + + " size=" + message.getPayloadLength() + + " payload=" + truncate(message.getPayload())); + + forwardOrQueue(frontendSession, message); + } + + @Override + protected void handleBinaryMessage( + WebSocketSession frontendSession, + BinaryMessage message + ) { + System.out.println("[VNC-GATEWAY] Frontend BINARY message:" + + " session=" + frontendSession.getId() + + " size=" + message.getPayloadLength()); + + forwardOrQueue(frontendSession, message); + } + + private void forwardOrQueue( + WebSocketSession frontendSession, + WebSocketMessage message + ) { + WebSocketSession backendSession = backendSessions.get(frontendSession.getId()); + + if (backendSession != null && backendSession.isOpen()) { + System.out.println("[VNC-GATEWAY] Forwarding frontend -> backend:" + + " type=" + messageType(message) + + " size=" + payloadLength(message)); + + sendSafe(backendSession, message, "frontend -> backend"); + return; + } + + Queue> queue = pendingMessages + .computeIfAbsent(frontendSession.getId(), id -> new ConcurrentLinkedQueue<>()); + + queue.add(message); + + System.out.println("[VNC-GATEWAY] Backend not ready. Queued frontend message:" + + " session=" + frontendSession.getId() + + " queueSize=" + queue.size() + + " type=" + messageType(message) + + " size=" + payloadLength(message)); + } + + @Override + public void afterConnectionClosed( + WebSocketSession frontendSession, + CloseStatus status + ) { + System.out.println("[VNC-GATEWAY] Frontend WS closed:" + + " session=" + frontendSession.getId() + + " status=" + status); + + pendingMessages.remove(frontendSession.getId()); + + WebSocketSession backendSession = backendSessions.remove(frontendSession.getId()); + + if (backendSession != null) { + closeSafe(backendSession, "frontend closed"); + } + } + + @Override + public void handleTransportError( + WebSocketSession frontendSession, + Throwable exception + ) { + System.out.println("[VNC-GATEWAY] Frontend transport error:" + + " session=" + frontendSession.getId() + + " error=" + exception.getClass().getName() + + ": " + exception.getMessage()); + + exception.printStackTrace(); + + pendingMessages.remove(frontendSession.getId()); + + WebSocketSession backendSession = backendSessions.remove(frontendSession.getId()); + closeSafe(backendSession, "frontend transport error"); + closeSafe(frontendSession, "frontend transport error"); + } + + private String extractAccessToken(WebSocketSession session) { + URI uri = session.getUri(); + + if (uri == null) { + System.out.println("[VNC-GATEWAY] Session URI is null"); + return null; + } + + String query = uri.getQuery(); + + if (query == null || query.isBlank()) { + System.out.println("[VNC-GATEWAY] Session query is empty"); + return null; + } + + String[] params = query.split("&"); + + for (String param : params) { + String[] parts = param.split("=", 2); + + if (parts.length == 2 && parts[0].equals("access_token")) { + return java.net.URLDecoder.decode( + parts[1], + java.nio.charset.StandardCharsets.UTF_8 + ); + } + } + + return null; + } + + private String toBackendVncWebSocketUrl(String backendBaseUrl) { + String wsBaseUrl = backendBaseUrl + .replaceFirst("^http://", "ws://") + .replaceFirst("^https://", "wss://"); + + return wsBaseUrl + "/ws/vnc"; + } + + private void sendError(WebSocketSession session, String message) { + if (session == null || !session.isOpen()) { + System.out.println("[VNC-GATEWAY] Cannot send error, session closed/null: " + message); + return; + } + + String payload = + "{\"type\":\"error\",\"message\":\"" + + safeJson(message == null ? "Unknown websocket error" : message) + + "\"}"; + + System.out.println("[VNC-GATEWAY] Sending error to frontend: " + payload); + + sendSafe(session, new TextMessage(payload), "send error"); + } + + private String safeJson(String value) { + return value + .replace("\\", "\\\\") + .replace("\"", "\\\""); + } + + private void sendSafe( + WebSocketSession session, + WebSocketMessage message, + String direction + ) { + try { + if (session == null) { + System.out.println("[VNC-GATEWAY] sendSafe skipped, session null. direction=" + direction); + return; + } + + if (!session.isOpen()) { + System.out.println("[VNC-GATEWAY] sendSafe skipped, session closed." + + " direction=" + direction + + " session=" + session.getId()); + return; + } + + synchronized (session) { + session.sendMessage(message); + } + + System.out.println("[VNC-GATEWAY] Sent message:" + + " direction=" + direction + + " session=" + session.getId() + + " type=" + messageType(message) + + " size=" + payloadLength(message)); + + } catch (IllegalStateException closed) { + System.out.println("[VNC-GATEWAY] send skipped, session already closed. direction=" + direction); + } catch (Exception error) { + System.out.println("[VNC-GATEWAY] send failed: " + direction + " " + error.getMessage()); + } + } + + private void closeSafe(WebSocketSession session, String reason) { + try { + if (session == null) { + return; + } + + if (!session.isOpen()) { + return; + } + + System.out.println("[VNC-GATEWAY] Closing session:" + + " session=" + session.getId() + + " reason=" + reason); + + session.close(); + + } catch (Exception error) { + System.out.println("[VNC-GATEWAY] closeSafe failed:" + + " reason=" + reason + + " error=" + error.getClass().getName() + + ": " + error.getMessage()); + } + } + + private void log(String event, WebSocketSession session) { + System.out.println("[VNC-GATEWAY] " + event + + " session=" + (session == null ? "null" : session.getId()) + + " open=" + (session != null && session.isOpen())); + } + + private String messageType(WebSocketMessage message) { + if (message instanceof TextMessage) { + return "TEXT"; + } + + if (message instanceof BinaryMessage) { + return "BINARY"; + } + + if (message instanceof PingMessage) { + return "PING"; + } + + if (message instanceof PongMessage) { + return "PONG"; + } + + return message.getClass().getSimpleName(); + } + + private int payloadLength(WebSocketMessage message) { + try { + return message.getPayloadLength(); + } catch (Exception ignored) { + return -1; + } + } + + private String truncate(String value) { + if (value == null) { + return "null"; + } + + int max = 300; + + if (value.length() <= max) { + return value; + } + + return value.substring(0, max) + "..."; + } + + private class BackendVncBridgeHandler extends BinaryWebSocketHandler { + + private final WebSocketSession frontendSession; + + private BackendVncBridgeHandler(WebSocketSession frontendSession) { + this.frontendSession = frontendSession; + } + + @Override + public void afterConnectionEstablished(WebSocketSession backendSession) { + System.out.println("[VNC-GATEWAY] Backend handler established:" + + " backendSession=" + backendSession.getId() + + " frontendSession=" + frontendSession.getId() + + " backendUri=" + backendSession.getUri()); + } + + @Override + protected void handleTextMessage( + WebSocketSession backendSession, + TextMessage message + ) { + System.out.println("[VNC-GATEWAY] Backend TEXT message:" + + " backendSession=" + backendSession.getId() + + " size=" + message.getPayloadLength() + + " payload=" + truncate(message.getPayload())); + + sendSafe(frontendSession, message, "backend -> frontend"); + } + + @Override + protected void handleBinaryMessage( + WebSocketSession backendSession, + BinaryMessage message + ) { + System.out.println("[VNC-GATEWAY] Backend BINARY message:" + + " backendSession=" + backendSession.getId() + + " size=" + message.getPayloadLength()); + + sendSafe(frontendSession, message, "backend -> frontend"); + } + + @Override + public void afterConnectionClosed( + WebSocketSession backendSession, + CloseStatus status + ) { + System.out.println("[VNC-GATEWAY] Backend WS closed:" + + " backendSession=" + backendSession.getId() + + " frontendSession=" + frontendSession.getId() + + " status=" + status); + + backendSessions.remove(frontendSession.getId()); + pendingMessages.remove(frontendSession.getId()); + + closeSafe(frontendSession, "backend closed"); + } + + @Override + public void handleTransportError( + WebSocketSession backendSession, + Throwable exception + ) { + System.out.println("[VNC-GATEWAY] Backend transport error:" + + " backendSession=" + backendSession.getId() + + " frontendSession=" + frontendSession.getId() + + " error=" + exception.getClass().getName() + + ": " + exception.getMessage()); + + exception.printStackTrace(); + + sendError(frontendSession, "Backend websocket transport error"); + + backendSessions.remove(frontendSession.getId()); + pendingMessages.remove(frontendSession.getId()); + + closeSafe(frontendSession, "backend transport error"); + closeSafe(backendSession, "backend transport error"); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 75939e4..463e030 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -26,5 +26,5 @@ gateway: response-timeout: 10s jwt: - secret: ${JWT_SECRET} + secret: ${JWT_SECRET:backend-gateway-local-development-secret-2026-super-long} expiration-minutes: ${JWT_EXPIRATION_MINUTES:1440} \ No newline at end of file