Working HTTP tunnel

This commit is contained in:
2025-07-05 06:46:17 +00:00
parent 168bae2812
commit 67335a4e0d
32 changed files with 1373 additions and 52 deletions

47
Dockerfile Normal file
View File

@ -0,0 +1,47 @@
# Multi-stage build for tunnel server
FROM maven:3.9.6-eclipse-temurin-21 AS builder
WORKDIR /app
# Copy pom.xml first for better Docker layer caching
COPY pom.xml .
RUN mvn dependency:go-offline -B
# Copy source code and build
COPY src ./src
RUN mvn clean package -DskipTests
# Runtime stage
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# Create data directory for H2 database
RUN mkdir -p /app/data
# Create non-root user
RUN addgroup -g 1001 -S tunnel && \
adduser -S tunnel -u 1001 -G tunnel
# Copy the jar file
COPY --from=builder /app/target/*.jar app.jar
# Change ownership of the app directory
RUN chown -R tunnel:tunnel /app
# Switch to non-root user
USER tunnel
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:5678/actuator/health || exit 1
# Expose port
EXPOSE 5678
# Environment variables
ENV SPRING_PROFILES_ACTIVE=docker
ENV JAVA_OPTS="-Xmx512m -Xms256m"
# Run the application
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

BIN
data/testdb.mv.db Normal file

Binary file not shown.

90
data/testdb.trace.db Normal file
View File

@ -0,0 +1,90 @@
2025-07-03 16:43:24.190635Z database: flush
org.h2.message.DbException: General error: "org.h2.mvstore.MVStoreException: The file is locked: /home/thinh/dev/personal/projects/tunnel/server/data/testdb.mv.db [2.3.232/7]" [50000-232]
at org.h2.message.DbException.get(DbException.java:212)
at org.h2.message.DbException.convert(DbException.java:407)
at org.h2.mvstore.db.Store.lambda$new$0(Store.java:122)
at org.h2.mvstore.MVStore.handleException(MVStore.java:1546)
at org.h2.mvstore.MVStore.panic(MVStore.java:371)
at org.h2.mvstore.MVStore.<init>(MVStore.java:291)
at org.h2.mvstore.MVStore$Builder.open(MVStore.java:2035)
at org.h2.mvstore.db.Store.<init>(Store.java:133)
at org.h2.engine.Database.<init>(Database.java:326)
at org.h2.engine.Engine.openSession(Engine.java:92)
at org.h2.engine.Engine.openSession(Engine.java:222)
at org.h2.engine.Engine.createSession(Engine.java:201)
at org.h2.engine.SessionRemote.connectEmbeddedOrServer(SessionRemote.java:344)
at org.h2.jdbc.JdbcConnection.<init>(JdbcConnection.java:124)
at org.h2.Driver.connect(Driver.java:59)
at com.intellij.database.remote.jdbc.helpers.JdbcHelperImpl.connect(JdbcHelperImpl.java:769)
at com.intellij.database.remote.jdbc.impl.RemoteDriverImpl.connect(RemoteDriverImpl.java:152)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:714)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:400)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Caused by: org.h2.jdbc.JdbcSQLNonTransientException: General error: "org.h2.mvstore.MVStoreException: The file is locked: /home/thinh/dev/personal/projects/tunnel/server/data/testdb.mv.db [2.3.232/7]" [50000-232]
at org.h2.message.DbException.getJdbcSQLException(DbException.java:566)
at org.h2.message.DbException.getJdbcSQLException(DbException.java:489)
... 32 more
Caused by: org.h2.mvstore.MVStoreException: The file is locked: /home/thinh/dev/personal/projects/tunnel/server/data/testdb.mv.db [2.3.232/7]
at org.h2.mvstore.DataUtils.newMVStoreException(DataUtils.java:996)
at org.h2.mvstore.SingleFileStore.lockFileChannel(SingleFileStore.java:143)
at org.h2.mvstore.SingleFileStore.open(SingleFileStore.java:117)
at org.h2.mvstore.SingleFileStore.open(SingleFileStore.java:81)
at org.h2.mvstore.MVStore.<init>(MVStore.java:286)
... 26 more
2025-07-03 16:43:24.219550Z database: flush
org.h2.message.DbException: General error: "org.h2.mvstore.MVStoreException: The file is locked: /home/thinh/dev/personal/projects/tunnel/server/data/testdb.mv.db [2.3.232/7]" [50000-232]
at org.h2.message.DbException.get(DbException.java:212)
at org.h2.message.DbException.convert(DbException.java:407)
at org.h2.mvstore.db.Store.lambda$new$0(Store.java:122)
at org.h2.mvstore.MVStore.handleException(MVStore.java:1546)
at org.h2.mvstore.MVStore.panic(MVStore.java:371)
at org.h2.mvstore.MVStore.<init>(MVStore.java:291)
at org.h2.mvstore.MVStore$Builder.open(MVStore.java:2035)
at org.h2.mvstore.db.Store.<init>(Store.java:133)
at org.h2.engine.Database.<init>(Database.java:326)
at org.h2.engine.Engine.openSession(Engine.java:92)
at org.h2.engine.Engine.openSession(Engine.java:222)
at org.h2.engine.Engine.createSession(Engine.java:201)
at org.h2.engine.SessionRemote.connectEmbeddedOrServer(SessionRemote.java:344)
at org.h2.jdbc.JdbcConnection.<init>(JdbcConnection.java:124)
at org.h2.Driver.connect(Driver.java:59)
at com.intellij.database.remote.jdbc.helpers.JdbcHelperImpl.connect(JdbcHelperImpl.java:769)
at com.intellij.database.remote.jdbc.impl.RemoteDriverImpl.connect(RemoteDriverImpl.java:152)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:714)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:400)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Caused by: org.h2.jdbc.JdbcSQLNonTransientException: General error: "org.h2.mvstore.MVStoreException: The file is locked: /home/thinh/dev/personal/projects/tunnel/server/data/testdb.mv.db [2.3.232/7]" [50000-232]
at org.h2.message.DbException.getJdbcSQLException(DbException.java:566)
at org.h2.message.DbException.getJdbcSQLException(DbException.java:489)
... 32 more
Caused by: org.h2.mvstore.MVStoreException: The file is locked: /home/thinh/dev/personal/projects/tunnel/server/data/testdb.mv.db [2.3.232/7]
at org.h2.mvstore.DataUtils.newMVStoreException(DataUtils.java:996)
at org.h2.mvstore.SingleFileStore.lockFileChannel(SingleFileStore.java:143)
at org.h2.mvstore.SingleFileStore.open(SingleFileStore.java:117)
at org.h2.mvstore.SingleFileStore.open(SingleFileStore.java:81)
at org.h2.mvstore.MVStore.<init>(MVStore.java:286)
... 26 more

55
pom.xml
View File

@ -35,15 +35,70 @@
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>10.3.1</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.6.3</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.6.3</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
<configuration>
<source>24</source>
<target>24</target>
<compilerArgs>
<arg>--enable-preview</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>

View File

@ -2,6 +2,7 @@ package dev.thinhha.tunnel_server;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
@SpringBootApplication
public class TunnelServerApplication {

View File

@ -0,0 +1,14 @@
package dev.thinhha.tunnel_server.config;
public class Constant {
public static final String ALPHANUMERIC = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
public static final String UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
public static final String LOWERCASE = "abcdefghijklmnopqrstuvwxyz";
public static final String DIGITS = "0123456789";
public static final String SPECIAL_CHARS = "!@#$%^&*()_+-=[]{}|;:,.<>?";
public static final String ALL_CHARS = ALPHANUMERIC + SPECIAL_CHARS;
public static final int CLIENT_SECRET_LENGTH = 64;
private Constant() {
}
}

View File

@ -0,0 +1,8 @@
package dev.thinhha.tunnel_server.config;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.handler.BinaryWebSocketHandler;
@Component
public class FacadeWebSocketHandler extends BinaryWebSocketHandler {
}

View File

@ -0,0 +1,67 @@
package dev.thinhha.tunnel_server.config;
import dev.thinhha.tunnel_server.service.JwtService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.Map;
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtWebSocketHandshakeInterceptor implements HandshakeInterceptor {
private final JwtService jwtService;
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
try {
String token = extractTokenFromRequest(request);
if (token == null) {
log.warn("No JWT token found in WebSocket handshake");
return false;
}
String clientName = jwtService.extractClientName(token);
attributes.put("clientName", clientName);
log.info("WebSocket handshake authenticated for client: {}", clientName);
return true;
} catch (Exception e) {
log.error("JWT authentication failed for WebSocket handshake", e);
return false;
}
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Exception exception) {
// No action needed after handshake
}
private String extractTokenFromRequest(ServerHttpRequest request) {
// Try to get token from Authorization header
String authHeader = request.getHeaders().getFirst("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
// Try to get token from query parameter
String query = request.getURI().getQuery();
if (query != null) {
Map<String, String> params = UriComponentsBuilder.fromUriString("?" + query)
.build()
.getQueryParams()
.toSingleValueMap();
return params.get("token");
}
return null;
}
}

View File

@ -0,0 +1,95 @@
package dev.thinhha.tunnel_server.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.thinhha.tunnel_server.dto.TunnelResponseDto;
import dev.thinhha.tunnel_server.service.TunnelService;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.BinaryMessage;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.BinaryWebSocketHandler;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
@Component
@Slf4j
public class WebSocketHandler extends BinaryWebSocketHandler {
private final Set<WebSocketSession> sessions = new CopyOnWriteArraySet<>();
private final ConcurrentHashMap<String, WebSocketSession> clientSessions = new ConcurrentHashMap<>();
private final ObjectMapper objectMapper;
private final TunnelService tunnelService;
public WebSocketHandler(ObjectMapper objectMapper,
TunnelService tunnelService) {
this.objectMapper = objectMapper;
this.tunnelService = tunnelService;
}
@Override
protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception {
try {
byte[] payload = message.getPayload().array();
TunnelResponseDto response = objectMapper.readValue(payload, TunnelResponseDto.class);
if (tunnelService != null) {
tunnelService.handleTunnelResponse(response);
}
} catch (Exception e) {
log.error("Error processing binary message from session {}: {}", session.getId(), e.getMessage());
}
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
log.error("Transport error for session {}: {}", session.getId(), exception.getMessage());
sessions.remove(session);
session.close();
}
@Override
public void afterConnectionEstablished(final @NonNull WebSocketSession session) throws Exception {
sessions.add(session);
// Extract client name from session attributes (set by JWT interceptor)
String clientName = (String) session.getAttributes().get("clientName");
if (clientName != null) {
clientSessions.put(clientName, session);
log.info("Client {} connected with name: {}", session.getId(), clientName);
} else {
log.warn("Client {} connected without valid client name", session.getId());
clientSessions.put(session.getId(), session);
}
}
@Override
public void afterConnectionClosed(final @NonNull WebSocketSession session, final @NonNull CloseStatus status) throws Exception {
sessions.remove(session);
// Remove from clientSessions by finding the entry with this session
String clientName = (String) session.getAttributes().get("clientName");
if (clientName != null) {
clientSessions.remove(clientName);
log.info("Client {} disconnected (name: {})", session.getId(), clientName);
} else {
clientSessions.remove(session.getId());
log.info("Client {} disconnected", session.getId());
}
}
@Override
public boolean supportsPartialMessages() {
return true;
}
public WebSocketSession getClientSession(String clientShortName) {
return clientSessions.get(clientShortName);
}
}

View File

@ -0,0 +1,230 @@
package dev.thinhha.tunnel_server.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.thinhha.tunnel_server.dto.TunnelRequestDto;
import dev.thinhha.tunnel_server.dto.TunnelResponseDto;
import dev.thinhha.tunnel_server.service.TunnelService;
import dev.thinhha.tunnel_server.types.TunnelRequestType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.*;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Component
@Slf4j
public class WebSocketTunnelHandler implements org.springframework.web.socket.WebSocketHandler {
private final TunnelService tunnelService;
private final ObjectMapper objectMapper;
public WebSocketTunnelHandler(TunnelService tunnelService, ObjectMapper objectMapper) {
this.tunnelService = tunnelService;
this.objectMapper = objectMapper;
}
// Track WebSocket connections: clientId/path -> Map<wsConnectionId, WebSocketSession>
private final Map<String, Map<String, WebSocketSession>> webSocketConnections = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String path = session.getUri().getPath();
String clientId = extractClientIdFromPath(path);
String wsPath = extractWsPathFromPath(path);
String wsConnectionId = UUID.randomUUID().toString();
log.info("WebSocket connection established for client: {} path: {} wsId: {}",
clientId, wsPath, wsConnectionId);
// Store WebSocket session
webSocketConnections.computeIfAbsent(clientId + wsPath, k -> new ConcurrentHashMap<>())
.put(wsConnectionId, session);
// Store connection ID in session attributes
session.getAttributes().put("wsConnectionId", wsConnectionId);
session.getAttributes().put("clientId", clientId);
session.getAttributes().put("wsPath", wsPath);
// Send WebSocket connection request to tunnel client
TunnelRequestDto tunnelRequest = new TunnelRequestDto();
tunnelRequest.setRequestId(UUID.randomUUID().toString());
tunnelRequest.setType(TunnelRequestType.WS_CONNECT);
tunnelRequest.setPath(wsPath);
tunnelRequest.setHeaders(extractHeaders(session));
tunnelRequest.setClientShortName(clientId);
tunnelRequest.setWsConnectionId(wsConnectionId);
try {
tunnelService.sendTunnelRequest(tunnelRequest);
} catch (Exception e) {
log.error("Failed to send WebSocket connect request to tunnel client", e);
session.close(CloseStatus.SERVER_ERROR);
}
}
@Override
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
if (message instanceof TextMessage textMessage) {
handleWebSocketMessage(session, textMessage.getPayload().getBytes(), "TEXT");
} else if (message instanceof BinaryMessage binaryMessage) {
handleWebSocketMessage(session, binaryMessage.getPayload().array(), "BINARY");
}
}
private void handleWebSocketMessage(WebSocketSession session, byte[] messageBody, String messageType) throws Exception {
String wsConnectionId = (String) session.getAttributes().get("wsConnectionId");
String clientId = (String) session.getAttributes().get("clientId");
String wsPath = (String) session.getAttributes().get("wsPath");
if (wsConnectionId == null || clientId == null) {
log.warn("WebSocket message received without proper connection tracking");
return;
}
// Forward message to tunnel client
TunnelRequestDto tunnelRequest = new TunnelRequestDto();
tunnelRequest.setRequestId(UUID.randomUUID().toString());
tunnelRequest.setType(TunnelRequestType.WS_MESSAGE);
tunnelRequest.setPath(wsPath);
tunnelRequest.setBody(messageBody);
tunnelRequest.setClientShortName(clientId);
tunnelRequest.setWsConnectionId(wsConnectionId);
tunnelRequest.setWsMessageType(messageType);
try {
tunnelService.sendTunnelRequest(tunnelRequest);
} catch (Exception e) {
log.error("Failed to forward WebSocket message to tunnel client", e);
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
String wsConnectionId = (String) session.getAttributes().get("wsConnectionId");
String clientId = (String) session.getAttributes().get("clientId");
String wsPath = (String) session.getAttributes().get("wsPath");
if (wsConnectionId != null && clientId != null && wsPath != null) {
// Remove from tracking
Map<String, WebSocketSession> clientConnections = webSocketConnections.get(clientId + wsPath);
if (clientConnections != null) {
clientConnections.remove(wsConnectionId);
if (clientConnections.isEmpty()) {
webSocketConnections.remove(clientId + wsPath);
}
}
// Send close request to tunnel client
TunnelRequestDto tunnelRequest = new TunnelRequestDto();
tunnelRequest.setRequestId(UUID.randomUUID().toString());
tunnelRequest.setType(TunnelRequestType.WS_CLOSE);
tunnelRequest.setPath(wsPath);
tunnelRequest.setClientShortName(clientId);
tunnelRequest.setWsConnectionId(wsConnectionId);
try {
tunnelService.sendTunnelRequest(tunnelRequest);
} catch (Exception e) {
log.error("Failed to send WebSocket close request to tunnel client", e);
}
log.info("WebSocket connection closed for client: {} wsId: {}", clientId, wsConnectionId);
}
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
log.error("WebSocket transport error: {}", exception.getMessage());
String wsConnectionId = (String) session.getAttributes().get("wsConnectionId");
if (wsConnectionId != null) {
log.error("Error on WebSocket connection: {}", wsConnectionId);
}
}
// Method to handle responses from tunnel client
public void handleWebSocketResponse(TunnelResponseDto response) {
String wsConnectionId = response.getWsConnectionId();
if (wsConnectionId == null) return;
// Find the WebSocket session
WebSocketSession targetSession = findWebSocketSession(wsConnectionId);
if (targetSession == null || !targetSession.isOpen()) {
log.warn("WebSocket session not found or closed: {}", wsConnectionId);
return;
}
try {
switch (response.getType()) {
case WS_CONNECT -> {
if (response.getStatusCode() != 101) {
// Connection failed, close the session
targetSession.close(CloseStatus.SERVER_ERROR);
}
// Connection successful - no action needed, session already established
}
case WS_MESSAGE -> {
// Forward message to WebSocket client
if ("TEXT".equals(response.getWsMessageType())) {
String textMessage = new String(response.getBody());
targetSession.sendMessage(new TextMessage(textMessage));
} else {
targetSession.sendMessage(new BinaryMessage(response.getBody()));
}
}
case WS_CLOSE -> {
// Close the WebSocket session
targetSession.close(new CloseStatus(response.getStatusCode(), ""));
}
case HTTP -> {
// HTTP responses should not be handled here
log.warn("Received HTTP response in WebSocket handler: {}", response.getRequestId());
}
}
} catch (Exception e) {
log.error("Error handling WebSocket response", e);
}
}
private WebSocketSession findWebSocketSession(String wsConnectionId) {
return webSocketConnections.values().stream()
.flatMap(map -> map.entrySet().stream())
.filter(entry -> entry.getKey().equals(wsConnectionId))
.map(Map.Entry::getValue)
.findFirst()
.orElse(null);
}
private String extractClientIdFromPath(String path) {
// Extract client ID from path like /ws/{clientId}/some/path
String[] parts = path.split("/");
return parts.length > 2 ? parts[2] : "unknown";
}
private String extractWsPathFromPath(String path) {
// Extract WebSocket path after client ID: /ws/{clientId}/path -> /path
String[] parts = path.split("/");
if (parts.length > 3) {
return "/" + String.join("/", java.util.Arrays.copyOfRange(parts, 3, parts.length));
}
return "/";
}
private Map<String, String> extractHeaders(WebSocketSession session) {
Map<String, String> headers = new HashMap<>();
session.getHandshakeHeaders().forEach((key, values) -> {
if (!values.isEmpty()) {
headers.put(key, values.get(0));
}
});
return headers;
}
@Override
public boolean supportsPartialMessages() {
return false;
}
}

View File

@ -0,0 +1,38 @@
package dev.thinhha.tunnel_server.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebsocketConfig implements WebSocketConfigurer {
private final WebSocketHandler webSocketHandler;
private final FacadeWebSocketHandler facadeWebSocketHandler;
private final WebSocketTunnelHandler webSocketTunnelHandler;
private final JwtWebSocketHandshakeInterceptor jwtHandshakeInterceptor;
public WebsocketConfig(WebSocketHandler webSocketHandler,
FacadeWebSocketHandler facadeWebSocketHandler,
WebSocketTunnelHandler webSocketTunnelHandler,
JwtWebSocketHandshakeInterceptor jwtHandshakeInterceptor) {
this.webSocketHandler = webSocketHandler;
this.facadeWebSocketHandler = facadeWebSocketHandler;
this.webSocketTunnelHandler = webSocketTunnelHandler;
this.jwtHandshakeInterceptor = jwtHandshakeInterceptor;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketHandler, "/client")
.addInterceptors(jwtHandshakeInterceptor)
.setAllowedOriginPatterns("*");
registry.addHandler(webSocketTunnelHandler, "/ws/**") // WebSocket tunnel endpoints
.setAllowedOriginPatterns("*");
registry.addHandler(facadeWebSocketHandler, "*")
.setAllowedOriginPatterns("*");
}
}

View File

@ -0,0 +1,91 @@
package dev.thinhha.tunnel_server.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.thinhha.tunnel_server.dto.ClientDto;
import dev.thinhha.tunnel_server.entity.Client;
import dev.thinhha.tunnel_server.mapper.ClientMapper;
import dev.thinhha.tunnel_server.repository.ClientRepository;
import dev.thinhha.tunnel_server.service.JwtService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Optional;
@RestController
@RequestMapping("/auth")
@Slf4j
public class AuthController {
private final ClientRepository clientRepository;
private final ClientMapper clientMapper;
private final ObjectMapper objectMapper;
private final JwtService jwtService;
public AuthController(ClientRepository clientRepository,
ClientMapper clientMapper,
ObjectMapper objectMapper,
JwtService jwtService) {
this.clientRepository = clientRepository;
this.clientMapper = clientMapper;
this.objectMapper = objectMapper;
this.jwtService = jwtService;
}
@PostMapping("/register")
public ResponseEntity<ClientDto> registerClient(@RequestBody ClientDto clientDto) {
try {
// Generate JWT token for the client
String token = jwtService.generateToken(clientDto.getClientName());
clientDto.setJwtToken(token);
// Save client to database
Client client = objectMapper.convertValue(clientDto, Client.class);
Client savedClient = clientRepository.save(client);
ClientDto responseDto = objectMapper.convertValue(savedClient, ClientDto.class);
log.info("Client registered: {}", clientDto.getClientName());
return ResponseEntity.ok(responseDto);
} catch (Exception e) {
log.error("Error registering client", e);
return ResponseEntity.badRequest().build();
}
}
@PostMapping("/token")
public ResponseEntity<String> generateToken(@RequestParam String clientName) {
try {
// Check if client exists
Optional<Client> clientOpt = clientRepository.findByClientName(clientName);
if (clientOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
// Generate new token
String token = jwtService.generateToken(clientName);
// Update client with new token
Client client = clientOpt.get();
client.setJwtToken(token);
clientRepository.save(client);
log.info("Token generated for client: {}", clientName);
return ResponseEntity.ok(token);
} catch (Exception e) {
log.error("Error generating token", e);
return ResponseEntity.badRequest().build();
}
}
@GetMapping("/validate")
public ResponseEntity<String> validateToken(@RequestParam String token) {
try {
String clientName = jwtService.extractClientName(token);
return ResponseEntity.ok("Token valid for client: " + clientName);
} catch (Exception e) {
log.error("Token validation failed", e);
return ResponseEntity.badRequest().body("Invalid token");
}
}
}

View File

@ -0,0 +1,31 @@
package dev.thinhha.tunnel_server.controller;
import dev.thinhha.tunnel_server.dto.ClientDto;
import dev.thinhha.tunnel_server.service.ClientService;
import dev.thinhha.tunnel_server.utils.ResourceUtils;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.net.URISyntaxException;
@RestController
@RequestMapping("/clients")
public class ClientController {
private final ClientService clientService;
public ClientController(ClientService clientService) {
this.clientService = clientService;
}
@PostMapping
public ResponseEntity<ClientDto> createClient(final @Valid @RequestBody ClientDto body) throws URISyntaxException {
var result = clientService.createClient(body);
return ResponseEntity.created(ResourceUtils.buildResultUri()).body(result);
}
}

View File

@ -0,0 +1,54 @@
package dev.thinhha.tunnel_server.controller;
import dev.thinhha.tunnel_server.dto.TunnelRequestDto;
import dev.thinhha.tunnel_server.dto.TunnelResponseDto;
import dev.thinhha.tunnel_server.service.TunnelService;
import dev.thinhha.tunnel_server.types.HttpMethod;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/tunnel")
@RequiredArgsConstructor
public class TunnelController {
private final TunnelService tunnelService;
@RequestMapping(value = "/{clientId}/**", method = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.PATCH, RequestMethod.HEAD, RequestMethod.OPTIONS})
public ResponseEntity<?> tunnelRequest(@PathVariable String clientId, HttpServletRequest request) throws IOException {
String requestId = UUID.randomUUID().toString();
// Extract path after /tunnel/{clientId}
String fullPath = request.getRequestURI();
String tunnelPath = fullPath.substring(("/tunnel/" + clientId).length());
// Create tunnel request DTO
TunnelRequestDto tunnelRequest = new TunnelRequestDto();
tunnelRequest.setRequestId(requestId);
tunnelRequest.setMethod(HttpMethod.valueOf(request.getMethod()));
tunnelRequest.setPath(tunnelPath);
tunnelRequest.setHeaders(tunnelService.extractHeaders(request));
tunnelRequest.setBody(request.getInputStream().readAllBytes());
tunnelRequest.setClientShortName(clientId);
try {
// Send request to WebSocket client and wait for response
CompletableFuture<TunnelResponseDto> responseFuture = tunnelService.sendTunnelRequest(tunnelRequest);
TunnelResponseDto response = responseFuture.get(30, TimeUnit.SECONDS);
return ResponseEntity.status(response.getStatusCode())
.headers(httpHeaders -> response.getHeaders().forEach(httpHeaders::add))
.body(response.getBody());
} catch (Exception e) {
return ResponseEntity.status(502).body("Tunnel request failed: " + e.getMessage());
}
}
}

View File

@ -0,0 +1,18 @@
package dev.thinhha.tunnel_server.dto;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.util.UUID;
/**
* DTO for {@link dev.thinhha.tunnel_server.entity.Client}
*/
@Getter
@Setter
public class ClientDto implements Serializable {
private UUID id;
private String clientName;
private String jwtToken;
}

