Working HTTP tunnel

This commit is contained in:
2025-07-05 06:45:39 +00:00
parent 9fdaf0fc59
commit eea345e93e
24 changed files with 1413 additions and 0 deletions

47
Dockerfile Normal file
View File

@ -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"]

BIN
data/tunnel_client.mv.db Normal file

Binary file not shown.

27
pom.xml
View File

@ -35,6 +35,33 @@
<artifactId>spring-boot-starter-websocket</artifactId> <artifactId>spring-boot-starter-websocket</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>

View File

@ -1,7 +1,10 @@
package dev.thinhha.tunnel_client; 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.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication @SpringBootApplication
public class TunnelClientApplication { public class TunnelClientApplication {
@ -10,4 +13,18 @@ public class TunnelClientApplication {
SpringApplication.run(TunnelClientApplication.class, args); 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);
}
};
}
} }

View File

@ -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");
}
}
}

View File

@ -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
}
}

View File

@ -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<String, String> routes;
}
}

View File

@ -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;
}
}

View File

@ -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<HeaderRule> getAllHeaderRules() {
return headerManipulationService.getAllHeaderRules();
}
@PostMapping
public ResponseEntity<HeaderRule> 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<String> addCorsHeaders(@RequestBody CorsRequest request) {
headerManipulationService.addCorsHeaders(
request.getPathPattern(),
request.getAllowedOrigins(),
request.getAllowedMethods(),
request.getAllowedHeaders()
);
return ResponseEntity.ok("CORS headers added successfully");
}
@DeleteMapping
public ResponseEntity<Void> 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; }
}
}

View File

@ -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<RouteConfig> getAllRoutes() {
return routeResolver.getAllRoutes();
}
@PostMapping
public ResponseEntity<RouteConfig> addRoute(@RequestBody RouteRequest request) {
RouteConfig route = routeResolver.addRoute(
request.getPathPattern(),
request.getTargetUrl(),
request.getPriority(),
request.getDescription()
);
return ResponseEntity.ok(route);
}
@DeleteMapping("/{pathPattern}")
public ResponseEntity<Void> removeRoute(@PathVariable String pathPattern) {
routeResolver.removeRoute(pathPattern);
return ResponseEntity.ok().build();
}
@GetMapping("/resolve/{path}")
public ResponseEntity<String> 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; }
}
}

View File

@ -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<String, String> headers;
private byte[] body;
private String clientShortName;
// WebSocket specific fields
private String wsConnectionId; // For tracking WS connections
private String wsMessageType; // TEXT or BINARY
}

View File

@ -0,0 +1,24 @@
package dev.thinhha.tunnel_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<String, String> headers;
private byte[] body;
// WebSocket specific fields
private String wsConnectionId;
private String wsMessageType; // TEXT or BINARY
private boolean wsConnectionEstablished;
}

View File

@ -0,0 +1,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
}
}

View File

@ -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;
}
}

View File

@ -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<HeaderRule, UUID> {
List<HeaderRule> 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<HeaderRule> findMatchingRules(String path);
List<HeaderRule> findByPathPatternAndEnabledTrue(String pathPattern);
}

View File

@ -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<RouteConfig, UUID> {
List<RouteConfig> 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<RouteConfig> findMatchingRoutes(String path);
boolean existsByPathPattern(String pathPattern);
}

View File

@ -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<String, String> processResponseHeaders(String path, Map<String, String> originalHeaders) {
Map<String, String> processedHeaders = new HashMap<>(originalHeaders);
List<HeaderRule> 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<String, String> 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<HeaderRule> rules = headerRuleRepository.findByPathPatternAndEnabledTrue(pathPattern);
rules.stream()
.filter(rule -> rule.getHeaderName().equalsIgnoreCase(headerName))
.forEach(rule -> {
rule.setEnabled(false);
headerRuleRepository.save(rule);
});
}
public List<HeaderRule> 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");
}
}

View File

@ -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<RouteConfig> 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<String, String> 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<RouteConfig> getAllRoutes() {
return routeConfigRepository.findByEnabledTrueOrderByPriorityDescPathPatternDesc();
}
}

View File

@ -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<String, WebSocketSession> 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<byte[]> response = restTemplate.exchange(
fullUrl,
HttpMethod.valueOf(request.getMethod().name()),
httpEntity,
byte[].class
);
// Extract and process response headers
Map<String, String> 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<String, String> 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<String, String> extractAndProcessHeaders(ResponseEntity<byte[]> response, String path) {
Map<String, String> 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;
}
}
}

View File

@ -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<String, WebSocketSession> clientSessions = new ConcurrentHashMap<>();
private final Map<String, WebSocketSession> targetSessions = new ConcurrentHashMap<>();
public void handleWebSocketTunnelRequest(String requestId, String path, Map<String, String> 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);
}
}
}
}

View File

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

View File

@ -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
}

View File

@ -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

View File

@ -1,6 +1,36 @@
spring: spring:
application: application:
name: Tunnel Client 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: server:
port: 8765 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