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>
|
<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>
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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:
|
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
|
||||||
|
Reference in New Issue
Block a user