runtime config, client VNC config, proxy max-in-memory-size, fixes REST proxy, limpeza do log no login.

This commit is contained in:
litoral05
2026-06-08 16:31:28 +01:00
parent 72eb393d84
commit 10e4a7b3ee
22 changed files with 820 additions and 32 deletions
@@ -1,6 +1,7 @@
package com.litoralregas.backend_gateway; package com.litoralregas.backend_gateway;
import com.litoralregas.backend_gateway.gateway.ProxyProperties; import com.litoralregas.backend_gateway.gateway.ProxyProperties;
import com.litoralregas.backend_gateway.runtime.RuntimeConfigProperties;
import com.litoralregas.backend_gateway.security.JwtProperties; import com.litoralregas.backend_gateway.security.JwtProperties;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -9,6 +10,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
@SpringBootApplication @SpringBootApplication
@EnableConfigurationProperties({ @EnableConfigurationProperties({
ProxyProperties.class, ProxyProperties.class,
RuntimeConfigProperties.class,
JwtProperties.class JwtProperties.class
}) })
public class BackendGatewayApplication { public class BackendGatewayApplication {
@@ -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.LoginRequest;
import com.litoralregas.backend_gateway.auth.dto.LoginResponse; import com.litoralregas.backend_gateway.auth.dto.LoginResponse;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@RestController @RestController
@@ -10,19 +9,13 @@ import org.springframework.web.bind.annotation.*;
public class AuthController { public class AuthController {
private final AuthService authService; private final AuthService authService;
private final PasswordEncoder passwordEncoder;
public AuthController( public AuthController(AuthService authService) {
AuthService authService,
PasswordEncoder passwordEncoder
) {
this.authService = authService; this.authService = authService;
this.passwordEncoder = passwordEncoder;
} }
@PostMapping("/login") @PostMapping("/login")
public LoginResponse login(@RequestBody LoginRequest request) { public LoginResponse login(@RequestBody LoginRequest request) {
System.out.println(passwordEncoder.encode("admin123"));
return authService.login(request); return authService.login(request);
} }
} }
@@ -16,6 +16,12 @@ public class ClientEntity {
@Column(name = "backend_base_url", nullable = false) @Column(name = "backend_base_url", nullable = false)
private String backendBaseUrl; 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) @Column(nullable = false)
private boolean enabled = true; private boolean enabled = true;
@@ -34,6 +40,14 @@ public class ClientEntity {
return backendBaseUrl; return backendBaseUrl;
} }
public String getDefaultVncHost() {
return defaultVncHost;
}
public int getDefaultVncPort() {
return defaultVncPort;
}
public boolean isEnabled() { public boolean isEnabled() {
return enabled; return enabled;
} }
@@ -50,6 +64,14 @@ public class ClientEntity {
this.backendBaseUrl = backendBaseUrl; this.backendBaseUrl = backendBaseUrl;
} }
public void setDefaultVncHost(String defaultVncHost) {
this.defaultVncHost = defaultVncHost;
}
public void setDefaultVncPort(int defaultVncPort) {
this.defaultVncPort = defaultVncPort;
}
public void setEnabled(boolean enabled) { public void setEnabled(boolean enabled) {
this.enabled = enabled; this.enabled = enabled;
} }
@@ -9,6 +9,9 @@ import java.util.List;
@Service @Service
public class ClientService { 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; private final ClientRepository clientRepository;
public ClientService(ClientRepository clientRepository) { public ClientService(ClientRepository clientRepository) {
@@ -19,6 +22,16 @@ public class ClientService {
ClientEntity client = new ClientEntity(); ClientEntity client = new ClientEntity();
client.setName(request.name()); client.setName(request.name());
client.setBackendBaseUrl(request.backendBaseUrl()); 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); client.setEnabled(true);
ClientEntity saved = clientRepository.save(client); ClientEntity saved = clientRepository.save(client);
@@ -38,6 +51,8 @@ public class ClientService {
client.getId(), client.getId(),
client.getName(), client.getName(),
client.getBackendBaseUrl(), client.getBackendBaseUrl(),
client.getDefaultVncHost(),
client.getDefaultVncPort(),
client.isEnabled(), client.isEnabled(),
client.getCreatedAt() client.getCreatedAt()
); );
@@ -4,6 +4,8 @@ public record ClientResponse(
Long id, Long id,
String name, String name,
String backendBaseUrl, String backendBaseUrl,
String defaultVncHost,
int defaultVncPort,
boolean enabled, boolean enabled,
String createdAt String createdAt
) { ) {
@@ -2,6 +2,8 @@ package com.litoralregas.backend_gateway.client.dto;
public record CreateClientRequest( public record CreateClientRequest(
String name, String name,
String backendBaseUrl String backendBaseUrl,
String defaultVncHost,
Integer defaultVncPort
) { ) {
} }
@@ -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<String> allowedOrigins = new ArrayList<>();
public List<String> getAllowedOrigins() {
return allowedOrigins;
}
public void setAllowedOrigins(List<String> allowedOrigins) {
this.allowedOrigins = allowedOrigins;
}
}
@@ -1,32 +1,53 @@
package com.litoralregas.backend_gateway.config; package com.litoralregas.backend_gateway.config;
import com.litoralregas.backend_gateway.security.JwtAuthenticationFilter; 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.Bean;
import org.springframework.context.annotation.Configuration; 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.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.Customizer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 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 @Configuration
@EnableConfigurationProperties(CorsProperties.class)
public class SecurityConfig { 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.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.corsProperties = corsProperties;
} }
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http return http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable()) .csrf(csrf -> csrf.disable())
.httpBasic(httpBasic -> httpBasic.disable()) .httpBasic(httpBasic -> httpBasic.disable())
.formLogin(formLogin -> formLogin.disable()) .formLogin(formLogin -> formLogin.disable())
.logout(logout -> logout.disable()) .logout(logout -> logout.disable())
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/auth/**").permitAll() .requestMatchers("/auth/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN") .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() .requestMatchers("/api/backend/**").authenticated()
.anyRequest().permitAll() .anyRequest().permitAll()
) )
@@ -34,6 +55,40 @@ public class SecurityConfig {
.build(); .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 @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
@@ -5,6 +5,7 @@ import io.netty.channel.ChannelOption;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.ExchangeStrategies;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient; import reactor.netty.http.client.HttpClient;
@@ -18,8 +19,15 @@ public class WebClientConfig {
Math.toIntExact(proxyProperties.getConnectTimeout().toMillis())) Math.toIntExact(proxyProperties.getConnectTimeout().toMillis()))
.responseTimeout(proxyProperties.getResponseTimeout()); .responseTimeout(proxyProperties.getResponseTimeout());
ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder()
.codecs(configurer -> configurer
.defaultCodecs()
.maxInMemorySize(Math.toIntExact(proxyProperties.getMaxInMemorySize().toBytes())))
.build();
return builder return builder
.clientConnector(new ReactorClientHttpConnector(httpClient)) .clientConnector(new ReactorClientHttpConnector(httpClient))
.exchangeStrategies(exchangeStrategies)
.build(); .build();
} }
} }
@@ -9,6 +9,8 @@ import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException; import org.springframework.web.reactive.function.client.WebClientResponseException;
import java.net.URI;
@Service @Service
public class BackendProxyService { public class BackendProxyService {
@@ -38,9 +40,11 @@ public class BackendProxyService {
+ (query != null ? "?" + query : ""); + (query != null ? "?" + query : "");
try { try {
HttpMethod method = HttpMethod.valueOf(request.getMethod());
WebClient.RequestBodySpec requestSpec = webClient WebClient.RequestBodySpec requestSpec = webClient
.method(HttpMethod.valueOf(request.getMethod())) .method(method)
.uri(targetUrl); .uri(URI.create(targetUrl));
String contentType = request.getContentType(); String contentType = request.getContentType();
@@ -54,8 +58,12 @@ public class BackendProxyService {
requestSpec.header("Accept", accept); requestSpec.header("Accept", accept);
} }
ResponseEntity<String> response = requestSpec WebClient.RequestHeadersSpec<?> outboundRequest =
.bodyValue(body != null ? body : "") supportsRequestBody(method)
? requestSpec.bodyValue(body != null ? body : "")
: requestSpec;
ResponseEntity<String> response = outboundRequest
.retrieve() .retrieve()
.toEntity(String.class) .toEntity(String.class)
.block(); .block();
@@ -82,4 +90,10 @@ public class BackendProxyService {
.resolveCurrentClient() .resolveCurrentClient()
.getBackendBaseUrl(); .getBackendBaseUrl();
} }
private boolean supportsRequestBody(HttpMethod method) {
return HttpMethod.POST.equals(method)
|| HttpMethod.PUT.equals(method)
|| HttpMethod.PATCH.equals(method);
}
} }
@@ -21,7 +21,7 @@ public class GatewayController {
return backendProxyService.getHealth(); return backendProxyService.getHealth();
} }
@RequestMapping("/api/backend/**") @RequestMapping(value = "/api/backend/**", headers = "Upgrade!=websocket")
public ResponseEntity<String> proxy( public ResponseEntity<String> proxy(
HttpServletRequest request, HttpServletRequest request,
@RequestBody(required = false) String body @RequestBody(required = false) String body
@@ -1,6 +1,7 @@
package com.litoralregas.backend_gateway.gateway; package com.litoralregas.backend_gateway.gateway;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.unit.DataSize;
import java.time.Duration; import java.time.Duration;
@@ -10,6 +11,7 @@ public class ProxyProperties {
private String backendBaseUrl; private String backendBaseUrl;
private Duration connectTimeout = Duration.ofSeconds(3); private Duration connectTimeout = Duration.ofSeconds(3);
private Duration responseTimeout = Duration.ofSeconds(10); private Duration responseTimeout = Duration.ofSeconds(10);
private DataSize maxInMemorySize = DataSize.ofMegabytes(64);
public String getBackendBaseUrl() { public String getBackendBaseUrl() {
return backendBaseUrl; return backendBaseUrl;
@@ -34,4 +36,12 @@ public class ProxyProperties {
public void setResponseTimeout(Duration responseTimeout) { public void setResponseTimeout(Duration responseTimeout) {
this.responseTimeout = responseTimeout; this.responseTimeout = responseTimeout;
} }
public DataSize getMaxInMemorySize() {
return maxInMemorySize;
}
public void setMaxInMemorySize(DataSize maxInMemorySize) {
this.maxInMemorySize = maxInMemorySize;
}
} }
@@ -10,14 +10,22 @@ import org.springframework.web.socket.server.standard.ServletServerContainerFact
public class BackendWebSocketConfig implements WebSocketConfigurer { public class BackendWebSocketConfig implements WebSocketConfigurer {
private final VncGatewayWebSocketHandler vncGatewayWebSocketHandler; private final VncGatewayWebSocketHandler vncGatewayWebSocketHandler;
private final StompGatewayWebSocketHandler stompGatewayWebSocketHandler;
public BackendWebSocketConfig(VncGatewayWebSocketHandler vncGatewayWebSocketHandler) { public BackendWebSocketConfig(
VncGatewayWebSocketHandler vncGatewayWebSocketHandler,
StompGatewayWebSocketHandler stompGatewayWebSocketHandler
) {
this.vncGatewayWebSocketHandler = vncGatewayWebSocketHandler; this.vncGatewayWebSocketHandler = vncGatewayWebSocketHandler;
this.stompGatewayWebSocketHandler = stompGatewayWebSocketHandler;
} }
@Override @Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { 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("*"); .setAllowedOrigins("*");
} }
@@ -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<String> 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<String, WebSocketSession> backendSessions = new ConcurrentHashMap<>();
private final Map<String, Queue<WebSocketMessage<?>>> 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<String> 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<WebSocketMessage<?>> 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<WebSocketMessage<?>> 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");
}
}
}
@@ -0,0 +1,7 @@
package com.litoralregas.backend_gateway.runtime;
public record ClientRuntimeConfig(
Long id,
String name
) {
}
@@ -0,0 +1,8 @@
package com.litoralregas.backend_gateway.runtime;
public record GatewayRuntimeConfig(
String backendApiBasePath,
String stompWebSocketPath,
String vncWebSocketPath
) {
}
@@ -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()
)
);
}
}
@@ -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;
}
}
@@ -0,0 +1,9 @@
package com.litoralregas.backend_gateway.runtime;
public record RuntimeConfigResponse(
String mode,
ClientRuntimeConfig client,
GatewayRuntimeConfig gateway,
VncRuntimeConfig vnc
) {
}
@@ -0,0 +1,7 @@
package com.litoralregas.backend_gateway.runtime;
public record VncRuntimeConfig(
String defaultHost,
int defaultPort
) {
}
+10
View File
@@ -24,6 +24,16 @@ gateway:
backend-base-url: http://10.100.1.2:18450 backend-base-url: http://10.100.1.2:18450
connect-timeout: 3s connect-timeout: 3s
response-timeout: 10s 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: jwt:
secret: ${JWT_SECRET:backend-gateway-local-development-secret-2026-super-long} secret: ${JWT_SECRET:backend-gateway-local-development-secret-2026-super-long}
@@ -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;