View File

@ -0,0 +1,26 @@
package dev.thinhha.tunnel_server.dto;
import dev.thinhha.tunnel_server.types.HttpMethod;
import dev.thinhha.tunnel_server.types.TunnelRequestType;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TunnelRequestDto {
private String requestId;
private TunnelRequestType type = TunnelRequestType.HTTP; // Default to HTTP for backward compatibility
private HttpMethod method;
private String path;
private Map<String, String> headers;
private byte[] body;
private String clientShortName;
// WebSocket specific fields
private String wsConnectionId; // For tracking WS connections
private String wsMessageType; // TEXT or BINARY
}

View File

@ -0,0 +1,24 @@
package dev.thinhha.tunnel_server.dto;
import dev.thinhha.tunnel_server.types.TunnelRequestType;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TunnelResponseDto {
private String requestId;
private TunnelRequestType type = TunnelRequestType.HTTP; // Default to HTTP for backward compatibility
private int statusCode;
private Map<String, String> headers;
private byte[] body;
// WebSocket specific fields
private String wsConnectionId;
private String wsMessageType; // TEXT or BINARY
private boolean wsConnectionEstablished;
}

View File

@ -0,0 +1,41 @@
package dev.thinhha.tunnel_server.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.proxy.HibernateProxy;
import java.util.Objects;
import java.util.UUID;
@Entity
@Table(name = "clients")
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class Client {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String clientName;
private String jwtToken;
@Override
public final boolean equals(Object o) {
if (this == o) return true;
if (o == null) return false;
Class<?> oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass();
Class<?> thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass();
if (thisEffectiveClass != oEffectiveClass) return false;
Client client = (Client) o;
return getId() != null && Objects.equals(getId(), client.getId());
}
@Override
public final int hashCode() {
return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode();
}
}

