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