Working HTTP tunnel
This commit is contained in:
47
Dockerfile
Normal file
47
Dockerfile
Normal 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
BIN
data/testdb.mv.db
Normal file
Binary file not shown.
90
data/testdb.trace.db
Normal file
90
data/testdb.trace.db
Normal 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
|
159
pom.xml
159
pom.xml
@ -1,58 +1,113 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.5.3</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>dev.thinhha</groupId>
|
||||
<artifactId>tunnel-server</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>Tunnel Server</name>
|
||||
<description>Demo project for Spring Boot</description>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license/>
|
||||
</licenses>
|
||||
<developers>
|
||||
<developer/>
|
||||
</developers>
|
||||
<scm>
|
||||
<connection/>
|
||||
<developerConnection/>
|
||||
<tag/>
|
||||
<url/>
|
||||
</scm>
|
||||
<properties>
|
||||
<java.version>24</java.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.5.3</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>dev.thinhha</groupId>
|
||||
<artifactId>tunnel-server</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>Tunnel Server</name>
|
||||
<description>Demo project for Spring Boot</description>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license/>
|
||||
</licenses>
|
||||
<developers>
|
||||
<developer/>
|
||||
</developers>
|
||||
<scm>
|
||||
<connection/>
|
||||
<developerConnection/>
|
||||
<tag/>
|
||||
<url/>
|
||||
</scm>
|
||||
<properties>
|
||||
<java.version>24</java.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<dependency>
|
||||
<groupId>com.nimbusds</groupId>
|
||||
<artifactId>nimbus-jose-jwt</artifactId>
|
||||
<version>10.3.1</version>
|
||||
</dependency>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.graalvm.buildtools</groupId>
|
||||
<artifactId>native-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<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>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
|
@ -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 {
|
||||
|
14
src/main/java/dev/thinhha/tunnel_server/config/Constant.java
Normal file
14
src/main/java/dev/thinhha/tunnel_server/config/Constant.java
Normal 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() {
|
||||
}
|
||||
}
|
@ -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 {
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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("*");
|
||||
}
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
18
src/main/java/dev/thinhha/tunnel_server/dto/ClientDto.java
Normal file
18
src/main/java/dev/thinhha/tunnel_server/dto/ClientDto.java
Normal 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;
|
||||
}
|
@ -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
|
||||
}
|
@ -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;
|
||||
}
|
41
src/main/java/dev/thinhha/tunnel_server/entity/Client.java
Normal file
41
src/main/java/dev/thinhha/tunnel_server/entity/Client.java
Normal 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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
package dev.thinhha.tunnel_server.types;
|
||||
|
||||
public enum ConnectionType {
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
package dev.thinhha.tunnel_server.types;
|
||||
|
||||
public enum HttpMethod {
|
||||
GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE
|
||||
}
|
@ -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
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
39
src/main/resources/application-docker.yaml
Normal file
39
src/main/resources/application-docker.yaml
Normal 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
|
@ -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
15
tunnel-server.iml
Normal 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>
|
Reference in New Issue
Block a user