Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10e4a7b3ee | |||
| 72eb393d84 | |||
| a27abfdafd | |||
| 3a4d70672f | |||
| 95344a03c2 |
@@ -33,3 +33,4 @@ build/
|
|||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
data/*.db
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -91,6 +91,10 @@
|
|||||||
<version>0.12.7</version>
|
<version>0.12.7</version>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -16,4 +18,4 @@ public class BackendGatewayApplication {
|
|||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(BackendGatewayApplication.class, args);
|
SpringApplication.run(BackendGatewayApplication.class, args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +64,15 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,16 +15,18 @@ public class ClientResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ClientEntity resolveCurrentClient() {
|
public ClientEntity resolveCurrentClient() {
|
||||||
|
|
||||||
Authentication authentication =
|
Authentication authentication =
|
||||||
SecurityContextHolder.getContext().getAuthentication();
|
SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
|
||||||
if (authentication == null) {
|
if (authentication == null || !authentication.isAuthenticated()) {
|
||||||
throw new RuntimeException("Not authenticated");
|
throw new RuntimeException("Not authenticated");
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthenticatedUser user =
|
Object principal = authentication.getPrincipal();
|
||||||
(AuthenticatedUser) authentication.getPrincipal();
|
|
||||||
|
if (!(principal instanceof AuthenticatedUser user)) {
|
||||||
|
throw new RuntimeException("Invalid authentication principal");
|
||||||
|
}
|
||||||
|
|
||||||
return clientRepository
|
return clientRepository
|
||||||
.findByIdAndEnabledTrue(user.clientId())
|
.findByIdAndEnabledTrue(user.clientId())
|
||||||
|
|||||||
@@ -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,8 +51,10 @@ 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,7 +4,9 @@ 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,28 +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())
|
||||||
|
.formLogin(formLogin -> formLogin.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("/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()
|
||||||
)
|
)
|
||||||
@@ -30,8 +55,42 @@ 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,11 +21,11 @@ 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
|
||||||
) {
|
) {
|
||||||
return backendProxyService.proxy(request, body);
|
return backendProxyService.proxy(request, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+43
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+503
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+508
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -24,7 +24,17 @@ 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}
|
secret: ${JWT_SECRET:backend-gateway-local-development-secret-2026-super-long}
|
||||||
expiration-minutes: ${JWT_EXPIRATION_MINUTES:1440}
|
expiration-minutes: ${JWT_EXPIRATION_MINUTES:1440}
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user