diff --git a/src/main/java/com/litoralregas/backend_gateway/BackendGatewayApplication.java b/src/main/java/com/litoralregas/backend_gateway/BackendGatewayApplication.java index e56c415..16a9722 100644 --- a/src/main/java/com/litoralregas/backend_gateway/BackendGatewayApplication.java +++ b/src/main/java/com/litoralregas/backend_gateway/BackendGatewayApplication.java @@ -1,6 +1,7 @@ package com.litoralregas.backend_gateway; import com.litoralregas.backend_gateway.gateway.ProxyProperties; +import com.litoralregas.backend_gateway.runtime.RuntimeConfigProperties; import com.litoralregas.backend_gateway.security.JwtProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -9,6 +10,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties @SpringBootApplication @EnableConfigurationProperties({ ProxyProperties.class, + RuntimeConfigProperties.class, JwtProperties.class }) public class BackendGatewayApplication { @@ -16,4 +18,4 @@ public class BackendGatewayApplication { public static void main(String[] args) { SpringApplication.run(BackendGatewayApplication.class, args); } -} \ No newline at end of file +} diff --git a/src/main/java/com/litoralregas/backend_gateway/auth/AuthController.java b/src/main/java/com/litoralregas/backend_gateway/auth/AuthController.java index b2e19db..6b2f31a 100644 --- a/src/main/java/com/litoralregas/backend_gateway/auth/AuthController.java +++ b/src/main/java/com/litoralregas/backend_gateway/auth/AuthController.java @@ -2,7 +2,6 @@ package com.litoralregas.backend_gateway.auth; import com.litoralregas.backend_gateway.auth.dto.LoginRequest; import com.litoralregas.backend_gateway.auth.dto.LoginResponse; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; @RestController @@ -10,19 +9,13 @@ import org.springframework.web.bind.annotation.*; public class AuthController { private final AuthService authService; - private final PasswordEncoder passwordEncoder; - public AuthController( - AuthService authService, - PasswordEncoder passwordEncoder - ) { + public AuthController(AuthService authService) { this.authService = authService; - this.passwordEncoder = passwordEncoder; } @PostMapping("/login") public LoginResponse login(@RequestBody LoginRequest request) { - System.out.println(passwordEncoder.encode("admin123")); return authService.login(request); } -} \ No newline at end of file +} diff --git a/src/main/java/com/litoralregas/backend_gateway/client/ClientEntity.java b/src/main/java/com/litoralregas/backend_gateway/client/ClientEntity.java index 65acd1f..2a74abc 100644 --- a/src/main/java/com/litoralregas/backend_gateway/client/ClientEntity.java +++ b/src/main/java/com/litoralregas/backend_gateway/client/ClientEntity.java @@ -16,6 +16,12 @@ public class ClientEntity { @Column(name = "backend_base_url", nullable = false) private String backendBaseUrl; + @Column(name = "default_vnc_host", nullable = false) + private String defaultVncHost; + + @Column(name = "default_vnc_port", nullable = false) + private int defaultVncPort = 5900; + @Column(nullable = false) private boolean enabled = true; @@ -34,6 +40,14 @@ public class ClientEntity { return backendBaseUrl; } + public String getDefaultVncHost() { + return defaultVncHost; + } + + public int getDefaultVncPort() { + return defaultVncPort; + } + public boolean isEnabled() { return enabled; } @@ -50,7 +64,15 @@ public class ClientEntity { this.backendBaseUrl = backendBaseUrl; } + public void setDefaultVncHost(String defaultVncHost) { + this.defaultVncHost = defaultVncHost; + } + + public void setDefaultVncPort(int defaultVncPort) { + this.defaultVncPort = defaultVncPort; + } + public void setEnabled(boolean enabled) { this.enabled = enabled; } -} \ No newline at end of file +} diff --git a/src/main/java/com/litoralregas/backend_gateway/client/ClientService.java b/src/main/java/com/litoralregas/backend_gateway/client/ClientService.java index b6cad0e..c571d9c 100644 --- a/src/main/java/com/litoralregas/backend_gateway/client/ClientService.java +++ b/src/main/java/com/litoralregas/backend_gateway/client/ClientService.java @@ -9,6 +9,9 @@ import java.util.List; @Service public class ClientService { + private static final String DEFAULT_VNC_HOST = "198.19.0.176"; + private static final int DEFAULT_VNC_PORT = 5900; + private final ClientRepository clientRepository; public ClientService(ClientRepository clientRepository) { @@ -19,6 +22,16 @@ public class ClientService { ClientEntity client = new ClientEntity(); client.setName(request.name()); client.setBackendBaseUrl(request.backendBaseUrl()); + client.setDefaultVncHost( + request.defaultVncHost() == null || request.defaultVncHost().isBlank() + ? DEFAULT_VNC_HOST + : request.defaultVncHost() + ); + client.setDefaultVncPort( + request.defaultVncPort() == null + ? DEFAULT_VNC_PORT + : request.defaultVncPort() + ); client.setEnabled(true); ClientEntity saved = clientRepository.save(client); @@ -38,8 +51,10 @@ public class ClientService { client.getId(), client.getName(), client.getBackendBaseUrl(), + client.getDefaultVncHost(), + client.getDefaultVncPort(), client.isEnabled(), client.getCreatedAt() ); } -} \ No newline at end of file +} diff --git a/src/main/java/com/litoralregas/backend_gateway/client/dto/ClientResponse.java b/src/main/java/com/litoralregas/backend_gateway/client/dto/ClientResponse.java index 8ef8cb1..39109ad 100644 --- a/src/main/java/com/litoralregas/backend_gateway/client/dto/ClientResponse.java +++ b/src/main/java/com/litoralregas/backend_gateway/client/dto/ClientResponse.java @@ -4,7 +4,9 @@ public record ClientResponse( Long id, String name, String backendBaseUrl, + String defaultVncHost, + int defaultVncPort, boolean enabled, String createdAt ) { -} \ No newline at end of file +} diff --git a/src/main/java/com/litoralregas/backend_gateway/client/dto/CreateClientRequest.java b/src/main/java/com/litoralregas/backend_gateway/client/dto/CreateClientRequest.java index a8d3e33..fe0d406 100644 --- a/src/main/java/com/litoralregas/backend_gateway/client/dto/CreateClientRequest.java +++ b/src/main/java/com/litoralregas/backend_gateway/client/dto/CreateClientRequest.java @@ -2,6 +2,8 @@ package com.litoralregas.backend_gateway.client.dto; public record CreateClientRequest( String name, - String backendBaseUrl + String backendBaseUrl, + String defaultVncHost, + Integer defaultVncPort ) { -} \ No newline at end of file +} diff --git a/src/main/java/com/litoralregas/backend_gateway/config/CorsProperties.java b/src/main/java/com/litoralregas/backend_gateway/config/CorsProperties.java new file mode 100644 index 0000000..9eb5ed7 --- /dev/null +++ b/src/main/java/com/litoralregas/backend_gateway/config/CorsProperties.java @@ -0,0 +1,20 @@ +package com.litoralregas.backend_gateway.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.List; + +@ConfigurationProperties(prefix = "app.cors") +public class CorsProperties { + + private List allowedOrigins = new ArrayList<>(); + + public List getAllowedOrigins() { + return allowedOrigins; + } + + public void setAllowedOrigins(List allowedOrigins) { + this.allowedOrigins = allowedOrigins; + } +} \ No newline at end of file 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 8d88c75..5849b60 100644 --- a/src/main/java/com/litoralregas/backend_gateway/config/SecurityConfig.java +++ b/src/main/java/com/litoralregas/backend_gateway/config/SecurityConfig.java @@ -1,32 +1,53 @@ package com.litoralregas.backend_gateway.config; import com.litoralregas.backend_gateway.security.JwtAuthenticationFilter; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.Customizer; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration +@EnableConfigurationProperties(CorsProperties.class) public class SecurityConfig { - private final JwtAuthenticationFilter jwtAuthenticationFilter; - public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CorsProperties corsProperties; + + public SecurityConfig( + JwtAuthenticationFilter jwtAuthenticationFilter, + CorsProperties corsProperties + ) { this.jwtAuthenticationFilter = jwtAuthenticationFilter; + this.corsProperties = corsProperties; } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http + .cors(Customizer.withDefaults()) .csrf(csrf -> csrf.disable()) .httpBasic(httpBasic -> httpBasic.disable()) .formLogin(formLogin -> formLogin.disable()) .logout(logout -> logout.disable()) .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/auth/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") - .requestMatchers("/ws/backend/vnc").permitAll() + .requestMatchers("/api/backend/ws").permitAll() + .requestMatchers("/api/backend/ws/vnc").permitAll() + .requestMatchers("/api/runtime/**").authenticated() .requestMatchers("/api/backend/**").authenticated() .anyRequest().permitAll() ) @@ -34,8 +55,42 @@ public class SecurityConfig { .build(); } + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowedOrigins(corsProperties.getAllowedOrigins()); + config.setAllowedMethods(List.of( + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS" + )); + config.setAllowedHeaders(List.of( + HttpHeaders.AUTHORIZATION, + HttpHeaders.CONTENT_TYPE, + HttpHeaders.ACCEPT, + "X-Requested-With", + "Origin" + )); + + config.setExposedHeaders(List.of( + HttpHeaders.AUTHORIZATION + )); + + config.setAllowCredentials(true); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return source; + } + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/litoralregas/backend_gateway/config/WebClientConfig.java b/src/main/java/com/litoralregas/backend_gateway/config/WebClientConfig.java index bbac12d..ec566c2 100644 --- a/src/main/java/com/litoralregas/backend_gateway/config/WebClientConfig.java +++ b/src/main/java/com/litoralregas/backend_gateway/config/WebClientConfig.java @@ -5,6 +5,7 @@ import io.netty.channel.ChannelOption; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.ExchangeStrategies; import org.springframework.web.reactive.function.client.WebClient; import reactor.netty.http.client.HttpClient; @@ -18,8 +19,15 @@ public class WebClientConfig { Math.toIntExact(proxyProperties.getConnectTimeout().toMillis())) .responseTimeout(proxyProperties.getResponseTimeout()); + ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder() + .codecs(configurer -> configurer + .defaultCodecs() + .maxInMemorySize(Math.toIntExact(proxyProperties.getMaxInMemorySize().toBytes()))) + .build(); + return builder .clientConnector(new ReactorClientHttpConnector(httpClient)) + .exchangeStrategies(exchangeStrategies) .build(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/litoralregas/backend_gateway/gateway/BackendProxyService.java b/src/main/java/com/litoralregas/backend_gateway/gateway/BackendProxyService.java index 1bb9f84..198349b 100644 --- a/src/main/java/com/litoralregas/backend_gateway/gateway/BackendProxyService.java +++ b/src/main/java/com/litoralregas/backend_gateway/gateway/BackendProxyService.java @@ -9,6 +9,8 @@ import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; +import java.net.URI; + @Service public class BackendProxyService { @@ -38,9 +40,11 @@ public class BackendProxyService { + (query != null ? "?" + query : ""); try { + HttpMethod method = HttpMethod.valueOf(request.getMethod()); + WebClient.RequestBodySpec requestSpec = webClient - .method(HttpMethod.valueOf(request.getMethod())) - .uri(targetUrl); + .method(method) + .uri(URI.create(targetUrl)); String contentType = request.getContentType(); @@ -54,8 +58,12 @@ public class BackendProxyService { requestSpec.header("Accept", accept); } - ResponseEntity response = requestSpec - .bodyValue(body != null ? body : "") + WebClient.RequestHeadersSpec outboundRequest = + supportsRequestBody(method) + ? requestSpec.bodyValue(body != null ? body : "") + : requestSpec; + + ResponseEntity response = outboundRequest .retrieve() .toEntity(String.class) .block(); @@ -82,4 +90,10 @@ public class BackendProxyService { .resolveCurrentClient() .getBackendBaseUrl(); } -} \ No newline at end of file + + private boolean supportsRequestBody(HttpMethod method) { + return HttpMethod.POST.equals(method) + || HttpMethod.PUT.equals(method) + || HttpMethod.PATCH.equals(method); + } +} diff --git a/src/main/java/com/litoralregas/backend_gateway/gateway/GatewayController.java b/src/main/java/com/litoralregas/backend_gateway/gateway/GatewayController.java index 4a73a66..c132b06 100644 --- a/src/main/java/com/litoralregas/backend_gateway/gateway/GatewayController.java +++ b/src/main/java/com/litoralregas/backend_gateway/gateway/GatewayController.java @@ -21,11 +21,11 @@ public class GatewayController { return backendProxyService.getHealth(); } - @RequestMapping("/api/backend/**") + @RequestMapping(value = "/api/backend/**", headers = "Upgrade!=websocket") public ResponseEntity proxy( HttpServletRequest request, @RequestBody(required = false) String body ) { return backendProxyService.proxy(request, body); } -} \ No newline at end of file +} diff --git a/src/main/java/com/litoralregas/backend_gateway/gateway/ProxyProperties.java b/src/main/java/com/litoralregas/backend_gateway/gateway/ProxyProperties.java index 2417342..9f77b16 100644 --- a/src/main/java/com/litoralregas/backend_gateway/gateway/ProxyProperties.java +++ b/src/main/java/com/litoralregas/backend_gateway/gateway/ProxyProperties.java @@ -1,6 +1,7 @@ package com.litoralregas.backend_gateway.gateway; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.unit.DataSize; import java.time.Duration; @@ -10,6 +11,7 @@ public class ProxyProperties { private String backendBaseUrl; private Duration connectTimeout = Duration.ofSeconds(3); private Duration responseTimeout = Duration.ofSeconds(10); + private DataSize maxInMemorySize = DataSize.ofMegabytes(64); public String getBackendBaseUrl() { return backendBaseUrl; @@ -34,4 +36,12 @@ public class ProxyProperties { public void setResponseTimeout(Duration responseTimeout) { this.responseTimeout = responseTimeout; } -} \ No newline at end of file + + public DataSize getMaxInMemorySize() { + return maxInMemorySize; + } + + public void setMaxInMemorySize(DataSize maxInMemorySize) { + this.maxInMemorySize = maxInMemorySize; + } +} 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 index 5fa414e..274043f 100644 --- a/src/main/java/com/litoralregas/backend_gateway/gateway/websocket/BackendWebSocketConfig.java +++ b/src/main/java/com/litoralregas/backend_gateway/gateway/websocket/BackendWebSocketConfig.java @@ -10,14 +10,22 @@ import org.springframework.web.socket.server.standard.ServletServerContainerFact public class BackendWebSocketConfig implements WebSocketConfigurer { private final VncGatewayWebSocketHandler vncGatewayWebSocketHandler; + private final StompGatewayWebSocketHandler stompGatewayWebSocketHandler; - public BackendWebSocketConfig(VncGatewayWebSocketHandler vncGatewayWebSocketHandler) { + public BackendWebSocketConfig( + VncGatewayWebSocketHandler vncGatewayWebSocketHandler, + StompGatewayWebSocketHandler stompGatewayWebSocketHandler + ) { this.vncGatewayWebSocketHandler = vncGatewayWebSocketHandler; + this.stompGatewayWebSocketHandler = stompGatewayWebSocketHandler; } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - registry.addHandler(vncGatewayWebSocketHandler, "/ws/backend/vnc") + registry.addHandler(stompGatewayWebSocketHandler, "/api/backend/ws") + .setAllowedOrigins("*"); + + registry.addHandler(vncGatewayWebSocketHandler, "/api/backend/ws/vnc") .setAllowedOrigins("*"); } @@ -32,4 +40,4 @@ public class BackendWebSocketConfig implements WebSocketConfigurer { return container; } -} \ No newline at end of file +} diff --git a/src/main/java/com/litoralregas/backend_gateway/gateway/websocket/StompGatewayWebSocketHandler.java b/src/main/java/com/litoralregas/backend_gateway/gateway/websocket/StompGatewayWebSocketHandler.java new file mode 100644 index 0000000..f9fdb63 --- /dev/null +++ b/src/main/java/com/litoralregas/backend_gateway/gateway/websocket/StompGatewayWebSocketHandler.java @@ -0,0 +1,503 @@ +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.PingMessage; +import org.springframework.web.socket.PongMessage; +import org.springframework.web.socket.SubProtocolCapable; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketHttpHeaders; +import org.springframework.web.socket.WebSocketMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.handler.AbstractWebSocketHandler; + +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; + +@Component +public class StompGatewayWebSocketHandler extends AbstractWebSocketHandler implements SubProtocolCapable { + + private static final Logger log = LoggerFactory.getLogger(StompGatewayWebSocketHandler.class); + + private static final int MAX_BINARY_MESSAGE_BUFFER_SIZE = 4 * 1024 * 1024; + private static final int MAX_TEXT_MESSAGE_BUFFER_SIZE = 1024 * 1024; + private static final List STOMP_SUB_PROTOCOLS = List.of( + "v10.stomp", + "v11.stomp", + "v12.stomp" + ); + + 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 StompGatewayWebSocketHandler( + JwtService jwtService, + ClientRepository clientRepository + ) { + this.jwtService = jwtService; + this.clientRepository = clientRepository; + + WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + container.setDefaultMaxBinaryMessageBufferSize(MAX_BINARY_MESSAGE_BUFFER_SIZE); + container.setDefaultMaxTextMessageBufferSize(MAX_TEXT_MESSAGE_BUFFER_SIZE); + + this.webSocketClient = new StandardWebSocketClient(container); + } + + @Override + public void afterConnectionEstablished(WebSocketSession frontendSession) { + log.info( + "STOMP frontend websocket connected. frontendSession={}, uri={}", + frontendSession.getId(), + frontendSession.getUri() + ); + + try { + String token = extractAccessToken(frontendSession); + + if (token == null || token.isBlank()) { + log.warn("STOMP frontend websocket rejected. reason=missing_token frontendSession={}", frontendSession.getId()); + sendError(frontendSession, "Invalid or missing access token"); + closeSafe(frontendSession, "missing token"); + return; + } + + if (!jwtService.isValid(token)) { + log.warn("STOMP frontend websocket rejected. reason=invalid_token frontendSession={}", frontendSession.getId()); + sendError(frontendSession, "Invalid or missing access token"); + closeSafe(frontendSession, "invalid token"); + return; + } + + Long userId = jwtService.extractUserId(token); + Long clientId = jwtService.extractClientId(token); + String username = jwtService.extractUsername(token); + String role = jwtService.extractRole(token); + + ClientEntity client = clientRepository.findByIdAndEnabledTrue(clientId) + .orElseThrow(() -> new RuntimeException("Client not found or disabled")); + + String backendWebSocketUrl = toBackendStompWebSocketUrl(client.getBackendBaseUrl()); + + log.info( + "Opening STOMP backend websocket. frontendSession={}, userId={}, username={}, role={}, clientId={}, clientName={}, backendUrl={}", + frontendSession.getId(), + userId, + username, + role, + client.getId(), + client.getName(), + backendWebSocketUrl + ); + + WebSocketHttpHeaders headers = backendHeaders(frontendSession); + + webSocketClient.execute( + new BackendStompBridgeHandler(frontendSession), + headers, + URI.create(backendWebSocketUrl) + ).whenComplete((backendSession, error) -> { + if (error != null) { + log.error( + "STOMP backend websocket connection failed. frontendSession={}, clientId={}, backendUrl={}", + frontendSession.getId(), + client.getId(), + backendWebSocketUrl, + error + ); + + sendError(frontendSession, "Could not connect to backend STOMP websocket"); + closeSafe(frontendSession, "backend websocket connection failed"); + return; + } + + backendSessions.put(frontendSession.getId(), backendSession); + + log.info( + "STOMP backend websocket connected. frontendSession={}, backendSession={}, backendUri={}", + frontendSession.getId(), + backendSession.getId(), + backendSession.getUri() + ); + + flushPendingMessages(frontendSession, backendSession); + }); + + } catch (Exception error) { + log.error( + "STOMP frontend websocket setup failed. frontendSession={}", + frontendSession.getId(), + error + ); + + sendError(frontendSession, error.getMessage()); + closeSafe(frontendSession, "setup exception"); + } + } + + @Override + public List getSubProtocols() { + return STOMP_SUB_PROTOCOLS; + } + + @Override + public void handleMessage( + WebSocketSession frontendSession, + WebSocketMessage message + ) { + log.debug( + "STOMP frontend websocket message received. frontendSession={}, type={}, size={}", + frontendSession.getId(), + messageType(message), + payloadLength(message) + ); + + forwardOrQueue(frontendSession, message); + } + + @Override + public void afterConnectionClosed( + WebSocketSession frontendSession, + CloseStatus status + ) { + log.info( + "STOMP frontend websocket closed. frontendSession={}, status={}", + frontendSession.getId(), + 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 + ) { + log.error( + "STOMP frontend websocket transport error. frontendSession={}", + frontendSession.getId(), + exception + ); + + pendingMessages.remove(frontendSession.getId()); + + WebSocketSession backendSession = backendSessions.remove(frontendSession.getId()); + + closeSafe(backendSession, "frontend transport error"); + closeSafe(frontendSession, "frontend transport error"); + } + + private void forwardOrQueue( + WebSocketSession frontendSession, + WebSocketMessage message + ) { + WebSocketSession backendSession = backendSessions.get(frontendSession.getId()); + + if (backendSession != null && backendSession.isOpen()) { + log.debug( + "Forwarding STOMP message frontend -> backend. frontendSession={}, backendSession={}, type={}, size={}", + frontendSession.getId(), + backendSession.getId(), + messageType(message), + payloadLength(message) + ); + + sendSafe(backendSession, message, "frontend -> backend"); + return; + } + + Queue> queue = pendingMessages + .computeIfAbsent(frontendSession.getId(), id -> new ConcurrentLinkedQueue<>()); + + queue.add(message); + + log.debug( + "Queued STOMP frontend message because backend is not ready. frontendSession={}, queueSize={}, type={}, size={}", + frontendSession.getId(), + queue.size(), + messageType(message), + payloadLength(message) + ); + } + + private void flushPendingMessages( + WebSocketSession frontendSession, + WebSocketSession backendSession + ) { + Queue> queue = pendingMessages.remove(frontendSession.getId()); + + if (queue == null || queue.isEmpty()) { + return; + } + + log.info( + "Flushing queued STOMP frontend messages. frontendSession={}, backendSession={}, count={}", + frontendSession.getId(), + backendSession.getId(), + queue.size() + ); + + for (WebSocketMessage pendingMessage : queue) { + sendSafe(backendSession, pendingMessage, "flush queued frontend -> backend"); + } + } + + private String extractAccessToken(WebSocketSession session) { + URI uri = session.getUri(); + + if (uri == null) { + return null; + } + + String query = uri.getQuery(); + + if (query == null || query.isBlank()) { + 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 URLDecoder.decode(parts[1], StandardCharsets.UTF_8); + } + } + + return null; + } + + private String toBackendStompWebSocketUrl(String backendBaseUrl) { + String normalizedBaseUrl = backendBaseUrl.endsWith("/") + ? backendBaseUrl.substring(0, backendBaseUrl.length() - 1) + : backendBaseUrl; + + String wsBaseUrl = normalizedBaseUrl + .replaceFirst("^http://", "ws://") + .replaceFirst("^https://", "wss://"); + + return wsBaseUrl + "/ws"; + } + + private WebSocketHttpHeaders backendHeaders(WebSocketSession frontendSession) { + WebSocketHttpHeaders headers = new WebSocketHttpHeaders(); + String acceptedProtocol = frontendSession.getAcceptedProtocol(); + + if (acceptedProtocol != null && !acceptedProtocol.isBlank()) { + headers.setSecWebSocketProtocol(acceptedProtocol); + } + + return headers; + } + + private void sendError(WebSocketSession session, String message) { + if (session == null || !session.isOpen()) { + return; + } + + String payload = + "{\"type\":\"error\",\"message\":\"" + + safeJson(message == null ? "Unknown websocket error" : message) + + "\"}"; + + 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) { + log.debug("STOMP websocket send skipped because session is null. direction={}", direction); + return; + } + + if (!session.isOpen()) { + log.debug( + "STOMP websocket send skipped because session is closed. direction={}, session={}", + direction, + session.getId() + ); + return; + } + + synchronized (session) { + session.sendMessage(message); + } + + log.debug( + "STOMP websocket message sent. direction={}, session={}, type={}, size={}", + direction, + session.getId(), + messageType(message), + payloadLength(message) + ); + + } catch (IllegalStateException closed) { + log.debug("STOMP websocket send skipped because session was already closed. direction={}", direction); + } catch (Exception error) { + log.warn( + "STOMP websocket send failed. direction={}, session={}", + direction, + session == null ? "null" : session.getId(), + error + ); + } + } + + private void closeSafe(WebSocketSession session, String reason) { + try { + if (session == null || !session.isOpen()) { + return; + } + + log.debug( + "Closing STOMP websocket session. session={}, reason={}", + session.getId(), + reason + ); + + session.close(); + + } catch (Exception error) { + log.warn( + "Failed to close STOMP websocket session. reason={}, session={}", + reason, + session == null ? "null" : session.getId(), + error + ); + } + } + + private String messageType(WebSocketMessage message) { + if (message instanceof TextMessage) { + return "TEXT"; + } + + 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 class BackendStompBridgeHandler extends AbstractWebSocketHandler { + + private final WebSocketSession frontendSession; + + private BackendStompBridgeHandler(WebSocketSession frontendSession) { + this.frontendSession = frontendSession; + } + + @Override + public void afterConnectionEstablished(WebSocketSession backendSession) { + log.debug( + "STOMP backend bridge handler established. frontendSession={}, backendSession={}, backendUri={}", + frontendSession.getId(), + backendSession.getId(), + backendSession.getUri() + ); + } + + @Override + public void handleMessage( + WebSocketSession backendSession, + WebSocketMessage message + ) { + log.debug( + "STOMP backend websocket message received. backendSession={}, frontendSession={}, type={}, size={}", + backendSession.getId(), + frontendSession.getId(), + messageType(message), + payloadLength(message) + ); + + sendSafe(frontendSession, message, "backend -> frontend"); + } + + @Override + public void afterConnectionClosed( + WebSocketSession backendSession, + CloseStatus status + ) { + log.info( + "STOMP backend websocket closed. backendSession={}, frontendSession={}, status={}", + backendSession.getId(), + frontendSession.getId(), + status + ); + + backendSessions.remove(frontendSession.getId()); + pendingMessages.remove(frontendSession.getId()); + + closeSafe(frontendSession, "backend closed"); + } + + @Override + public void handleTransportError( + WebSocketSession backendSession, + Throwable exception + ) { + log.error( + "STOMP backend websocket transport error. backendSession={}, frontendSession={}", + backendSession.getId(), + frontendSession.getId(), + exception + ); + + sendError(frontendSession, "Backend websocket transport error"); + + backendSessions.remove(frontendSession.getId()); + pendingMessages.remove(frontendSession.getId()); + + closeSafe(frontendSession, "backend transport error"); + closeSafe(backendSession, "backend transport error"); + } + } +} diff --git a/src/main/java/com/litoralregas/backend_gateway/runtime/ClientRuntimeConfig.java b/src/main/java/com/litoralregas/backend_gateway/runtime/ClientRuntimeConfig.java new file mode 100644 index 0000000..a786a1a --- /dev/null +++ b/src/main/java/com/litoralregas/backend_gateway/runtime/ClientRuntimeConfig.java @@ -0,0 +1,7 @@ +package com.litoralregas.backend_gateway.runtime; + +public record ClientRuntimeConfig( + Long id, + String name +) { +} diff --git a/src/main/java/com/litoralregas/backend_gateway/runtime/GatewayRuntimeConfig.java b/src/main/java/com/litoralregas/backend_gateway/runtime/GatewayRuntimeConfig.java new file mode 100644 index 0000000..28f044b --- /dev/null +++ b/src/main/java/com/litoralregas/backend_gateway/runtime/GatewayRuntimeConfig.java @@ -0,0 +1,8 @@ +package com.litoralregas.backend_gateway.runtime; + +public record GatewayRuntimeConfig( + String backendApiBasePath, + String stompWebSocketPath, + String vncWebSocketPath +) { +} diff --git a/src/main/java/com/litoralregas/backend_gateway/runtime/RuntimeConfigController.java b/src/main/java/com/litoralregas/backend_gateway/runtime/RuntimeConfigController.java new file mode 100644 index 0000000..98a0b05 --- /dev/null +++ b/src/main/java/com/litoralregas/backend_gateway/runtime/RuntimeConfigController.java @@ -0,0 +1,43 @@ +package com.litoralregas.backend_gateway.runtime; + +import com.litoralregas.backend_gateway.client.ClientEntity; +import com.litoralregas.backend_gateway.client.ClientResolver; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class RuntimeConfigController { + + private final ClientResolver clientResolver; + private final RuntimeConfigProperties properties; + + public RuntimeConfigController( + ClientResolver clientResolver, + RuntimeConfigProperties properties + ) { + this.clientResolver = clientResolver; + this.properties = properties; + } + + @GetMapping("/api/runtime/config") + public RuntimeConfigResponse getRuntimeConfig() { + ClientEntity client = clientResolver.resolveCurrentClient(); + + return new RuntimeConfigResponse( + properties.getMode(), + new ClientRuntimeConfig( + client.getId(), + client.getName() + ), + new GatewayRuntimeConfig( + properties.getBackendApiBasePath(), + properties.getStompWebSocketPath(), + properties.getVncWebSocketPath() + ), + new VncRuntimeConfig( + client.getDefaultVncHost(), + client.getDefaultVncPort() + ) + ); + } +} diff --git a/src/main/java/com/litoralregas/backend_gateway/runtime/RuntimeConfigProperties.java b/src/main/java/com/litoralregas/backend_gateway/runtime/RuntimeConfigProperties.java new file mode 100644 index 0000000..4e9f161 --- /dev/null +++ b/src/main/java/com/litoralregas/backend_gateway/runtime/RuntimeConfigProperties.java @@ -0,0 +1,45 @@ +package com.litoralregas.backend_gateway.runtime; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.runtime") +public class RuntimeConfigProperties { + + private String mode = "development"; + private String backendApiBasePath = "/api/backend"; + private String stompWebSocketPath = "/api/backend/ws"; + private String vncWebSocketPath = "/api/backend/ws/vnc"; + + public String getMode() { + return mode; + } + + public void setMode(String mode) { + this.mode = mode; + } + + public String getBackendApiBasePath() { + return backendApiBasePath; + } + + public void setBackendApiBasePath(String backendApiBasePath) { + this.backendApiBasePath = backendApiBasePath; + } + + public String getStompWebSocketPath() { + return stompWebSocketPath; + } + + public void setStompWebSocketPath(String stompWebSocketPath) { + this.stompWebSocketPath = stompWebSocketPath; + } + + public String getVncWebSocketPath() { + return vncWebSocketPath; + } + + public void setVncWebSocketPath(String vncWebSocketPath) { + this.vncWebSocketPath = vncWebSocketPath; + } + +} diff --git a/src/main/java/com/litoralregas/backend_gateway/runtime/RuntimeConfigResponse.java b/src/main/java/com/litoralregas/backend_gateway/runtime/RuntimeConfigResponse.java new file mode 100644 index 0000000..65fd9b0 --- /dev/null +++ b/src/main/java/com/litoralregas/backend_gateway/runtime/RuntimeConfigResponse.java @@ -0,0 +1,9 @@ +package com.litoralregas.backend_gateway.runtime; + +public record RuntimeConfigResponse( + String mode, + ClientRuntimeConfig client, + GatewayRuntimeConfig gateway, + VncRuntimeConfig vnc +) { +} diff --git a/src/main/java/com/litoralregas/backend_gateway/runtime/VncRuntimeConfig.java b/src/main/java/com/litoralregas/backend_gateway/runtime/VncRuntimeConfig.java new file mode 100644 index 0000000..fe3c8cb --- /dev/null +++ b/src/main/java/com/litoralregas/backend_gateway/runtime/VncRuntimeConfig.java @@ -0,0 +1,7 @@ +package com.litoralregas.backend_gateway.runtime; + +public record VncRuntimeConfig( + String defaultHost, + int defaultPort +) { +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 463e030..f188c2f 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -24,7 +24,17 @@ gateway: backend-base-url: http://10.100.1.2:18450 connect-timeout: 3s response-timeout: 10s + max-in-memory-size: 64MB + +app: + cors: + allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:1420,http://127.0.0.1:1420} + runtime: + mode: ${APP_RUNTIME_MODE:development} + backend-api-base-path: ${APP_BACKEND_API_BASE_PATH:/api/backend} + stomp-web-socket-path: ${APP_STOMP_WEB_SOCKET_PATH:/api/backend/ws} + vnc-web-socket-path: ${APP_VNC_WEB_SOCKET_PATH:/api/backend/ws/vnc} jwt: secret: ${JWT_SECRET:backend-gateway-local-development-secret-2026-super-long} - expiration-minutes: ${JWT_EXPIRATION_MINUTES:1440} \ No newline at end of file + expiration-minutes: ${JWT_EXPIRATION_MINUTES:1440} diff --git a/src/main/resources/db/migration/V4__add_client_runtime_config.sql b/src/main/resources/db/migration/V4__add_client_runtime_config.sql new file mode 100644 index 0000000..90ebabd --- /dev/null +++ b/src/main/resources/db/migration/V4__add_client_runtime_config.sql @@ -0,0 +1,5 @@ +ALTER TABLE clients + ADD COLUMN default_vnc_host TEXT NOT NULL DEFAULT '198.19.0.176'; + +ALTER TABLE clients + ADD COLUMN default_vnc_port INTEGER NOT NULL DEFAULT 5900;