View File

@ -0,0 +1,15 @@
package dev.thinhha.tunnel_server.mapper;
import dev.thinhha.tunnel_server.dto.ClientDto;
import dev.thinhha.tunnel_server.entity.Client;
import org.mapstruct.*;
@Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE, componentModel = MappingConstants.ComponentModel.SPRING)
public interface ClientMapper {
Client toEntity(ClientDto clientDto);
ClientDto toDto(Client client);
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
Client partialUpdate(ClientDto clientDto, @MappingTarget Client client);
}

View File

@ -0,0 +1,12 @@
package dev.thinhha.tunnel_server.repository;
import dev.thinhha.tunnel_server.entity.Client;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.Optional;
import java.util.UUID;
public interface ClientRepository extends JpaRepository<Client, UUID>, JpaSpecificationExecutor<Client> {
Optional<Client> findByClientName(String clientName);
}

View File

@ -0,0 +1,8 @@
package dev.thinhha.tunnel_server.service;
import dev.thinhha.tunnel_server.dto.ClientDto;
public interface ClientService {
ClientDto createClient(ClientDto clientDto);
ClientDto updateClient(ClientDto clientDto);
}

View File

@ -0,0 +1,83 @@
package dev.thinhha.tunnel_server.service;
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.MACSigner;
import com.nimbusds.jose.crypto.MACVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
@Service
@Slf4j
public class JwtService {
@Value("${jwt.secret:your-256-bit-secret-key-here-must-be-at-least-32-chars}")
private String jwtSecret;
@Value("${jwt.expiration:864000000}")
private long jwtExpiration;
public String generateToken(String clientName) {
try {
JWSSigner signer = new MACSigner(jwtSecret.getBytes());
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
.subject(clientName)
.claim("name", clientName)
.issuer("tunnel-server")
.issueTime(new Date())
.expirationTime(Date.from(Instant.now().plus(jwtExpiration, ChronoUnit.SECONDS)))
.build();
SignedJWT signedJWT = new SignedJWT(
new JWSHeader(JWSAlgorithm.HS256),
claimsSet);
signedJWT.sign(signer);
return signedJWT.serialize();
} catch (JOSEException e) {
log.error("Error generating JWT token", e);
throw new RuntimeException("Failed to generate JWT token", e);
}
}
public JWTClaimsSet validateToken(String token) {
try {
SignedJWT signedJWT = SignedJWT.parse(token);
JWSVerifier verifier = new MACVerifier(jwtSecret.getBytes());
if (!signedJWT.verify(verifier)) {
throw new RuntimeException("Invalid JWT signature");
}
JWTClaimsSet claimsSet = signedJWT.getJWTClaimsSet();
// Check expiration
if (claimsSet.getExpirationTime().before(new Date())) {
throw new RuntimeException("JWT token has expired");
}
return claimsSet;
} catch (Exception e) {
log.error("Error validating JWT token", e);
throw new RuntimeException("Invalid JWT token", e);
}
}
public String extractClientName(String token) {
try {
JWTClaimsSet claimsSet = validateToken(token);
return claimsSet.getStringClaim("name");
} catch (Exception e) {
log.error("Error extracting client name from JWT", e);
throw new RuntimeException("Failed to extract client name", e);
}
}
}

