Compare commits

...

16 Commits

Author SHA1 Message Date
litoral05 10e4a7b3ee runtime config, client VNC config, proxy max-in-memory-size, fixes REST proxy, limpeza do log no login. 2026-06-08 16:31:28 +01:00
litoral05 72eb393d84 Proper logged version of VncGatewayWebSocketHandler 2026-06-08 14:10:28 +01:00
litoral05 a27abfdafd Add initial backend websocket gateway 2026-06-08 12:31:53 +01:00
DiogoVieira14 3a4d70672f Modificar README.md 2026-06-03 17:21:30 +01:00
litoral05 95344a03c2 adds readme 2026-06-03 17:20:33 +01:00
litoral05 0bb25521a1 Creates default admin user 2026-06-03 15:45:08 +01:00
litoral05 8de3e42576 Load jwt configuriation from .env 2026-06-03 15:17:10 +01:00
litoral05 d5ef831efc remove debug controllers 2026-06-03 15:05:28 +01:00
litoral05 15e73677c4 Verify role based access control 2026-06-03 14:59:59 +01:00
litoral05 a72ee91bd4 Require admin role for admin endpoints 2026-06-03 14:46:29 +01:00
litoral05 102e906b36 Require authentication for backend proxy 2026-06-03 14:39:23 +01:00
litoral05 45419f23c1 Route proxy using authenticated client 2026-06-03 14:35:41 +01:00
litoral05 2203b0f2c3 Add jwt authentication filter 2026-06-03 14:29:02 +01:00
litoral05 4b67ba9995 Add jwt token parsing 2026-06-03 14:13:10 +01:00
litoral05 5d422e1608 Add jwt token generation 2026-06-03 14:08:00 +01:00
litoral05 04f4732da1 Add login endpoint 2026-06-03 12:23:08 +01:00
39 changed files with 2181 additions and 27 deletions
+3
View File
@@ -31,3 +31,6 @@ build/
### VS Code ###
.vscode/
.env
data/*.db
+373
View File
@@ -0,0 +1,373 @@
# Backend Gateway
Version: v1.0.0
## Overview
Backend Gateway is the central access layer between client applications (Tauri, Web, Mobile) and site-specific backend services.
The gateway provides:
* Authentication
* Authorization
* Multi-client routing
* Backend proxying
* Centralized user management
* Secure access through JWT
The goal is to expose a single public endpoint while keeping site backends private behind WireGuard.
---
# Architecture
```text
Tauri/Web App
Backend Gateway (VPS)
WireGuard Tunnel
Site Backend
PLC / Modbus / VNC / Local Services
```
Client applications never communicate directly with site backends.
All traffic goes through the gateway.
---
# Technology Stack
* Java 21
* Spring Boot 3
* Spring Security
* JWT (JJWT)
* Flyway
* SQLite
* WireGuard
---
# Database
SQLite database:
```text
./data/backend-gateway.db
```
Schema managed exclusively through Flyway migrations.
Hibernate schema generation is disabled.
```yaml
spring:
jpa:
hibernate:
ddl-auto: none
```
---
# Authentication
Authentication uses username/password credentials.
Endpoint:
```http
POST /auth/login
```
Request:
```json
{
"username": "admin",
"password": "admin123"
}
```
Response:
```json
{
"accessToken": "...",
"tokenType": "Bearer",
"userId": 1,
"clientId": 1,
"clientName": "dev-local",
"username": "admin",
"role": "ADMIN"
}
```
Passwords are stored using BCrypt.
---
# Authorization
Roles currently supported:
```text
ADMIN
CLIENT_USER
```
ADMIN:
* Manage clients
* Manage users
* Access backend proxy
CLIENT_USER:
* Access backend proxy
* No administrative access
---
# JWT
JWT authentication is stateless.
Claims:
```json
{
"sub": "username",
"userId": 1,
"clientId": 1,
"role": "ADMIN"
}
```
Protected requests require:
```http
Authorization: Bearer <token>
```
---
# Multi-Client Architecture
Each user belongs to a client.
Each client defines a backend endpoint:
```text
client
└─ backendBaseUrl
```
Example:
```text
Client:
dev-local
Backend:
http://10.100.1.2:18450
```
When a user authenticates:
```text
JWT
└─ clientId
```
The gateway uses the clientId to determine which backend should receive proxied requests.
---
# Backend Proxy
Proxy endpoint:
```http
/api/backend/**
```
Example:
```http
GET /api/backend/actuator/health
```
Gateway flow:
```text
JWT
└─ clientId
clientId
└─ backendBaseUrl
backendBaseUrl
└─ target backend request
```
Users never see backend addresses.
---
# Flyway Migrations
Current migrations:
```text
V1__create_clients.sql
V2__create_users.sql
V3__bootstrap_default_admin.sql
```
Flyway automatically applies migrations on startup.
---
# Bootstrap Administrator
A default administrator is created automatically on a fresh installation.
Default credentials:
```text
Username: admin
Password: admin123
```
IMPORTANT:
Change this password immediately in production.
---
# Environment Variables
Required:
```env
JWT_SECRET=<secret>
```
Optional:
```env
JWT_EXPIRATION_MINUTES=1440
```
Example:
```env
JWT_SECRET=very-long-random-production-secret
JWT_EXPIRATION_MINUTES=1440
```
Configuration:
```yaml
jwt:
secret: ${JWT_SECRET}
expiration-minutes: ${JWT_EXPIRATION_MINUTES:1440}
```
---
# VPS Deployment
Required directory:
```text
backend-gateway/
├── data/
├── target/
├── .env
```
Database location:
```text
./data/backend-gateway.db
```
The data directory must exist before startup.
Example:
```bash
mkdir -p data
```
---
# Current Features (v1.0.0)
Implemented:
* JWT Authentication
* BCrypt Passwords
* Role Authorization
* Multi-Client Support
* Client Management
* User Management
* Backend Proxy
* Flyway Migrations
* Bootstrap Admin User
* Environment-Based Secrets
* SQLite Persistence
---
# Planned Features
v1.1
* Change Password Endpoint
* Disable User Endpoint
* Disable Client Endpoint
* Update Client Backend URL
Future
* WebSocket Proxy
* VNC Proxy Support
* Audit Logging
* Refresh Tokens
* User Password Reset
* Client Administration UI
---
# Security Notes
* Never commit .env files.
* Never store JWT secrets in source control.
* Always use HTTPS in production.
* Change bootstrap administrator credentials after installation.
* Keep site backends private behind WireGuard.
---
# Status
Current release:
```text
v1.0.0
```
State:
```text
Production deployable
```
Binary file not shown.
+23
View File
@@ -72,6 +72,29 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.7</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.7</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.7</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
</dependencies>
<build>
@@ -1,15 +1,21 @@
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;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication
@EnableConfigurationProperties(ProxyProperties.class)
@EnableConfigurationProperties({
ProxyProperties.class,
RuntimeConfigProperties.class,
JwtProperties.class
})
public class BackendGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(BackendGatewayApplication.class, args);
}
}
}
@@ -0,0 +1,21 @@
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.web.bind.annotation.*;
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/login")
public LoginResponse login(@RequestBody LoginRequest request) {
return authService.login(request);
}
}
@@ -0,0 +1,58 @@
package com.litoralregas.backend_gateway.auth;
import com.litoralregas.backend_gateway.auth.dto.LoginRequest;
import com.litoralregas.backend_gateway.auth.dto.LoginResponse;
import com.litoralregas.backend_gateway.security.JwtService;
import com.litoralregas.backend_gateway.user.UserEntity;
import com.litoralregas.backend_gateway.user.UserRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
public AuthService(
UserRepository userRepository,
PasswordEncoder passwordEncoder,
JwtService jwtService
) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
}
public LoginResponse login(LoginRequest request) {
UserEntity user = userRepository.findByUsername(request.username())
.orElseThrow(InvalidCredentialsException::new);
if (!user.isEnabled()) {
throw new InvalidCredentialsException();
}
boolean valid = passwordEncoder.matches(
request.password(),
user.getPasswordHash()
);
if (!valid) {
throw new InvalidCredentialsException();
}
String accessToken = jwtService.generateToken(user);
return new LoginResponse(
accessToken,
"Bearer",
user.getId(),
user.getClient().getId(),
user.getClient().getName(),
user.getUsername(),
user.getRole()
);
}
}
@@ -0,0 +1,12 @@
package com.litoralregas.backend_gateway.auth;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public class InvalidCredentialsException extends RuntimeException {
public InvalidCredentialsException() {
super("Invalid credentials");
}
}
@@ -0,0 +1,7 @@
package com.litoralregas.backend_gateway.auth.dto;
public record LoginRequest(
String username,
String password
) {
}
@@ -0,0 +1,14 @@
package com.litoralregas.backend_gateway.auth.dto;
import com.litoralregas.backend_gateway.user.UserRole;
public record LoginResponse(
String accessToken,
String tokenType,
Long userId,
Long clientId,
String clientName,
String username,
UserRole role
) {
}
@@ -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;
}
}
}
@@ -7,4 +7,5 @@ import java.util.Optional;
public interface ClientRepository extends JpaRepository<ClientEntity, Long> {
Optional<ClientEntity> findByName(String name);
Optional<ClientEntity> findByIdAndEnabledTrue(Long id);
}
@@ -0,0 +1,35 @@
package com.litoralregas.backend_gateway.client;
import com.litoralregas.backend_gateway.security.AuthenticatedUser;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
@Service
public class ClientResolver {
private final ClientRepository clientRepository;
public ClientResolver(ClientRepository clientRepository) {
this.clientRepository = clientRepository;
}
public ClientEntity resolveCurrentClient() {
Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
throw new RuntimeException("Not authenticated");
}
Object principal = authentication.getPrincipal();
if (!(principal instanceof AuthenticatedUser user)) {
throw new RuntimeException("Invalid authentication principal");
}
return clientRepository
.findByIdAndEnabledTrue(user.clientId())
.orElseThrow(() -> new RuntimeException("Client not found"));
}
}
@@ -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()
);
}
}
}
@@ -4,7 +4,9 @@ public record ClientResponse(
Long id,
String name,
String backendBaseUrl,
String defaultVncHost,
int defaultVncPort,
boolean enabled,
String createdAt
) {
}
}
@@ -2,6 +2,8 @@ package com.litoralregas.backend_gateway.client.dto;
public record CreateClientRequest(
String name,
String backendBaseUrl
String backendBaseUrl,
String defaultVncHost,
Integer defaultVncPort
) {
}
}
@@ -0,0 +1,17 @@
package com.litoralregas.backend_gateway.auth;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class AuthExceptionHandler {
@ExceptionHandler(InvalidCredentialsException.class)
public ResponseEntity<String> handleInvalidCredentials() {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("Invalid credentials");
}
}
@@ -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,27 +1,96 @@
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;
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("/api/backend/ws").permitAll()
.requestMatchers("/api/backend/ws/vnc").permitAll()
.requestMatchers("/api/runtime/**").authenticated()
.requestMatchers("/api/backend/**").authenticated()
.anyRequest().permitAll()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.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();
}
}
}
@@ -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();
}
}
}
@@ -1,5 +1,6 @@
package com.litoralregas.backend_gateway.gateway;
import com.litoralregas.backend_gateway.client.ClientResolver;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
@@ -8,23 +9,24 @@ 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 {
private final WebClient webClient;
private final ProxyProperties proxyProperties;
private final ClientResolver clientResolver;
public BackendProxyService(WebClient webClient, ProxyProperties proxyProperties) {
public BackendProxyService(
WebClient webClient,
ClientResolver clientResolver
) {
this.webClient = webClient;
this.proxyProperties = proxyProperties;
this.clientResolver = clientResolver;
}
public String getHealth() {
return webClient.get()
.uri(proxyProperties.getBackendBaseUrl() + "/actuator/health")
.retrieve()
.bodyToMono(String.class)
.block();
return "UP";
}
public ResponseEntity<String> proxy(HttpServletRequest request, String body) {
@@ -33,14 +35,16 @@ public class BackendProxyService {
String query = request.getQueryString();
String targetUrl =
proxyProperties.getBackendBaseUrl()
resolveBackendUrl()
+ path
+ (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<String> response = requestSpec
.bodyValue(body != null ? body : "")
WebClient.RequestHeadersSpec<?> outboundRequest =
supportsRequestBody(method)
? requestSpec.bodyValue(body != null ? body : "")
: requestSpec;
ResponseEntity<String> response = outboundRequest
.retrieve()
.toEntity(String.class)
.block();
@@ -76,4 +84,16 @@ public class BackendProxyService {
.body("Backend unavailable");
}
}
}
private String resolveBackendUrl() {
return clientResolver
.resolveCurrentClient()
.getBackendBaseUrl();
}
private boolean supportsRequestBody(HttpMethod method) {
return HttpMethod.POST.equals(method)
|| HttpMethod.PUT.equals(method)
|| HttpMethod.PATCH.equals(method);
}
}
@@ -21,11 +21,11 @@ public class GatewayController {
return backendProxyService.getHealth();
}
@RequestMapping("/api/backend/**")
@RequestMapping(value = "/api/backend/**", headers = "Upgrade!=websocket")
public ResponseEntity<String> proxy(
HttpServletRequest request,
@RequestBody(required = false) String body
) {
return backendProxyService.proxy(request, body);
}
}
}
@@ -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;
}
}
public DataSize getMaxInMemorySize() {
return maxInMemorySize;
}
public void setMaxInMemorySize(DataSize maxInMemorySize) {
this.maxInMemorySize = maxInMemorySize;
}
}
@@ -0,0 +1,43 @@
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;
private final StompGatewayWebSocketHandler stompGatewayWebSocketHandler;
public BackendWebSocketConfig(
VncGatewayWebSocketHandler vncGatewayWebSocketHandler,
StompGatewayWebSocketHandler stompGatewayWebSocketHandler
) {
this.vncGatewayWebSocketHandler = vncGatewayWebSocketHandler;
this.stompGatewayWebSocketHandler = stompGatewayWebSocketHandler;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(stompGatewayWebSocketHandler, "/api/backend/ws")
.setAllowedOrigins("*");
registry.addHandler(vncGatewayWebSocketHandler, "/api/backend/ws/vnc")
.setAllowedOrigins("*");
}
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container =
new ServletServerContainerFactoryBean();
container.setMaxBinaryMessageBufferSize(4 * 1024 * 1024);
container.setMaxTextMessageBufferSize(1024 * 1024);
container.setMaxSessionIdleTimeout(0L);
return container;
}
}
@@ -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,508 @@
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.BinaryMessage;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.PingMessage;
import org.springframework.web.socket.PongMessage;
import org.springframework.web.socket.TextMessage;
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.BinaryWebSocketHandler;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
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 static final Logger log = LoggerFactory.getLogger(VncGatewayWebSocketHandler.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 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(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(
"VNC frontend websocket connected. frontendSession={}, uri={}",
frontendSession.getId(),
frontendSession.getUri()
);
try {
String token = extractAccessToken(frontendSession);
if (token == null || token.isBlank()) {
log.warn("VNC 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("VNC 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 = toBackendVncWebSocketUrl(client.getBackendBaseUrl());
log.info(
"Opening VNC backend websocket. frontendSession={}, userId={}, username={}, role={}, clientId={}, clientName={}, backendUrl={}",
frontendSession.getId(),
userId,
username,
role,
client.getId(),
client.getName(),
backendWebSocketUrl
);
webSocketClient.execute(
new BackendVncBridgeHandler(frontendSession),
backendWebSocketUrl
).whenComplete((backendSession, error) -> {
if (error != null) {
log.error(
"VNC backend websocket connection failed. frontendSession={}, clientId={}, backendUrl={}",
frontendSession.getId(),
client.getId(),
backendWebSocketUrl,
error
);
sendError(frontendSession, "Could not connect to backend VNC websocket");
closeSafe(frontendSession, "backend websocket connection failed");
return;
}
backendSessions.put(frontendSession.getId(), backendSession);
log.info(
"VNC backend websocket connected. frontendSession={}, backendSession={}, backendUri={}",
frontendSession.getId(),
backendSession.getId(),
backendSession.getUri()
);
flushPendingMessages(frontendSession, backendSession);
});
} catch (Exception error) {
log.error(
"VNC frontend websocket setup failed. frontendSession={}",
frontendSession.getId(),
error
);
sendError(frontendSession, error.getMessage());
closeSafe(frontendSession, "setup exception");
}
}
@Override
protected void handleTextMessage(
WebSocketSession frontendSession,
TextMessage message
) {
log.debug(
"VNC frontend text message received. frontendSession={}, size={}",
frontendSession.getId(),
message.getPayloadLength()
);
forwardOrQueue(frontendSession, message);
}
@Override
protected void handleBinaryMessage(
WebSocketSession frontendSession,
BinaryMessage message
) {
log.debug(
"VNC frontend binary message received. frontendSession={}, size={}",
frontendSession.getId(),
message.getPayloadLength()
);
forwardOrQueue(frontendSession, message);
}
@Override
public void afterConnectionClosed(
WebSocketSession frontendSession,
CloseStatus status
) {
log.info(
"VNC 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(
"VNC 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 VNC 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 VNC 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 VNC 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 toBackendVncWebSocketUrl(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/vnc";
}
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("VNC websocket send skipped because session is null. direction={}", direction);
return;
}
if (!session.isOpen()) {
log.debug(
"VNC websocket send skipped because session is closed. direction={}, session={}",
direction,
session.getId()
);
return;
}
synchronized (session) {
session.sendMessage(message);
}
log.debug(
"VNC websocket message sent. direction={}, session={}, type={}, size={}",
direction,
session.getId(),
messageType(message),
payloadLength(message)
);
} catch (IllegalStateException closed) {
log.debug("VNC websocket send skipped because session was already closed. direction={}", direction);
} catch (Exception error) {
log.warn(
"VNC 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 VNC websocket session. session={}, reason={}",
session.getId(),
reason
);
session.close();
} catch (Exception error) {
log.warn(
"Failed to close VNC 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 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 class BackendVncBridgeHandler extends BinaryWebSocketHandler {
private final WebSocketSession frontendSession;
private BackendVncBridgeHandler(WebSocketSession frontendSession) {
this.frontendSession = frontendSession;
}
@Override
public void afterConnectionEstablished(WebSocketSession backendSession) {
log.debug(
"VNC backend bridge handler established. frontendSession={}, backendSession={}, backendUri={}",
frontendSession.getId(),
backendSession.getId(),
backendSession.getUri()
);
}
@Override
protected void handleTextMessage(
WebSocketSession backendSession,
TextMessage message
) {
log.debug(
"VNC backend text message received. backendSession={}, frontendSession={}, size={}",
backendSession.getId(),
frontendSession.getId(),
message.getPayloadLength()
);
sendSafe(frontendSession, message, "backend -> frontend");
}
@Override
protected void handleBinaryMessage(
WebSocketSession backendSession,
BinaryMessage message
) {
log.debug(
"VNC backend binary message received. backendSession={}, frontendSession={}, size={}",
backendSession.getId(),
frontendSession.getId(),
message.getPayloadLength()
);
sendSafe(frontendSession, message, "backend -> frontend");
}
@Override
public void afterConnectionClosed(
WebSocketSession backendSession,
CloseStatus status
) {
log.info(
"VNC 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(
"VNC 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
) {
}
@@ -0,0 +1,11 @@
package com.litoralregas.backend_gateway.security;
import com.litoralregas.backend_gateway.user.UserRole;
public record AuthenticatedUser(
Long userId,
Long clientId,
String username,
UserRole role
) {
}
@@ -0,0 +1,70 @@
package com.litoralregas.backend_gateway.security;
import com.litoralregas.backend_gateway.user.UserRole;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
public JwtAuthenticationFilter(JwtService jwtService) {
this.jwtService = jwtService;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
if (!jwtService.isValid(token)) {
filterChain.doFilter(request, response);
return;
}
String username = jwtService.extractUsername(token);
Long userId = jwtService.extractUserId(token);
Long clientId = jwtService.extractClientId(token);
UserRole role = UserRole.valueOf(jwtService.extractRole(token));
AuthenticatedUser authenticatedUser = new AuthenticatedUser(
userId,
clientId,
username,
role
);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
authenticatedUser,
null,
List.of(new SimpleGrantedAuthority("ROLE_" + role.name()))
);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,26 @@
package com.litoralregas.backend_gateway.security;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
private String secret;
private long expirationMinutes;
public String getSecret() {
return secret;
}
public long getExpirationMinutes() {
return expirationMinutes;
}
public void setSecret(String secret) {
this.secret = secret;
}
public void setExpirationMinutes(long expirationMinutes) {
this.expirationMinutes = expirationMinutes;
}
}
@@ -0,0 +1,101 @@
package com.litoralregas.backend_gateway.security;
import com.litoralregas.backend_gateway.user.UserEntity;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
@Service
public class JwtService {
private final JwtProperties jwtProperties;
public JwtService(JwtProperties jwtProperties) {
this.jwtProperties = jwtProperties;
}
public String generateToken(UserEntity user) {
Instant now = Instant.now();
Instant expiresAt = now.plusSeconds(jwtProperties.getExpirationMinutes() * 60);
return Jwts.builder()
.subject(user.getUsername())
.claim("userId", user.getId())
.claim("clientId", user.getClient().getId())
.claim("role", user.getRole().name())
.issuedAt(Date.from(now))
.expiration(Date.from(expiresAt))
.signWith(getSigningKey())
.compact();
}
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(
jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8)
);
}
private Claims getClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
public boolean isValid(String token) {
try {
getClaims(token);
return true;
} catch (JwtException ex) {
return false;
}
}
public String extractUsername(String token) {
return getClaims(token).getSubject();
}
public Long extractClientId(String token) {
Object value = getClaims(token).get("clientId");
if (value instanceof Integer integer) {
return integer.longValue();
}
if (value instanceof Long longValue) {
return longValue;
}
return Long.parseLong(value.toString());
}
public String extractRole(String token) {
return getClaims(token).get("role", String.class);
}
public Long extractUserId(String token) {
Object value = getClaims(token).get("userId");
if (value instanceof Integer integer) {
return integer.longValue();
}
if (value instanceof Long longValue) {
return longValue;
}
return Long.parseLong(value.toString());
}
}
+15 -1
View File
@@ -23,4 +23,18 @@ gateway:
proxy:
backend-base-url: http://10.100.1.2:18450
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:
secret: ${JWT_SECRET:backend-gateway-local-development-secret-2026-super-long}
expiration-minutes: ${JWT_EXPIRATION_MINUTES:1440}
@@ -0,0 +1,16 @@
INSERT INTO clients (name, backend_base_url, enabled)
SELECT 'dev-local', 'http://10.100.1.2:18450', 1
WHERE NOT EXISTS (
SELECT 1 FROM clients WHERE name = 'dev-local'
);
INSERT INTO users (client_id, username, password_hash, role, enabled)
SELECT
(SELECT id FROM clients WHERE name = 'dev-local'),
'admin',
'$2a$10$it1vy5t1FXISQTWit2A39udaMR0N0yqJtJUnxMqF1Xz4SuNBaam6u',
'ADMIN',
1
WHERE NOT EXISTS (
SELECT 1 FROM users WHERE username = 'admin'
);
@@ -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;