diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a5f46c2 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/data/testdb.mv.db b/data/testdb.mv.db new file mode 100644 index 0000000..fae1a1f Binary files /dev/null and b/data/testdb.mv.db differ diff --git a/data/testdb.trace.db b/data/testdb.trace.db new file mode 100644 index 0000000..065f409 --- /dev/null +++ b/data/testdb.trace.db @@ -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.(MVStore.java:291) + at org.h2.mvstore.MVStore$Builder.open(MVStore.java:2035) + at org.h2.mvstore.db.Store.(Store.java:133) + at org.h2.engine.Database.(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.(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.(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.(MVStore.java:291) + at org.h2.mvstore.MVStore$Builder.open(MVStore.java:2035) + at org.h2.mvstore.db.Store.(Store.java:133) + at org.h2.engine.Database.(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.(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.(MVStore.java:286) + ... 26 more diff --git a/pom.xml b/pom.xml index a4775b8..23a2a19 100644 --- a/pom.xml +++ b/pom.xml @@ -1,58 +1,113 @@ - 4.0.0 - - org.springframework.boot - spring-boot-starter-parent - 3.5.3 - - - dev.thinhha - tunnel-server - 0.0.1-SNAPSHOT - Tunnel Server - Demo project for Spring Boot - - - - - - - - - - - - - - - 24 - - - - org.springframework.boot - spring-boot-starter-websocket - + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.3 + + + dev.thinhha + tunnel-server + 0.0.1-SNAPSHOT + Tunnel Server + Demo project for Spring Boot + + + + + + + + + + + + + + + 24 + + + + org.springframework.boot + spring-boot-starter-websocket + - - org.springframework.boot - spring-boot-starter-test - test - - + + com.nimbusds + nimbus-jose-jwt + 10.3.1 + - - - - org.graalvm.buildtools - native-maven-plugin - - - org.springframework.boot - spring-boot-maven-plugin - - - + + org.mapstruct + mapstruct + 1.6.3 + + + org.mapstruct + mapstruct-processor + 1.6.3 + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + runtime + + + org.projectlombok + lombok + provided + + + org.springframework.boot + spring-boot-starter-validation + + + org.postgresql + postgresql + runtime + + + org.springframework.boot + spring-boot-starter-actuator + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + 24 + 24 + + --enable-preview + + + + + org.graalvm.buildtools + native-maven-plugin + + + org.springframework.boot + spring-boot-maven-plugin + + + diff --git a/src/main/java/dev/thinhha/tunnel_server/TunnelServerApplication.java b/src/main/java/dev/thinhha/tunnel_server/TunnelServerApplication.java index 45345f5..af3e16b 100644 --- a/src/main/java/dev/thinhha/tunnel_server/TunnelServerApplication.java +++ b/src/main/java/dev/thinhha/tunnel_server/TunnelServerApplication.java @@ -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 { diff --git a/src/main/java/dev/thinhha/tunnel_server/config/Constant.java b/src/main/java/dev/thinhha/tunnel_server/config/Constant.java new file mode 100644 index 0000000..ce71a82 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/config/Constant.java @@ -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() { + } +} diff --git a/src/main/java/dev/thinhha/tunnel_server/config/FacadeWebSocketHandler.java b/src/main/java/dev/thinhha/tunnel_server/config/FacadeWebSocketHandler.java new file mode 100644 index 0000000..84f5304 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/config/FacadeWebSocketHandler.java @@ -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 { +} diff --git a/src/main/java/dev/thinhha/tunnel_server/config/JwtWebSocketHandshakeInterceptor.java b/src/main/java/dev/thinhha/tunnel_server/config/JwtWebSocketHandshakeInterceptor.java new file mode 100644 index 0000000..8aaec0e --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/config/JwtWebSocketHandshakeInterceptor.java @@ -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 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 params = UriComponentsBuilder.fromUriString("?" + query) + .build() + .getQueryParams() + .toSingleValueMap(); + return params.get("token"); + } + + return null; + } +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_server/config/WebSocketHandler.java b/src/main/java/dev/thinhha/tunnel_server/config/WebSocketHandler.java new file mode 100644 index 0000000..f4ef78b --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/config/WebSocketHandler.java @@ -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 sessions = new CopyOnWriteArraySet<>(); + private final ConcurrentHashMap 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); + } + +} diff --git a/src/main/java/dev/thinhha/tunnel_server/config/WebSocketTunnelHandler.java b/src/main/java/dev/thinhha/tunnel_server/config/WebSocketTunnelHandler.java new file mode 100644 index 0000000..e7c2bb1 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/config/WebSocketTunnelHandler.java @@ -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 + private final Map> 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 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 extractHeaders(WebSocketSession session) { + Map 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; + } +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_server/config/WebsocketConfig.java b/src/main/java/dev/thinhha/tunnel_server/config/WebsocketConfig.java new file mode 100644 index 0000000..0ec94c9 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/config/WebsocketConfig.java @@ -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("*"); + } +} diff --git a/src/main/java/dev/thinhha/tunnel_server/controller/AuthController.java b/src/main/java/dev/thinhha/tunnel_server/controller/AuthController.java new file mode 100644 index 0000000..197636c --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/controller/AuthController.java @@ -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 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 generateToken(@RequestParam String clientName) { + try { + // Check if client exists + Optional 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 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"); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_server/controller/ClientController.java b/src/main/java/dev/thinhha/tunnel_server/controller/ClientController.java new file mode 100644 index 0000000..f6f03d6 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/controller/ClientController.java @@ -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 createClient(final @Valid @RequestBody ClientDto body) throws URISyntaxException { + var result = clientService.createClient(body); + return ResponseEntity.created(ResourceUtils.buildResultUri()).body(result); + + } +} diff --git a/src/main/java/dev/thinhha/tunnel_server/controller/TunnelController.java b/src/main/java/dev/thinhha/tunnel_server/controller/TunnelController.java new file mode 100644 index 0000000..e4fa2fa --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/controller/TunnelController.java @@ -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 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()); + } + } +} diff --git a/src/main/java/dev/thinhha/tunnel_server/dto/ClientDto.java b/src/main/java/dev/thinhha/tunnel_server/dto/ClientDto.java new file mode 100644 index 0000000..ba4de7c --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/dto/ClientDto.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_server/dto/TunnelRequestDto.java b/src/main/java/dev/thinhha/tunnel_server/dto/TunnelRequestDto.java new file mode 100644 index 0000000..f39b5c1 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/dto/TunnelRequestDto.java @@ -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 headers; + private byte[] body; + private String clientShortName; + + // WebSocket specific fields + private String wsConnectionId; // For tracking WS connections + private String wsMessageType; // TEXT or BINARY +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_server/dto/TunnelResponseDto.java b/src/main/java/dev/thinhha/tunnel_server/dto/TunnelResponseDto.java new file mode 100644 index 0000000..26868f9 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/dto/TunnelResponseDto.java @@ -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 headers; + private byte[] body; + + // WebSocket specific fields + private String wsConnectionId; + private String wsMessageType; // TEXT or BINARY + private boolean wsConnectionEstablished; +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_server/entity/Client.java b/src/main/java/dev/thinhha/tunnel_server/entity/Client.java new file mode 100644 index 0000000..1f65b03 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/entity/Client.java @@ -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(); + } +} diff --git a/src/main/java/dev/thinhha/tunnel_server/mapper/ClientMapper.java b/src/main/java/dev/thinhha/tunnel_server/mapper/ClientMapper.java new file mode 100644 index 0000000..5070b38 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/mapper/ClientMapper.java @@ -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); +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_server/repository/ClientRepository.java b/src/main/java/dev/thinhha/tunnel_server/repository/ClientRepository.java new file mode 100644 index 0000000..cf1821b --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/repository/ClientRepository.java @@ -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, JpaSpecificationExecutor { + Optional findByClientName(String clientName); +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_server/service/ClientService.java b/src/main/java/dev/thinhha/tunnel_server/service/ClientService.java new file mode 100644 index 0000000..9a24b3a --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/service/ClientService.java @@ -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); +} diff --git a/src/main/java/dev/thinhha/tunnel_server/service/JwtService.java b/src/main/java/dev/thinhha/tunnel_server/service/JwtService.java new file mode 100644 index 0000000..aa8332d --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/service/JwtService.java @@ -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); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_server/service/TunnelService.java b/src/main/java/dev/thinhha/tunnel_server/service/TunnelService.java new file mode 100644 index 0000000..7a0e695 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/service/TunnelService.java @@ -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> pendingResponses = new ConcurrentHashMap<>(); + + public TunnelService(@Lazy WebSocketHandler webSocketHandler, + ObjectMapper objectMapper, + @Lazy WebSocketTunnelHandler webSocketTunnelHandler) { + this.webSocketHandler = webSocketHandler; + this.objectMapper = objectMapper; + this.webSocketTunnelHandler = webSocketTunnelHandler; + } + + + public Map extractHeaders(HttpServletRequest request) { + Map headers = new HashMap<>(); + Enumeration headerNames = request.getHeaderNames(); + + while (headerNames.hasMoreElements()) { + String headerName = headerNames.nextElement(); + String headerValue = request.getHeader(headerName); + headers.put(headerName, headerValue); + } + + return headers; + } + + public CompletableFuture sendTunnelRequest(TunnelRequestDto tunnelRequest) throws IOException { + CompletableFuture 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 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()); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_server/service/impl/ClientServiceImpl.java b/src/main/java/dev/thinhha/tunnel_server/service/impl/ClientServiceImpl.java new file mode 100644 index 0000000..375d785 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/service/impl/ClientServiceImpl.java @@ -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; + } +} diff --git a/src/main/java/dev/thinhha/tunnel_server/types/ConnectionType.java b/src/main/java/dev/thinhha/tunnel_server/types/ConnectionType.java new file mode 100644 index 0000000..46aa129 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/types/ConnectionType.java @@ -0,0 +1,4 @@ +package dev.thinhha.tunnel_server.types; + +public enum ConnectionType { +} diff --git a/src/main/java/dev/thinhha/tunnel_server/types/HttpMethod.java b/src/main/java/dev/thinhha/tunnel_server/types/HttpMethod.java new file mode 100644 index 0000000..626284f --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/types/HttpMethod.java @@ -0,0 +1,5 @@ +package dev.thinhha.tunnel_server.types; + +public enum HttpMethod { + GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_server/types/TunnelRequestType.java b/src/main/java/dev/thinhha/tunnel_server/types/TunnelRequestType.java new file mode 100644 index 0000000..8dc4ae5 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/types/TunnelRequestType.java @@ -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 +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_server/utils/RandomUtils.java b/src/main/java/dev/thinhha/tunnel_server/utils/RandomUtils.java new file mode 100644 index 0000000..7f597f1 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/utils/RandomUtils.java @@ -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(); + } + +} diff --git a/src/main/java/dev/thinhha/tunnel_server/utils/ResourceUtils.java b/src/main/java/dev/thinhha/tunnel_server/utils/ResourceUtils.java new file mode 100644 index 0000000..9bbec66 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_server/utils/ResourceUtils.java @@ -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"); + } +} diff --git a/src/main/resources/application-docker.yaml b/src/main/resources/application-docker.yaml new file mode 100644 index 0000000..d528507 --- /dev/null +++ b/src/main/resources/application-docker.yaml @@ -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 \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 163c4c3..a361e3b 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -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 + diff --git a/tunnel-server.iml b/tunnel-server.iml new file mode 100644 index 0000000..8aaef62 --- /dev/null +++ b/tunnel-server.iml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file