View File

@ -0,0 +1,104 @@
package dev.thinhha.tunnel_server.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.thinhha.tunnel_server.config.WebSocketHandler;
import dev.thinhha.tunnel_server.config.WebSocketTunnelHandler;
import dev.thinhha.tunnel_server.dto.TunnelRequestDto;
import dev.thinhha.tunnel_server.dto.TunnelResponseDto;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.web.socket.BinaryMessage;
import org.springframework.web.socket.WebSocketSession;
import java.io.IOException;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
@Service
@Slf4j
public class TunnelService {
private final WebSocketHandler webSocketHandler;
private final ObjectMapper objectMapper;
private final WebSocketTunnelHandler webSocketTunnelHandler;
private final Map<String, CompletableFuture<TunnelResponseDto>> pendingResponses = new ConcurrentHashMap<>();
public TunnelService(@Lazy WebSocketHandler webSocketHandler,
ObjectMapper objectMapper,
@Lazy WebSocketTunnelHandler webSocketTunnelHandler) {
this.webSocketHandler = webSocketHandler;
this.objectMapper = objectMapper;
this.webSocketTunnelHandler = webSocketTunnelHandler;
}
public Map<String, String> extractHeaders(HttpServletRequest request) {
Map<String, String> headers = new HashMap<>();
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String headerName = headerNames.nextElement();
String headerValue = request.getHeader(headerName);
headers.put(headerName, headerValue);
}
return headers;
}
public CompletableFuture<TunnelResponseDto> sendTunnelRequest(TunnelRequestDto tunnelRequest) throws IOException {
CompletableFuture<TunnelResponseDto> responseFuture = new CompletableFuture<>();
// Store the future for when response comes back
pendingResponses.put(tunnelRequest.getRequestId(), responseFuture);
// Find the WebSocket session for the client
WebSocketSession session = webSocketHandler.getClientSession(tunnelRequest.getClientShortName());
if (session == null || !session.isOpen()) {
responseFuture.completeExceptionally(new RuntimeException("Client not connected"));
return responseFuture;
}
// Serialize and send the request
byte[] messageBytes = objectMapper.writeValueAsBytes(tunnelRequest);
BinaryMessage message = new BinaryMessage(messageBytes);
try {
session.sendMessage(message);
log.info("Sent tunnel request {} to client {}", tunnelRequest.getRequestId(), tunnelRequest.getClientShortName());
} catch (IOException e) {
log.error("Failed to send tunnel request", e);
pendingResponses.remove(tunnelRequest.getRequestId());
responseFuture.completeExceptionally(e);
}
return responseFuture;
}
public void handleTunnelResponse(TunnelResponseDto response) {
// Handle WebSocket responses separately
if (response.getType() != null && response.getType() != dev.thinhha.tunnel_server.types.TunnelRequestType.HTTP) {
if (webSocketTunnelHandler != null) {
webSocketTunnelHandler.handleWebSocketResponse(response);
log.info("Handled WebSocket response: {} type: {}", response.getRequestId(), response.getType());
}
return;
}
// Handle HTTP responses
CompletableFuture<TunnelResponseDto> responseFuture = pendingResponses.remove(response.getRequestId());
if (responseFuture != null) {
responseFuture.complete(response);
log.info("Completed tunnel response {}", response.getRequestId());
} else {
log.warn("Received response for unknown request {}", response.getRequestId());
}
}
}

