Add initial backend websocket gateway
This commit is contained in:
@@ -33,3 +33,4 @@ build/
|
||||
.vscode/
|
||||
|
||||
.env
|
||||
data/*.db
|
||||
|
||||
@@ -91,6 +91,10 @@
|
||||
<version>0.12.7</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
+35
@@ -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;
|
||||
}
|
||||
}
|
||||
+479
@@ -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<String, WebSocketSession> backendSessions = new ConcurrentHashMap<>();
|
||||
private final Map<String, Queue<WebSocketMessage<?>>> 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<WebSocketMessage<?>> 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<WebSocketMessage<?>> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user