Working HTTP tunnel
This commit is contained in:
47
Dockerfile
Normal file
47
Dockerfile
Normal file
@ -0,0 +1,47 @@
|
||||
# Multi-stage build for tunnel 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
BIN
data/tunnel_client.mv.db
Normal file
Binary file not shown.
27
pom.xml
27
pom.xml
@ -35,6 +35,33 @@
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</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>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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; }
|
||||
}
|
||||
}
|
@ -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; }
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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;
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
package dev.thinhha.tunnel_client.types;
|
||||
|
||||
public enum HttpMethod {
|
||||
GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE
|
||||
}
|
@ -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
|
||||
}
|
47
src/main/resources/application-docker.yaml
Normal file
47
src/main/resources/application-docker.yaml
Normal 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
|
@ -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
|
||||
|
Reference in New Issue
Block a user