View File

@ -0,0 +1,38 @@
package dev.thinhha.tunnel_server.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.thinhha.tunnel_server.dto.ClientDto;
import dev.thinhha.tunnel_server.entity.Client;
import dev.thinhha.tunnel_server.mapper.ClientMapper;
import dev.thinhha.tunnel_server.repository.ClientRepository;
import dev.thinhha.tunnel_server.service.ClientService;
import org.springframework.stereotype.Service;
@Service
public class ClientServiceImpl implements ClientService {
private final ClientMapper clientMapper;
private final ClientRepository clientRepository;
private final ObjectMapper objectMapper;
public ClientServiceImpl(ClientMapper clientMapper,
ClientRepository clientRepository,
ObjectMapper objectMapper) {
this.clientMapper = clientMapper;
this.clientRepository = clientRepository;
this.objectMapper = objectMapper;
}
@Override
public ClientDto createClient(ClientDto clientDto) {
var client = objectMapper.convertValue(clientDto, Client.class);
return objectMapper.convertValue(
clientRepository.save(client), ClientDto.class
);
}
@Override
public ClientDto updateClient(ClientDto clientDto) {
return null;
}
}

View File

@ -0,0 +1,4 @@
package dev.thinhha.tunnel_server.types;
public enum ConnectionType {
}

