diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..c3e924c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,47 @@
+# Multi-stage build for tunnel client
+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:8765/actuator/health || exit 1
+
+# Expose port
+EXPOSE 8765
+
+# 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/tunnel_client.mv.db b/data/tunnel_client.mv.db
new file mode 100644
index 0000000..ad65b9d
Binary files /dev/null and b/data/tunnel_client.mv.db differ
diff --git a/pom.xml b/pom.xml
index 7981a7c..06a1f30 100644
--- a/pom.xml
+++ b/pom.xml
@@ -34,6 +34,33 @@
org.springframework.boot
spring-boot-starter-websocket
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+ org.projectlombok
+ lombok
+ provided
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+
+ com.h2database
+ h2
+ runtime
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
org.springframework.boot
diff --git a/src/main/java/dev/thinhha/tunnel_client/TunnelClientApplication.java b/src/main/java/dev/thinhha/tunnel_client/TunnelClientApplication.java
index 33264f7..086c7f5 100644
--- a/src/main/java/dev/thinhha/tunnel_client/TunnelClientApplication.java
+++ b/src/main/java/dev/thinhha/tunnel_client/TunnelClientApplication.java
@@ -1,7 +1,10 @@
package dev.thinhha.tunnel_client;
+import dev.thinhha.tunnel_client.service.TunnelClient;
+import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class TunnelClientApplication {
@@ -10,4 +13,18 @@ public class TunnelClientApplication {
SpringApplication.run(TunnelClientApplication.class, args);
}
+ @Bean
+ CommandLineRunner runner(TunnelClient tunnelClient) {
+ return args -> {
+ tunnelClient.connect();
+
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ System.out.println("Shutting down tunnel client...");
+ }));
+
+ while (true) {
+ Thread.sleep(1000);
+ }
+ };
+ }
}
diff --git a/src/main/java/dev/thinhha/tunnel_client/config/DataInitializer.java b/src/main/java/dev/thinhha/tunnel_client/config/DataInitializer.java
new file mode 100644
index 0000000..0f82b25
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/config/DataInitializer.java
@@ -0,0 +1,51 @@
+package dev.thinhha.tunnel_client.config;
+
+import dev.thinhha.tunnel_client.entity.RouteConfig;
+import dev.thinhha.tunnel_client.repository.RouteConfigRepository;
+import dev.thinhha.tunnel_client.service.HeaderManipulationService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class DataInitializer implements CommandLineRunner {
+
+ private final RouteConfigRepository routeConfigRepository;
+ private final HeaderManipulationService headerManipulationService;
+ private final TunnelConfig tunnelConfig;
+
+ @Override
+ public void run(String... args) throws Exception {
+ // Load initial routes from configuration into database
+ if (tunnelConfig.getTarget().getRoutes() != null) {
+ tunnelConfig.getTarget().getRoutes().forEach((pathPattern, targetUrl) -> {
+ if (!routeConfigRepository.existsByPathPattern(pathPattern)) {
+ RouteConfig route = new RouteConfig();
+ route.setPathPattern(pathPattern);
+ route.setTargetUrl(targetUrl);
+ route.setPriority(pathPattern.length()); // Longer paths get higher priority
+ route.setDescription("Auto-imported from configuration");
+ route.setEnabled(true);
+
+ routeConfigRepository.save(route);
+ log.info("Imported route: {} -> {}", pathPattern, targetUrl);
+ }
+ });
+ }
+
+ // Add default CORS headers for API paths if not already configured
+ if (headerManipulationService.getAllHeaderRules().isEmpty()) {
+ log.info("Initializing default CORS headers...");
+ headerManipulationService.addCorsHeaders(
+ "/api",
+ "*",
+ "GET, POST, PUT, DELETE, OPTIONS, PATCH",
+ "Origin, Content-Type, Accept, Authorization, X-Requested-With"
+ );
+ log.info("Default CORS headers added for /api paths");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/thinhha/tunnel_client/config/NoOpResponseErrorHandler.java b/src/main/java/dev/thinhha/tunnel_client/config/NoOpResponseErrorHandler.java
new file mode 100644
index 0000000..9f1ef35
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/config/NoOpResponseErrorHandler.java
@@ -0,0 +1,21 @@
+package dev.thinhha.tunnel_client.config;
+
+import org.springframework.http.client.ClientHttpResponse;
+import org.springframework.web.client.ResponseErrorHandler;
+
+import java.io.IOException;
+
+public class NoOpResponseErrorHandler implements ResponseErrorHandler {
+
+ @Override
+ public boolean hasError(ClientHttpResponse response) throws IOException {
+ // Never consider any response as an error
+ // This allows us to handle all HTTP status codes (including 4xx, 5xx) gracefully
+ return false;
+ }
+
+ @Override
+ public void handleError(ClientHttpResponse response) throws IOException {
+ // No-op: do nothing, let the response be returned as-is
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/thinhha/tunnel_client/config/TunnelConfig.java b/src/main/java/dev/thinhha/tunnel_client/config/TunnelConfig.java
new file mode 100644
index 0000000..131a463
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/config/TunnelConfig.java
@@ -0,0 +1,34 @@
+package dev.thinhha.tunnel_client.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "tunnel")
+public class TunnelConfig {
+
+ private Server server = new Server();
+ private Client client = new Client();
+ private Target target = new Target();
+
+ @Data
+ public static class Server {
+ private String url = "ws://localhost:5678";
+ }
+
+ @Data
+ public static class Client {
+ private String name = "default-client";
+ private String token = "";
+ }
+
+ @Data
+ public static class Target {
+ private String defaultUrl = "http://localhost:8080";
+ private Map routes;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/thinhha/tunnel_client/config/WebSocketConfig.java b/src/main/java/dev/thinhha/tunnel_client/config/WebSocketConfig.java
new file mode 100644
index 0000000..5bcd976
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/config/WebSocketConfig.java
@@ -0,0 +1,38 @@
+package dev.thinhha.tunnel_client.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.converter.ByteArrayHttpMessageConverter;
+import org.springframework.http.converter.StringHttpMessageConverter;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.web.client.RestTemplate;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+
+@Configuration
+public class WebSocketConfig {
+
+ @Bean
+ public RestTemplate restTemplate() {
+ RestTemplate restTemplate = new RestTemplate();
+
+ // Configure message converters to handle different content types properly
+ StringHttpMessageConverter stringConverter = new StringHttpMessageConverter(StandardCharsets.UTF_8);
+ stringConverter.setWriteAcceptCharset(false);
+
+ ByteArrayHttpMessageConverter byteArrayConverter = new ByteArrayHttpMessageConverter();
+ MappingJackson2HttpMessageConverter jsonConverter = new MappingJackson2HttpMessageConverter();
+
+ restTemplate.setMessageConverters(Arrays.asList(
+ byteArrayConverter, // Handle binary data
+ stringConverter, // Handle text data
+ jsonConverter // Handle JSON data
+ ));
+
+ // Configure error handler to not throw exceptions for HTTP error status codes
+ restTemplate.setErrorHandler(new NoOpResponseErrorHandler());
+
+ return restTemplate;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/thinhha/tunnel_client/controller/HeaderRuleController.java b/src/main/java/dev/thinhha/tunnel_client/controller/HeaderRuleController.java
new file mode 100644
index 0000000..8ef3ad1
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/controller/HeaderRuleController.java
@@ -0,0 +1,101 @@
+package dev.thinhha.tunnel_client.controller;
+
+import dev.thinhha.tunnel_client.entity.HeaderRule;
+import dev.thinhha.tunnel_client.service.HeaderManipulationService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.UUID;
+
+@RestController
+@RequestMapping("/api/header-rules")
+@RequiredArgsConstructor
+public class HeaderRuleController {
+
+ private final HeaderManipulationService headerManipulationService;
+
+ @GetMapping
+ public List getAllHeaderRules() {
+ return headerManipulationService.getAllHeaderRules();
+ }
+
+ @PostMapping
+ public ResponseEntity addHeaderRule(@RequestBody HeaderRuleRequest request) {
+ HeaderRule rule = headerManipulationService.addHeaderRule(
+ request.getPathPattern(),
+ request.getHeaderName(),
+ request.getHeaderValue(),
+ request.getRuleType(),
+ request.getPriority(),
+ request.getDescription()
+ );
+ return ResponseEntity.ok(rule);
+ }
+
+ @PostMapping("/cors")
+ public ResponseEntity addCorsHeaders(@RequestBody CorsRequest request) {
+ headerManipulationService.addCorsHeaders(
+ request.getPathPattern(),
+ request.getAllowedOrigins(),
+ request.getAllowedMethods(),
+ request.getAllowedHeaders()
+ );
+ return ResponseEntity.ok("CORS headers added successfully");
+ }
+
+ @DeleteMapping
+ public ResponseEntity removeHeaderRule(@RequestParam String pathPattern, @RequestParam String headerName) {
+ headerManipulationService.removeHeaderRule(pathPattern, headerName);
+ return ResponseEntity.ok().build();
+ }
+
+ public static class HeaderRuleRequest {
+ private String pathPattern;
+ private String headerName;
+ private String headerValue;
+ private HeaderRule.HeaderRuleType ruleType;
+ private Integer priority;
+ private String description;
+
+ // Getters and setters
+ public String getPathPattern() { return pathPattern; }
+ public void setPathPattern(String pathPattern) { this.pathPattern = pathPattern; }
+
+ public String getHeaderName() { return headerName; }
+ public void setHeaderName(String headerName) { this.headerName = headerName; }
+
+ public String getHeaderValue() { return headerValue; }
+ public void setHeaderValue(String headerValue) { this.headerValue = headerValue; }
+
+ public HeaderRule.HeaderRuleType getRuleType() { return ruleType; }
+ public void setRuleType(HeaderRule.HeaderRuleType ruleType) { this.ruleType = ruleType; }
+
+ public Integer getPriority() { return priority; }
+ public void setPriority(Integer priority) { this.priority = priority; }
+
+ public String getDescription() { return description; }
+ public void setDescription(String description) { this.description = description; }
+ }
+
+ public static class CorsRequest {
+ private String pathPattern;
+ private String allowedOrigins = "*";
+ private String allowedMethods = "GET, POST, PUT, DELETE, OPTIONS";
+ private String allowedHeaders = "Origin, Content-Type, Accept, Authorization";
+
+ // Getters and setters
+ public String getPathPattern() { return pathPattern; }
+ public void setPathPattern(String pathPattern) { this.pathPattern = pathPattern; }
+
+ public String getAllowedOrigins() { return allowedOrigins; }
+ public void setAllowedOrigins(String allowedOrigins) { this.allowedOrigins = allowedOrigins; }
+
+ public String getAllowedMethods() { return allowedMethods; }
+ public void setAllowedMethods(String allowedMethods) { this.allowedMethods = allowedMethods; }
+
+ public String getAllowedHeaders() { return allowedHeaders; }
+ public void setAllowedHeaders(String allowedHeaders) { this.allowedHeaders = allowedHeaders; }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/thinhha/tunnel_client/controller/RouteController.java b/src/main/java/dev/thinhha/tunnel_client/controller/RouteController.java
new file mode 100644
index 0000000..e6d2ab9
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/controller/RouteController.java
@@ -0,0 +1,64 @@
+package dev.thinhha.tunnel_client.controller;
+
+import dev.thinhha.tunnel_client.entity.RouteConfig;
+import dev.thinhha.tunnel_client.service.RouteResolver;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/routes")
+@RequiredArgsConstructor
+public class RouteController {
+
+ private final RouteResolver routeResolver;
+
+ @GetMapping
+ public List getAllRoutes() {
+ return routeResolver.getAllRoutes();
+ }
+
+ @PostMapping
+ public ResponseEntity addRoute(@RequestBody RouteRequest request) {
+ RouteConfig route = routeResolver.addRoute(
+ request.getPathPattern(),
+ request.getTargetUrl(),
+ request.getPriority(),
+ request.getDescription()
+ );
+ return ResponseEntity.ok(route);
+ }
+
+ @DeleteMapping("/{pathPattern}")
+ public ResponseEntity removeRoute(@PathVariable String pathPattern) {
+ routeResolver.removeRoute(pathPattern);
+ return ResponseEntity.ok().build();
+ }
+
+ @GetMapping("/resolve/{path}")
+ public ResponseEntity resolveRoute(@PathVariable String path) {
+ String targetUrl = routeResolver.resolveTargetUrl("/" + path);
+ return ResponseEntity.ok(targetUrl);
+ }
+
+ public static class RouteRequest {
+ private String pathPattern;
+ private String targetUrl;
+ private Integer priority;
+ private String description;
+
+ public String getPathPattern() { return pathPattern; }
+ public void setPathPattern(String pathPattern) { this.pathPattern = pathPattern; }
+
+ public String getTargetUrl() { return targetUrl; }
+ public void setTargetUrl(String targetUrl) { this.targetUrl = targetUrl; }
+
+ public Integer getPriority() { return priority; }
+ public void setPriority(Integer priority) { this.priority = priority; }
+
+ public String getDescription() { return description; }
+ public void setDescription(String description) { this.description = description; }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/thinhha/tunnel_client/dto/TunnelRequestDto.java b/src/main/java/dev/thinhha/tunnel_client/dto/TunnelRequestDto.java
new file mode 100644
index 0000000..978d54f
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/dto/TunnelRequestDto.java
@@ -0,0 +1,26 @@
+package dev.thinhha.tunnel_client.dto;
+
+import dev.thinhha.tunnel_client.types.HttpMethod;
+import dev.thinhha.tunnel_client.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_client/dto/TunnelResponseDto.java b/src/main/java/dev/thinhha/tunnel_client/dto/TunnelResponseDto.java
new file mode 100644
index 0000000..ab4d773
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/dto/TunnelResponseDto.java
@@ -0,0 +1,24 @@
+package dev.thinhha.tunnel_client.dto;
+
+import dev.thinhha.tunnel_client.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_client/entity/HeaderRule.java b/src/main/java/dev/thinhha/tunnel_client/entity/HeaderRule.java
new file mode 100644
index 0000000..4409916
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/entity/HeaderRule.java
@@ -0,0 +1,48 @@
+package dev.thinhha.tunnel_client.entity;
+
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.UUID;
+
+@Entity
+@Table(name = "header_rule")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class HeaderRule {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private UUID id;
+
+ @Column(name = "path_pattern", nullable = false)
+ private String pathPattern;
+
+ @Column(name = "header_name", nullable = false)
+ private String headerName;
+
+ @Column(name = "header_value", nullable = false)
+ private String headerValue;
+
+ @Column(name = "rule_type", nullable = false)
+ @Enumerated(EnumType.STRING)
+ private HeaderRuleType ruleType = HeaderRuleType.ADD;
+
+ @Column(name = "priority", nullable = false)
+ private Integer priority = 0;
+
+ @Column(name = "enabled", nullable = false)
+ private Boolean enabled = true;
+
+ @Column(name = "description")
+ private String description;
+
+ public enum HeaderRuleType {
+ ADD, // Add header (keep existing if present)
+ SET, // Set header (replace existing)
+ REMOVE // Remove header
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/thinhha/tunnel_client/entity/RouteConfig.java b/src/main/java/dev/thinhha/tunnel_client/entity/RouteConfig.java
new file mode 100644
index 0000000..004f8da
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/entity/RouteConfig.java
@@ -0,0 +1,41 @@
+package dev.thinhha.tunnel_client.entity;
+
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.UUID;
+
+@Entity
+@Table(name = "route_config")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class RouteConfig {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private UUID id;
+
+ @Column(name = "path_pattern", nullable = false, unique = true)
+ private String pathPattern;
+
+ @Column(name = "target_url", nullable = false)
+ private String targetUrl;
+
+ @Column(name = "priority", nullable = false)
+ private Integer priority = 0;
+
+ @Column(name = "enabled", nullable = false)
+ private Boolean enabled = true;
+
+ @Column(name = "description")
+ private String description;
+
+ public RouteConfig(String pathPattern, String targetUrl, Integer priority) {
+ this.pathPattern = pathPattern;
+ this.targetUrl = targetUrl;
+ this.priority = priority;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/thinhha/tunnel_client/repository/HeaderRuleRepository.java b/src/main/java/dev/thinhha/tunnel_client/repository/HeaderRuleRepository.java
new file mode 100644
index 0000000..c2769d0
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/repository/HeaderRuleRepository.java
@@ -0,0 +1,20 @@
+package dev.thinhha.tunnel_client.repository;
+
+import dev.thinhha.tunnel_client.entity.HeaderRule;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.UUID;
+
+@Repository
+public interface HeaderRuleRepository extends JpaRepository {
+
+ List findByEnabledTrueOrderByPriorityDescPathPatternDesc();
+
+ @Query("SELECT h FROM HeaderRule h WHERE h.enabled = true AND ?1 LIKE CONCAT(h.pathPattern, '%') ORDER BY h.priority DESC, LENGTH(h.pathPattern) DESC")
+ List findMatchingRules(String path);
+
+ List findByPathPatternAndEnabledTrue(String pathPattern);
+}
\ No newline at end of file
diff --git a/src/main/java/dev/thinhha/tunnel_client/repository/RouteConfigRepository.java b/src/main/java/dev/thinhha/tunnel_client/repository/RouteConfigRepository.java
new file mode 100644
index 0000000..a3a92c9
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/repository/RouteConfigRepository.java
@@ -0,0 +1,20 @@
+package dev.thinhha.tunnel_client.repository;
+
+import dev.thinhha.tunnel_client.entity.RouteConfig;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.UUID;
+
+@Repository
+public interface RouteConfigRepository extends JpaRepository {
+
+ List findByEnabledTrueOrderByPriorityDescPathPatternDesc();
+
+ @Query("SELECT r FROM RouteConfig r WHERE r.enabled = true AND ?1 LIKE CONCAT(r.pathPattern, '%') ORDER BY r.priority DESC, LENGTH(r.pathPattern) DESC")
+ List findMatchingRoutes(String path);
+
+ boolean existsByPathPattern(String pathPattern);
+}
\ No newline at end of file
diff --git a/src/main/java/dev/thinhha/tunnel_client/service/HeaderManipulationService.java b/src/main/java/dev/thinhha/tunnel_client/service/HeaderManipulationService.java
new file mode 100644
index 0000000..cbd1475
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/service/HeaderManipulationService.java
@@ -0,0 +1,90 @@
+package dev.thinhha.tunnel_client.service;
+
+import dev.thinhha.tunnel_client.entity.HeaderRule;
+import dev.thinhha.tunnel_client.repository.HeaderRuleRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class HeaderManipulationService {
+
+ private final HeaderRuleRepository headerRuleRepository;
+
+ public Map processResponseHeaders(String path, Map originalHeaders) {
+ Map processedHeaders = new HashMap<>(originalHeaders);
+
+ List matchingRules = headerRuleRepository.findMatchingRules(path);
+
+ for (HeaderRule rule : matchingRules) {
+ applyHeaderRule(processedHeaders, rule);
+ log.debug("Applied header rule: {} {} -> {}", rule.getRuleType(), rule.getHeaderName(), rule.getHeaderValue());
+ }
+
+ return processedHeaders;
+ }
+
+ private void applyHeaderRule(Map headers, HeaderRule rule) {
+ String headerName = rule.getHeaderName();
+ String headerValue = rule.getHeaderValue();
+
+ switch (rule.getRuleType()) {
+ case ADD -> {
+ // Add header only if not already present
+ if (!headers.containsKey(headerName)) {
+ headers.put(headerName, headerValue);
+ }
+ }
+ case SET -> {
+ // Set header (replace if exists)
+ headers.put(headerName, headerValue);
+ }
+ case REMOVE -> {
+ // Remove header
+ headers.remove(headerName);
+ }
+ }
+ }
+
+ public HeaderRule addHeaderRule(String pathPattern, String headerName, String headerValue,
+ HeaderRule.HeaderRuleType ruleType, Integer priority, String description) {
+ HeaderRule rule = new HeaderRule();
+ rule.setPathPattern(pathPattern);
+ rule.setHeaderName(headerName);
+ rule.setHeaderValue(headerValue);
+ rule.setRuleType(ruleType);
+ rule.setPriority(priority != null ? priority : 0);
+ rule.setDescription(description);
+ rule.setEnabled(true);
+
+ return headerRuleRepository.save(rule);
+ }
+
+ public void removeHeaderRule(String pathPattern, String headerName) {
+ List rules = headerRuleRepository.findByPathPatternAndEnabledTrue(pathPattern);
+ rules.stream()
+ .filter(rule -> rule.getHeaderName().equalsIgnoreCase(headerName))
+ .forEach(rule -> {
+ rule.setEnabled(false);
+ headerRuleRepository.save(rule);
+ });
+ }
+
+ public List getAllHeaderRules() {
+ return headerRuleRepository.findByEnabledTrueOrderByPriorityDescPathPatternDesc();
+ }
+
+ public void addCorsHeaders(String pathPattern, String allowedOrigins, String allowedMethods, String allowedHeaders) {
+ // Add CORS headers
+ addHeaderRule(pathPattern, "Access-Control-Allow-Origin", allowedOrigins, HeaderRule.HeaderRuleType.SET, 100, "CORS - Allowed Origins");
+ addHeaderRule(pathPattern, "Access-Control-Allow-Methods", allowedMethods, HeaderRule.HeaderRuleType.SET, 100, "CORS - Allowed Methods");
+ addHeaderRule(pathPattern, "Access-Control-Allow-Headers", allowedHeaders, HeaderRule.HeaderRuleType.SET, 100, "CORS - Allowed Headers");
+ addHeaderRule(pathPattern, "Access-Control-Allow-Credentials", "true", HeaderRule.HeaderRuleType.SET, 100, "CORS - Allow Credentials");
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/thinhha/tunnel_client/service/RouteResolver.java b/src/main/java/dev/thinhha/tunnel_client/service/RouteResolver.java
new file mode 100644
index 0000000..bf530e2
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/service/RouteResolver.java
@@ -0,0 +1,86 @@
+package dev.thinhha.tunnel_client.service;
+
+import dev.thinhha.tunnel_client.config.TunnelConfig;
+import dev.thinhha.tunnel_client.entity.RouteConfig;
+import dev.thinhha.tunnel_client.repository.RouteConfigRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class RouteResolver {
+
+ private final TunnelConfig tunnelConfig;
+ private final RouteConfigRepository routeConfigRepository;
+
+ public String resolveTargetUrl(String path) {
+ // First check database routes
+ List matchingRoutes = routeConfigRepository.findMatchingRoutes(path);
+ if (!matchingRoutes.isEmpty()) {
+ RouteConfig bestMatch = matchingRoutes.get(0);
+ log.debug("Resolved path '{}' to database target: {}", path, bestMatch.getTargetUrl());
+ return bestMatch.getTargetUrl();
+ }
+
+ // Fallback to configuration routes
+ if (tunnelConfig.getTarget().getRoutes() != null && !tunnelConfig.getTarget().getRoutes().isEmpty()) {
+ String bestMatch = null;
+ String bestMatchUrl = null;
+
+ for (Map.Entry route : tunnelConfig.getTarget().getRoutes().entrySet()) {
+ String routePath = route.getKey();
+ String targetUrl = route.getValue();
+
+ if (path.startsWith(routePath)) {
+ if (bestMatch == null || routePath.length() > bestMatch.length()) {
+ bestMatch = routePath;
+ bestMatchUrl = targetUrl;
+ }
+ }
+ }
+
+ if (bestMatchUrl != null) {
+ log.debug("Resolved path '{}' to config target: {}", path, bestMatchUrl);
+ return bestMatchUrl;
+ }
+ }
+
+ log.debug("No route match for path '{}', using default target: {}", path, tunnelConfig.getTarget().getDefaultUrl());
+ return tunnelConfig.getTarget().getDefaultUrl();
+ }
+
+ public boolean isWebSocketTarget(String targetUrl) {
+ return targetUrl.startsWith("ws://") || targetUrl.startsWith("wss://");
+ }
+
+ public RouteConfig addRoute(String pathPattern, String targetUrl, Integer priority, String description) {
+ RouteConfig route = new RouteConfig();
+ route.setPathPattern(pathPattern);
+ route.setTargetUrl(targetUrl);
+ route.setPriority(priority != null ? priority : 0);
+ route.setDescription(description);
+ route.setEnabled(true);
+
+ return routeConfigRepository.save(route);
+ }
+
+ public void removeRoute(String pathPattern) {
+ routeConfigRepository.findByEnabledTrueOrderByPriorityDescPathPatternDesc()
+ .stream()
+ .filter(route -> route.getPathPattern().equals(pathPattern))
+ .findFirst()
+ .ifPresent(route -> {
+ route.setEnabled(false);
+ routeConfigRepository.save(route);
+ });
+ }
+
+ public List getAllRoutes() {
+ return routeConfigRepository.findByEnabledTrueOrderByPriorityDescPathPatternDesc();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/thinhha/tunnel_client/service/TunnelClient.java b/src/main/java/dev/thinhha/tunnel_client/service/TunnelClient.java
new file mode 100644
index 0000000..dd4e465
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/service/TunnelClient.java
@@ -0,0 +1,435 @@
+package dev.thinhha.tunnel_client.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.thinhha.tunnel_client.config.TunnelConfig;
+import dev.thinhha.tunnel_client.dto.TunnelRequestDto;
+import dev.thinhha.tunnel_client.dto.TunnelResponseDto;
+import dev.thinhha.tunnel_client.types.TunnelRequestType;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.*;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.socket.*;
+import org.springframework.web.socket.client.standard.StandardWebSocketClient;
+import org.springframework.web.socket.handler.BinaryWebSocketHandler;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+
+@Service
+@Slf4j
+public class TunnelClient extends BinaryWebSocketHandler {
+
+ private final TunnelConfig tunnelConfig;
+ private final RouteResolver routeResolver;
+ private final HeaderManipulationService headerManipulationService;
+ private final ObjectMapper objectMapper;
+ private final RestTemplate restTemplate;
+ private WebSocketSession session;
+ private final CountDownLatch connectionLatch = new CountDownLatch(1);
+
+ // Track WebSocket connections: wsConnectionId -> target WebSocket session
+ private final Map webSocketConnections = new ConcurrentHashMap<>();
+
+ public TunnelClient(TunnelConfig tunnelConfig, RouteResolver routeResolver, HeaderManipulationService headerManipulationService, ObjectMapper objectMapper, RestTemplate restTemplate) {
+ this.tunnelConfig = tunnelConfig;
+ this.routeResolver = routeResolver;
+ this.headerManipulationService = headerManipulationService;
+ this.objectMapper = objectMapper;
+ this.restTemplate = restTemplate;
+ }
+
+ public void connect() {
+ try {
+ StandardWebSocketClient client = new StandardWebSocketClient();
+ WebSocketHttpHeaders headers = new WebSocketHttpHeaders();
+
+ if (!tunnelConfig.getClient().getToken().isEmpty()) {
+ headers.add("Authorization", "Bearer " + tunnelConfig.getClient().getToken());
+ }
+
+ URI serverUri = URI.create(tunnelConfig.getServer().getUrl() + "/client");
+ log.info("Connecting to tunnel server at: {}", serverUri);
+
+ client.execute(this, headers, serverUri);
+
+ connectionLatch.await();
+ log.info("Connected to tunnel server as client: {}", tunnelConfig.getClient().getName());
+
+ } catch (Exception e) {
+ log.error("Failed to connect to tunnel server", e);
+ }
+ }
+
+ @Override
+ public void afterConnectionEstablished(WebSocketSession session) throws Exception {
+ this.session = session;
+ log.info("WebSocket connection established with session: {}", session.getId());
+ connectionLatch.countDown();
+ }
+
+ @Override
+ public void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception {
+ try {
+ byte[] payload = message.getPayload().array();
+ TunnelRequestDto request = objectMapper.readValue(payload, TunnelRequestDto.class);
+
+ log.info("Received tunnel request: {} {} {} {}", request.getRequestId(), request.getType(), request.getMethod(), request.getPath());
+
+ TunnelResponseDto response = switch (request.getType()) {
+ case HTTP -> handleHttpTunnelRequest(request);
+ case WS_CONNECT -> handleWebSocketConnect(request);
+ case WS_MESSAGE -> handleWebSocketMessage(request);
+ case WS_CLOSE -> handleWebSocketClose(request);
+ };
+
+ byte[] responseBytes = objectMapper.writeValueAsBytes(response);
+ session.sendMessage(new BinaryMessage(responseBytes));
+
+ } catch (Exception e) {
+ log.error("Error processing tunnel request", e);
+ }
+ }
+
+ private TunnelResponseDto handleHttpTunnelRequest(TunnelRequestDto request) {
+ try {
+ String targetUrl = routeResolver.resolveTargetUrl(request.getPath());
+ String fullUrl = targetUrl + request.getPath();
+
+ log.info("Forwarding request {} {} to: {}", request.getMethod(), request.getPath(), fullUrl);
+
+ HttpHeaders headers = new HttpHeaders();
+ if (request.getHeaders() != null) {
+ request.getHeaders().forEach((key, value) -> {
+ // Handle Content-Type specifically to ensure proper parsing
+ if ("Content-Type".equalsIgnoreCase(key)) {
+ headers.set(key, value);
+ log.debug("Setting Content-Type: {}", value);
+ } else if ("Content-Length".equalsIgnoreCase(key)) {
+ // Skip Content-Length as RestTemplate will set it automatically
+ log.debug("Skipping Content-Length header (will be set automatically)");
+ } else {
+ headers.add(key, value);
+ }
+ });
+ }
+
+ // Create HTTP entity with proper body handling
+ final HttpEntity> httpEntity = createHttpEntity(request, headers);
+
+ // Use byte[] to handle all response types properly
+ ResponseEntity response = restTemplate.exchange(
+ fullUrl,
+ HttpMethod.valueOf(request.getMethod().name()),
+ httpEntity,
+ byte[].class
+ );
+
+ // Extract and process response headers
+ Map responseHeaders = extractAndProcessHeaders(response, request.getPath());
+
+ int statusCode = response.getStatusCode().value();
+ log.info("Target service responded: {} {} -> {} {}",
+ request.getMethod(), request.getPath(), statusCode,
+ getStatusCodeDescription(statusCode));
+
+ TunnelResponseDto tunnelResponse = new TunnelResponseDto();
+ tunnelResponse.setRequestId(request.getRequestId());
+ tunnelResponse.setType(TunnelRequestType.HTTP);
+ tunnelResponse.setStatusCode(statusCode);
+ tunnelResponse.setHeaders(responseHeaders);
+ tunnelResponse.setBody(response.getBody());
+
+ return tunnelResponse;
+
+ } catch (Exception e) {
+ log.error("Error forwarding request to target service", e);
+
+ Map errorHeaders = new HashMap<>();
+ errorHeaders.put("Content-Type", "text/plain");
+
+ TunnelResponseDto errorResponse = new TunnelResponseDto();
+ errorResponse.setRequestId(request.getRequestId());
+ errorResponse.setType(TunnelRequestType.HTTP);
+ errorResponse.setStatusCode(500);
+ errorResponse.setHeaders(errorHeaders);
+ errorResponse.setBody(("Internal Server Error: " + e.getMessage()).getBytes());
+
+ return errorResponse;
+ }
+ }
+
+ @Override
+ public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
+ log.error("Transport error: {}", exception.getMessage());
+ }
+
+ @Override
+ public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
+ log.info("Connection closed with status: {}", closeStatus);
+ this.session = null;
+ }
+
+ @Override
+ public boolean supportsPartialMessages() {
+ return false;
+ }
+
+ private String getStatusCodeDescription(int statusCode) {
+ return switch (statusCode / 100) {
+ case 2 -> "Success";
+ case 3 -> "Redirection";
+ case 4 -> "Client Error";
+ case 5 -> "Server Error";
+ default -> "Unknown";
+ };
+ }
+
+ private HttpEntity> createHttpEntity(TunnelRequestDto request, HttpHeaders headers) {
+ if (request.getBody() != null && request.getBody().length > 0) {
+ String contentType = headers.getFirst("Content-Type");
+ if (contentType != null && contentType.startsWith("application/json")) {
+ // For JSON, convert bytes to string for proper handling
+ String jsonBody = new String(request.getBody());
+ return new HttpEntity<>(jsonBody, headers);
+ } else if (contentType != null && contentType.startsWith("application/x-www-form-urlencoded")) {
+ // For form data, convert bytes to string
+ String formBody = new String(request.getBody());
+ return new HttpEntity<>(formBody, headers);
+ } else {
+ // For binary data or other content types, use byte array
+ return new HttpEntity<>(request.getBody(), headers);
+ }
+ } else {
+ return new HttpEntity<>(headers);
+ }
+ }
+
+ private Map extractAndProcessHeaders(ResponseEntity response, String path) {
+ Map responseHeaders = new HashMap<>();
+ response.getHeaders().forEach((key, values) -> {
+ if (!values.isEmpty()) {
+ responseHeaders.put(key, values.get(0));
+ }
+ });
+
+ // Apply header manipulation rules
+ return headerManipulationService.processResponseHeaders(path, responseHeaders);
+ }
+
+ private TunnelResponseDto handleWebSocketConnect(TunnelRequestDto request) {
+ try {
+ String targetUrl = routeResolver.resolveTargetUrl(request.getPath());
+ String wsUrl = targetUrl.replace("http://", "ws://").replace("https://", "wss://") + request.getPath();
+
+ log.info("Establishing WebSocket connection to: {}", wsUrl);
+
+ StandardWebSocketClient client = new StandardWebSocketClient();
+ WebSocketHttpHeaders headers = new WebSocketHttpHeaders();
+
+ if (request.getHeaders() != null) {
+ request.getHeaders().forEach(headers::add);
+ }
+
+ // Create handler for target WebSocket
+ WebSocketHandler targetHandler = new WebSocketHandler() {
+ @Override
+ public void afterConnectionEstablished(WebSocketSession targetSession) throws Exception {
+ webSocketConnections.put(request.getWsConnectionId(), targetSession);
+ log.info("WebSocket connection established: {}", request.getWsConnectionId());
+
+ // Send success response back to tunnel server
+ TunnelResponseDto response = new TunnelResponseDto();
+ response.setRequestId(request.getRequestId());
+ response.setType(TunnelRequestType.WS_CONNECT);
+ response.setStatusCode(101); // WebSocket upgrade
+ response.setHeaders(new HashMap<>());
+ response.setBody(new byte[0]);
+ response.setWsConnectionId(request.getWsConnectionId());
+ response.setWsConnectionEstablished(true);
+
+ try {
+ byte[] responseBytes = objectMapper.writeValueAsBytes(response);
+ session.sendMessage(new BinaryMessage(responseBytes));
+ } catch (Exception e) {
+ log.error("Error sending WebSocket connect response", e);
+ }
+ }
+
+ @Override
+ public void handleMessage(WebSocketSession targetSession, WebSocketMessage> message) throws Exception {
+ // Forward message from target to tunnel server
+ String messageType;
+ byte[] messageBody;
+
+ if (message instanceof TextMessage textMsg) {
+ messageType = "TEXT";
+ messageBody = textMsg.getPayload().getBytes();
+ } else if (message instanceof BinaryMessage binaryMsg) {
+ messageType = "BINARY";
+ messageBody = binaryMsg.getPayload().array();
+ } else {
+ return; // Unknown message type
+ }
+
+ TunnelResponseDto response = new TunnelResponseDto(
+ java.util.UUID.randomUUID().toString(),
+ TunnelRequestType.WS_MESSAGE,
+ 200,
+ new HashMap<>(),
+ messageBody,
+ request.getWsConnectionId(),
+ messageType,
+ false
+ );
+
+ try {
+ byte[] responseBytes = objectMapper.writeValueAsBytes(response);
+ session.sendMessage(new BinaryMessage(responseBytes));
+ } catch (Exception e) {
+ log.error("Error forwarding WebSocket message", e);
+ }
+ }
+
+ @Override
+ public void afterConnectionClosed(WebSocketSession targetSession, CloseStatus closeStatus) throws Exception {
+ webSocketConnections.remove(request.getWsConnectionId());
+ log.info("WebSocket connection closed: {}", request.getWsConnectionId());
+
+ // Notify tunnel server of connection close
+ TunnelResponseDto response = new TunnelResponseDto(
+ java.util.UUID.randomUUID().toString(),
+ TunnelRequestType.WS_CLOSE,
+ closeStatus.getCode(),
+ new HashMap<>(),
+ new byte[0],
+ request.getWsConnectionId(),
+ null,
+ false
+ );
+
+ try {
+ byte[] responseBytes = objectMapper.writeValueAsBytes(response);
+ session.sendMessage(new BinaryMessage(responseBytes));
+ } catch (Exception e) {
+ log.error("Error sending WebSocket close notification", e);
+ }
+ }
+
+ @Override
+ public void handleTransportError(WebSocketSession targetSession, Throwable exception) throws Exception {
+ log.error("WebSocket transport error: {}", exception.getMessage());
+ }
+
+ @Override
+ public boolean supportsPartialMessages() {
+ return false;
+ }
+ };
+
+ client.execute(targetHandler, headers, URI.create(wsUrl));
+
+ // Return immediate response (actual connection established response sent in handler)
+ TunnelResponseDto response = new TunnelResponseDto();
+ response.setRequestId(request.getRequestId());
+ response.setType(TunnelRequestType.WS_CONNECT);
+ response.setStatusCode(102); // Processing
+ response.setHeaders(new HashMap<>());
+ response.setBody(new byte[0]);
+ response.setWsConnectionId(request.getWsConnectionId());
+ response.setWsConnectionEstablished(false);
+ return response;
+
+ } catch (Exception e) {
+ log.error("Error establishing WebSocket connection", e);
+ TunnelResponseDto response = new TunnelResponseDto();
+ response.setRequestId(request.getRequestId());
+ response.setType(TunnelRequestType.WS_CONNECT);
+ response.setStatusCode(500);
+ response.setHeaders(new HashMap<>());
+ response.setBody(("WebSocket connection failed: " + e.getMessage()).getBytes());
+ response.setWsConnectionId(request.getWsConnectionId());
+ response.setWsConnectionEstablished(false);
+ return response;
+ }
+ }
+
+ private TunnelResponseDto handleWebSocketMessage(TunnelRequestDto request) {
+ try {
+ WebSocketSession targetSession = webSocketConnections.get(request.getWsConnectionId());
+ if (targetSession == null || !targetSession.isOpen()) {
+ log.warn("WebSocket connection not found or closed: {}", request.getWsConnectionId());
+ TunnelResponseDto response = new TunnelResponseDto();
+ response.setRequestId(request.getRequestId());
+ response.setType(TunnelRequestType.WS_MESSAGE);
+ response.setStatusCode(404);
+ response.setHeaders(new HashMap<>());
+ response.setBody("WebSocket connection not found".getBytes());
+ response.setWsConnectionId(request.getWsConnectionId());
+ return response;
+ }
+
+ // Forward message to target
+ if ("TEXT".equals(request.getWsMessageType())) {
+ String textPayload = new String(request.getBody());
+ targetSession.sendMessage(new TextMessage(textPayload));
+ } else {
+ targetSession.sendMessage(new BinaryMessage(request.getBody()));
+ }
+
+ TunnelResponseDto response = new TunnelResponseDto();
+ response.setRequestId(request.getRequestId());
+ response.setType(TunnelRequestType.WS_MESSAGE);
+ response.setStatusCode(200);
+ response.setHeaders(new HashMap<>());
+ response.setBody(new byte[0]);
+ response.setWsConnectionId(request.getWsConnectionId());
+ return response;
+
+ } catch (Exception e) {
+ log.error("Error handling WebSocket message", e);
+ TunnelResponseDto response = new TunnelResponseDto();
+ response.setRequestId(request.getRequestId());
+ response.setType(TunnelRequestType.WS_MESSAGE);
+ response.setStatusCode(500);
+ response.setHeaders(new HashMap<>());
+ response.setBody(("WebSocket message failed: " + e.getMessage()).getBytes());
+ response.setWsConnectionId(request.getWsConnectionId());
+ return response;
+ }
+ }
+
+ private TunnelResponseDto handleWebSocketClose(TunnelRequestDto request) {
+ try {
+ WebSocketSession targetSession = webSocketConnections.remove(request.getWsConnectionId());
+ if (targetSession != null && targetSession.isOpen()) {
+ targetSession.close();
+ log.info("Closed WebSocket connection: {}", request.getWsConnectionId());
+ }
+
+ TunnelResponseDto response = new TunnelResponseDto();
+ response.setRequestId(request.getRequestId());
+ response.setType(TunnelRequestType.WS_CLOSE);
+ response.setStatusCode(200);
+ response.setHeaders(new HashMap<>());
+ response.setBody(new byte[0]);
+ response.setWsConnectionId(request.getWsConnectionId());
+ return response;
+
+ } catch (Exception e) {
+ log.error("Error closing WebSocket connection", e);
+ TunnelResponseDto response = new TunnelResponseDto();
+ response.setRequestId(request.getRequestId());
+ response.setType(TunnelRequestType.WS_CLOSE);
+ response.setStatusCode(500);
+ response.setHeaders(new HashMap<>());
+ response.setBody(new byte[0]);
+ response.setWsConnectionId(request.getWsConnectionId());
+ return response;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/thinhha/tunnel_client/service/WebSocketTunnelService.java b/src/main/java/dev/thinhha/tunnel_client/service/WebSocketTunnelService.java
new file mode 100644
index 0000000..3679ade
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/service/WebSocketTunnelService.java
@@ -0,0 +1,133 @@
+package dev.thinhha.tunnel_client.service;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.web.socket.*;
+import org.springframework.web.socket.client.standard.StandardWebSocketClient;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Service
+@Slf4j
+public class WebSocketTunnelService {
+
+ @Value("${tunnel.target.url:http://localhost:8080}")
+ private String targetUrl;
+
+ private final Map clientSessions = new ConcurrentHashMap<>();
+ private final Map targetSessions = new ConcurrentHashMap<>();
+
+ public void handleWebSocketTunnelRequest(String requestId, String path, Map headers,
+ WebSocketSession clientSession) {
+ try {
+ String targetWsUrl = targetUrl.replace("http://", "ws://").replace("https://", "wss://") + path;
+
+ StandardWebSocketClient client = new StandardWebSocketClient();
+ WebSocketHttpHeaders wsHeaders = new WebSocketHttpHeaders();
+
+ if (headers != null) {
+ headers.forEach(wsHeaders::add);
+ }
+
+ WebSocketHandler targetHandler = new WebSocketHandler() {
+ @Override
+ public void afterConnectionEstablished(WebSocketSession session) throws Exception {
+ log.info("WebSocket tunnel established: {} -> {}", requestId, targetWsUrl);
+ targetSessions.put(requestId, session);
+ }
+
+ @Override
+ public void handleMessage(WebSocketSession session, WebSocketMessage> message) throws Exception {
+ if(message.getClass().isAssignableFrom(BinaryMessage.class)) {
+ handleBinaryMessage(session, (BinaryMessage) message);
+ } else if(message.getClass().isAssignableFrom(TextMessage.class)) {
+ handleTextMessage(session, (TextMessage) message);
+ }
+ }
+
+ public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
+ WebSocketSession clientWs = clientSessions.get(requestId);
+ if (clientWs != null && clientWs.isOpen()) {
+ clientWs.sendMessage(message);
+ }
+ }
+
+ public void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception {
+ WebSocketSession clientWs = clientSessions.get(requestId);
+ if (clientWs != null && clientWs.isOpen()) {
+ clientWs.sendMessage(message);
+ }
+ }
+
+ @Override
+ public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
+ log.info("Target WebSocket closed: {}", requestId);
+ targetSessions.remove(requestId);
+
+ WebSocketSession clientWs = clientSessions.get(requestId);
+ if (clientWs != null && clientWs.isOpen()) {
+ clientWs.close(closeStatus);
+ }
+ clientSessions.remove(requestId);
+ }
+
+ @Override
+ public boolean supportsPartialMessages() {
+ return false;
+ }
+
+ @Override
+ public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
+ log.error("WebSocket tunnel error: {}", exception.getMessage());
+ }
+ };
+
+ client.execute(targetHandler, wsHeaders, URI.create(targetWsUrl));
+ clientSessions.put(requestId, clientSession);
+
+ } catch (Exception e) {
+ log.error("Failed to establish WebSocket tunnel", e);
+ try {
+ clientSession.close(CloseStatus.SERVER_ERROR);
+ } catch (IOException ex) {
+ log.error("Failed to close client session", ex);
+ }
+ }
+ }
+
+ public void forwardToTarget(String requestId, WebSocketMessage> message) {
+ WebSocketSession targetSession = targetSessions.get(requestId);
+ if (targetSession != null && targetSession.isOpen()) {
+ try {
+ targetSession.sendMessage(message);
+ } catch (IOException e) {
+ log.error("Failed to forward message to target", e);
+ }
+ }
+ }
+
+ public void closeWebSocketTunnel(String requestId) {
+ WebSocketSession clientSession = clientSessions.remove(requestId);
+ WebSocketSession targetSession = targetSessions.remove(requestId);
+
+ if (clientSession != null && clientSession.isOpen()) {
+ try {
+ clientSession.close();
+ } catch (IOException e) {
+ log.error("Failed to close client session", e);
+ }
+ }
+
+ if (targetSession != null && targetSession.isOpen()) {
+ try {
+ targetSession.close();
+ } catch (IOException e) {
+ log.error("Failed to close target session", e);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/thinhha/tunnel_client/types/HttpMethod.java b/src/main/java/dev/thinhha/tunnel_client/types/HttpMethod.java
new file mode 100644
index 0000000..2b07d73
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/types/HttpMethod.java
@@ -0,0 +1,5 @@
+package dev.thinhha.tunnel_client.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_client/types/TunnelRequestType.java b/src/main/java/dev/thinhha/tunnel_client/types/TunnelRequestType.java
new file mode 100644
index 0000000..e144e54
--- /dev/null
+++ b/src/main/java/dev/thinhha/tunnel_client/types/TunnelRequestType.java
@@ -0,0 +1,8 @@
+package dev.thinhha.tunnel_client.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/resources/application-docker.yaml b/src/main/resources/application-docker.yaml
new file mode 100644
index 0000000..890707c
--- /dev/null
+++ b/src/main/resources/application-docker.yaml
@@ -0,0 +1,47 @@
+spring:
+ application:
+ name: Tunnel Client
+ datasource:
+ url: ${SPRING_DATASOURCE_URL:jdbc:h2:file:/app/data/tunnel_client}
+ driver-class-name: org.h2.Driver
+ username: ${SPRING_DATASOURCE_USERNAME:sa}
+ password: ${SPRING_DATASOURCE_PASSWORD:tunnel_client_password}
+ jpa:
+ hibernate:
+ ddl-auto: update
+ show-sql: false
+ h2:
+ console:
+ enabled: true
+ path: /h2-console
+
+server:
+ port: 8765
+
+tunnel:
+ server:
+ url: ${TUNNEL_SERVER_URL:ws://tunnel-server:5678}
+ client:
+ name: ${TUNNEL_CLIENT_NAME:docker-client}
+ token: ${TUNNEL_CLIENT_TOKEN:}
+ target:
+ default-url: ${TUNNEL_TARGET_DEFAULT_URL:http://host.docker.internal:8080}
+ routes:
+ /api/v1: ${TUNNEL_TARGET_API_V1:http://host.docker.internal:3000}
+ /api/v2: ${TUNNEL_TARGET_API_V2:http://host.docker.internal:3001}
+ /admin: ${TUNNEL_TARGET_ADMIN:http://host.docker.internal:4000}
+ /ws: ${TUNNEL_TARGET_WS:ws://host.docker.internal:8080}
+
+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 a73b6f6..941f3dd 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -1,6 +1,36 @@
spring:
application:
name: Tunnel Client
+ datasource:
+ url: jdbc:h2:file:./data/tunnel_client
+ driver-class-name: org.h2.Driver
+ username: sa
+ password: tunnel_client_password
+ jpa:
+ hibernate:
+ ddl-auto: update
+ show-sql: false
+ properties:
+ hibernate:
+ format_sql: true
+ h2:
+ console:
+ enabled: true
+ path: /h2-console
server:
port: 8765
+
+tunnel:
+ server:
+ url: ws://localhost:5678
+ client:
+ name: client1
+ token: eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiY2xpZW50MSIsImlzcyI6InR1bm5lbC1zZXJ2ZXIiLCJzdWIiOiJjbGllbnQxIiwiZXhwIjoyNjE1Njg3MDQwLCJpYXQiOjE3NTE2ODcwNDB9.0UsKSSa3Ep0s8ILp_9iAC4y8DrY5Rv-B8p9uCPKGOHo
+ target:
+ default-url: http://localhost:8080
+ routes:
+ /api/v1: http://localhost:3000
+ /api/v2: http://localhost:3001
+ /admin: http://localhost:4000
+ /ws: ws://localhost:8080