View File

@ -0,0 +1,5 @@
package dev.thinhha.tunnel_server.types;
public enum HttpMethod {
GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE
}

View File

@ -0,0 +1,8 @@
package dev.thinhha.tunnel_server.types;
public enum TunnelRequestType {
HTTP, // Regular HTTP request
WS_CONNECT, // WebSocket connection request
WS_MESSAGE, // WebSocket message
WS_CLOSE // WebSocket close
}

View File

@ -0,0 +1,27 @@
package dev.thinhha.tunnel_server.utils;
import dev.thinhha.tunnel_server.config.Constant;
import lombok.NonNull;
import java.security.SecureRandom;
public class RandomUtils {
private static final SecureRandom secureRandom = new SecureRandom();
private RandomUtils() {
}
public static @NonNull String generateAlphaNumericString(final int length) {
return generateFromCharset(Constant.ALPHANUMERIC, length);
}
public static @NonNull String generateFromCharset(final @NonNull String charset, final int length) {
var builder = new StringBuilder();
for (int i = 0; i < length; i++) {
var index = secureRandom.nextInt(charset.length());
builder.append(charset.charAt(index));
}
return builder.toString();
}
}

View File

@ -0,0 +1,12 @@
package dev.thinhha.tunnel_server.utils;
import java.net.URI;
import java.net.URISyntaxException;
public class ResourceUtils {
private ResourceUtils() {}
public static URI buildResultUri() throws URISyntaxException {
return new URI("abc");
}
}

View File

@ -0,0 +1,39 @@
spring:
application:
name: Tunnel Server
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:h2:file:/app/data/tunnel_server}
username: ${SPRING_DATASOURCE_USERNAME:sa}
password: ${SPRING_DATASOURCE_PASSWORD:tunnel_server_password}
jpa:
hibernate:
ddl-auto: update
show-sql: false
h2:
console:
enabled: true
path: /h2-console
threads:
virtual:
enabled: true
servlet:
multipart:
max-request-size: 10GB
max-file-size: 5GB
server:
port: 5678
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: always
logging:
level:
dev.thinhha: INFO
org.springframework.web.socket: DEBUG

View File

@ -1,6 +1,27 @@
spring:
application:
name: Tunnel Server
datasource:
url: jdbc:postgresql://localhost:5432/tunnel_server
username: tunnel_server
password: tunnel_server
jpa:
hibernate:
ddl-auto: create-drop
threads:
virtual:
enabled: true
servlet:
multipart:
max-request-size: 10GB
max-file-size: 5GB
server:
port: 5678
logging:
level:
dev.thinhha: debug

15
tunnel-server.iml Normal file
View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="FacetManager">
<facet type="jpa" name="JPA">
<configuration>
<setting name="validation-enabled" value="true" />
<setting name="provider-name" value="Hibernate" />
<datasource-mapping>
<factory-entry name="entityManagerFactory" value="2ad22f7a-d508-4607-abb0-429f56f08b06" />
</datasource-mapping>
<naming-strategy-map />
</configuration>
</facet>
</component>
</module>