From eea345e93e7e6f3e9c961b12a6c2a50e160574a0 Mon Sep 17 00:00:00 2001 From: Thinh Ha Date: Sat, 5 Jul 2025 06:45:39 +0000 Subject: [PATCH] Working HTTP tunnel --- Dockerfile | 47 ++ data/tunnel_client.mv.db | Bin 0 -> 32768 bytes pom.xml | 27 ++ .../TunnelClientApplication.java | 17 + .../tunnel_client/config/DataInitializer.java | 51 ++ .../config/NoOpResponseErrorHandler.java | 21 + .../tunnel_client/config/TunnelConfig.java | 34 ++ .../tunnel_client/config/WebSocketConfig.java | 38 ++ .../controller/HeaderRuleController.java | 101 ++++ .../controller/RouteController.java | 64 +++ .../tunnel_client/dto/TunnelRequestDto.java | 26 ++ .../tunnel_client/dto/TunnelResponseDto.java | 24 + .../tunnel_client/entity/HeaderRule.java | 48 ++ .../tunnel_client/entity/RouteConfig.java | 41 ++ .../repository/HeaderRuleRepository.java | 20 + .../repository/RouteConfigRepository.java | 20 + .../service/HeaderManipulationService.java | 90 ++++ .../tunnel_client/service/RouteResolver.java | 86 ++++ .../tunnel_client/service/TunnelClient.java | 435 ++++++++++++++++++ .../service/WebSocketTunnelService.java | 133 ++++++ .../tunnel_client/types/HttpMethod.java | 5 + .../types/TunnelRequestType.java | 8 + src/main/resources/application-docker.yaml | 47 ++ src/main/resources/application.yaml | 30 ++ 24 files changed, 1413 insertions(+) create mode 100644 Dockerfile create mode 100644 data/tunnel_client.mv.db create mode 100644 src/main/java/dev/thinhha/tunnel_client/config/DataInitializer.java create mode 100644 src/main/java/dev/thinhha/tunnel_client/config/NoOpResponseErrorHandler.java create mode 100644 src/main/java/dev/thinhha/tunnel_client/config/TunnelConfig.java create mode 100644 src/main/java/dev/thinhha/tunnel_client/config/WebSocketConfig.java create mode 100644 src/main/java/dev/thinhha/tunnel_client/controller/HeaderRuleController.java create mode 100644 src/main/java/dev/thinhha/tunnel_client/controller/RouteController.java create mode 100644 src/main/java/dev/thinhha/tunnel_client/dto/TunnelRequestDto.java create mode 100644 src/main/java/dev/thinhha/tunnel_client/dto/TunnelResponseDto.java create mode 100644 src/main/java/dev/thinhha/tunnel_client/entity/HeaderRule.java create mode 100644 src/main/java/dev/thinhha/tunnel_client/entity/RouteConfig.java create mode 100644 src/main/java/dev/thinhha/tunnel_client/repository/HeaderRuleRepository.java create mode 100644 src/main/java/dev/thinhha/tunnel_client/repository/RouteConfigRepository.java create mode 100644 src/main/java/dev/thinhha/tunnel_client/service/HeaderManipulationService.java create mode 100644 src/main/java/dev/thinhha/tunnel_client/service/RouteResolver.java create mode 100644 src/main/java/dev/thinhha/tunnel_client/service/TunnelClient.java create mode 100644 src/main/java/dev/thinhha/tunnel_client/service/WebSocketTunnelService.java create mode 100644 src/main/java/dev/thinhha/tunnel_client/types/HttpMethod.java create mode 100644 src/main/java/dev/thinhha/tunnel_client/types/TunnelRequestType.java create mode 100644 src/main/resources/application-docker.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c3e924c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +# Multi-stage build for tunnel client +FROM maven:3.9.6-eclipse-temurin-21 AS builder + +WORKDIR /app + +# Copy pom.xml first for better Docker layer caching +COPY pom.xml . +RUN mvn dependency:go-offline -B + +# Copy source code and build +COPY src ./src +RUN mvn clean package -DskipTests + +# Runtime stage +FROM eclipse-temurin:21-jre-alpine + +WORKDIR /app + +# Create data directory for H2 database +RUN mkdir -p /app/data + +# Create non-root user +RUN addgroup -g 1001 -S tunnel && \ + adduser -S tunnel -u 1001 -G tunnel + +# Copy the jar file +COPY --from=builder /app/target/*.jar app.jar + +# Change ownership of the app directory +RUN chown -R tunnel:tunnel /app + +# Switch to non-root user +USER tunnel + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8765/actuator/health || exit 1 + +# Expose port +EXPOSE 8765 + +# Environment variables +ENV SPRING_PROFILES_ACTIVE=docker +ENV JAVA_OPTS="-Xmx512m -Xms256m" + +# Run the application +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/data/tunnel_client.mv.db b/data/tunnel_client.mv.db new file mode 100644 index 0000000000000000000000000000000000000000..ad65b9d84835ebd8eaeb465d636f5377c7e10be7 GIT binary patch literal 32768 zcmeHQS!^4}8D3Jd#K@MDxJr5;2{!a0%b@5zxw}MzgrZg!VTx3E$W~fJ-D7AGp-6?K zRO3ix0OY`MRD9&>n7qQm2s!zmG#Hu5siR?J9 zDYnL0mdK$a`lg`c)7GZ~`ydT%=jv0Fi)X zbF$!5094ThM-tGiS#d*B*8AGxthGdl!O|is>WX*^foWh4< zR!rlyQYoaGs$nCUWRPat&Joj6c*v9tK^8b^T(bqEB*sFfCJUOR@gXZ_h%rl%L$+$0 zk`}W_k|c$bG&NGIpNp;w(T(j!vsOpZ=7s1AQA8kn z#i(qd!o^K=WWliD?8T#9g>5sMtDT=WnufpB*tplfdrw1UdJjHc+eFnu-KaKT@BoEXgIwA) z%nFKf{vFZ36T1^%+(a9jfFo3V5btkQ?OM8aF3NZA3J*AEuHyT$rn=Dxh}W!{U> ze>bK7W$x>+&)tZT+Q7@4muJUi0U=-oP@ojh0D zSeVrS_c0TJQyr^XHeuqVx~56k zHbK(_LbDvxWY;v0a}74s#)gWL;IotC1N?qM-*{1#B{$CrnjPB@2EiZC$28T=3x*CrLCrDL+2 z#@tlhDLwm^%Y!7B2ZBi1P${899*Q(fzT zZu{m7ds!|3)XQ?Qbpq~Qpq?6ZmNSd!SXUa>Iu%<7Q*VCk9Cj<*jsz_M70|0K2{SIfe z%*f{#AbpJ)xg3Py{t;&6`6)c?kDiC?=^sUaB0v$K2v7tl0u%v?!0n9y@&5^*ZpYHK z_G)?*vqe?hGyk95-|XV^&P;U-IJ|$(TmV4;`~hH2s_>4N-0}OxF0vEzYJ1*&W>d7*FIvMtWnP9t?U)yg0K7LFVDz1< zxS`nqJXg^<8z6MdRaiaKJx0qwGS)~c^4oWFW&_}*HFGsrOzYv}6VC7!PR)neVlg$( zX0in~Q%t8LhYx0=eFIxfiaj9XIAB<9jOR5O>& zru9UoyQW1wF|X&=GKnR9P)!3aSxKaeLtnF;C@ikQ2CN)tarb?ebE#}DRagzPsZ2pX z1?}rX!9i-Ru(}LZ=$Ycu@#)0;{B(q!&go0p72V0@^}_T_>#~WNBkY5dr;|B7QP5ea zehKhmPwT5}c)2*2P9?+9uzPe3j;6%{#`jlUIXodyom`h*xQJt8XhOSNz-PKZ8S9hX@`{FqC^=JF{bw}x;IvehH zW1TI9XP`mP2HX}EGpR?5Ivx`8?0hO;NM(|Ro~NaF`jj5y&nzsS<&;NPi)Y1E@l-Ou zT0FBNuHh$Z?K_%q*%CifpT)r@K0?>Fhup>CGVI7QmeH(Z@++oqo`)V;vxBhDX z$>9D`CJ-DMWu_i~J@MurKbL*<3-A5p%y*{Gy!co$o6EDaY@$-BokupCt(VW0tBu53 z!m?1KF`KMaoAp{{7N4IbXY~_{CymYWsC{^9W&6s%zjSHge^>tUT$Q{3jgc+X7Q+>hogx?aMOF?tZyN&W_Us74+KXJj54EB-8TvLARp-I+L5VloS?=)X7ZiUN$+X+$8RF*Z{D4F z>_=D6A7Y+N9s&!unzh;T#%8S!4P{I9+6HUYs-^O|tvUuGk%FGz=8vwfH=CRBlP4=R z%c!i^8lYMRUNiM}>Ta6QLqP`z*IN>P$P5XpfPqQRGBiIW&{Y{L7WJqU?f)wV#Q&u|_W!Le&%*Klv4GC+KoF%^k7&-b*&-7FB)sV; z<#z1$w<-Ytf8=K)4=}-lyC(m?hU}lJ{mEJekYay0)&3u>GX54&^4}uD`>|y3p1{5d zOy2g5jfd`-U>;bRYQklcxsXa4tZBMPky`=uX1bMJ%s;%i2DEUXE~;H?d6yfU5?qg z#Ps%Fj!E?Yf%oo;hT6+9Jza#kQ(eT1U9oA1k4@h|VA|1#_gR@~wt~QY zqtvBRJ4$W3QR+_RL2s1W(Yy4l%p{ui{>qoO-v62gnw}!2J4#LF(}-rBS=Z$3{mtZ&81?dl~!9^=Cb;`t?80org.springframework.boot spring-boot-starter-websocket + + + org.springframework.boot + spring-boot-starter-web + + + + org.projectlombok + lombok + provided + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + com.h2database + h2 + runtime + + + + org.springframework.boot + spring-boot-starter-actuator + org.springframework.boot diff --git a/src/main/java/dev/thinhha/tunnel_client/TunnelClientApplication.java b/src/main/java/dev/thinhha/tunnel_client/TunnelClientApplication.java index 33264f7..086c7f5 100644 --- a/src/main/java/dev/thinhha/tunnel_client/TunnelClientApplication.java +++ b/src/main/java/dev/thinhha/tunnel_client/TunnelClientApplication.java @@ -1,7 +1,10 @@ package dev.thinhha.tunnel_client; +import dev.thinhha.tunnel_client.service.TunnelClient; +import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; @SpringBootApplication public class TunnelClientApplication { @@ -10,4 +13,18 @@ public class TunnelClientApplication { SpringApplication.run(TunnelClientApplication.class, args); } + @Bean + CommandLineRunner runner(TunnelClient tunnelClient) { + return args -> { + tunnelClient.connect(); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("Shutting down tunnel client..."); + })); + + while (true) { + Thread.sleep(1000); + } + }; + } } diff --git a/src/main/java/dev/thinhha/tunnel_client/config/DataInitializer.java b/src/main/java/dev/thinhha/tunnel_client/config/DataInitializer.java new file mode 100644 index 0000000..0f82b25 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/config/DataInitializer.java @@ -0,0 +1,51 @@ +package dev.thinhha.tunnel_client.config; + +import dev.thinhha.tunnel_client.entity.RouteConfig; +import dev.thinhha.tunnel_client.repository.RouteConfigRepository; +import dev.thinhha.tunnel_client.service.HeaderManipulationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class DataInitializer implements CommandLineRunner { + + private final RouteConfigRepository routeConfigRepository; + private final HeaderManipulationService headerManipulationService; + private final TunnelConfig tunnelConfig; + + @Override + public void run(String... args) throws Exception { + // Load initial routes from configuration into database + if (tunnelConfig.getTarget().getRoutes() != null) { + tunnelConfig.getTarget().getRoutes().forEach((pathPattern, targetUrl) -> { + if (!routeConfigRepository.existsByPathPattern(pathPattern)) { + RouteConfig route = new RouteConfig(); + route.setPathPattern(pathPattern); + route.setTargetUrl(targetUrl); + route.setPriority(pathPattern.length()); // Longer paths get higher priority + route.setDescription("Auto-imported from configuration"); + route.setEnabled(true); + + routeConfigRepository.save(route); + log.info("Imported route: {} -> {}", pathPattern, targetUrl); + } + }); + } + + // Add default CORS headers for API paths if not already configured + if (headerManipulationService.getAllHeaderRules().isEmpty()) { + log.info("Initializing default CORS headers..."); + headerManipulationService.addCorsHeaders( + "/api", + "*", + "GET, POST, PUT, DELETE, OPTIONS, PATCH", + "Origin, Content-Type, Accept, Authorization, X-Requested-With" + ); + log.info("Default CORS headers added for /api paths"); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_client/config/NoOpResponseErrorHandler.java b/src/main/java/dev/thinhha/tunnel_client/config/NoOpResponseErrorHandler.java new file mode 100644 index 0000000..9f1ef35 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/config/NoOpResponseErrorHandler.java @@ -0,0 +1,21 @@ +package dev.thinhha.tunnel_client.config; + +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.web.client.ResponseErrorHandler; + +import java.io.IOException; + +public class NoOpResponseErrorHandler implements ResponseErrorHandler { + + @Override + public boolean hasError(ClientHttpResponse response) throws IOException { + // Never consider any response as an error + // This allows us to handle all HTTP status codes (including 4xx, 5xx) gracefully + return false; + } + + @Override + public void handleError(ClientHttpResponse response) throws IOException { + // No-op: do nothing, let the response be returned as-is + } +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_client/config/TunnelConfig.java b/src/main/java/dev/thinhha/tunnel_client/config/TunnelConfig.java new file mode 100644 index 0000000..131a463 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/config/TunnelConfig.java @@ -0,0 +1,34 @@ +package dev.thinhha.tunnel_client.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Data +@Component +@ConfigurationProperties(prefix = "tunnel") +public class TunnelConfig { + + private Server server = new Server(); + private Client client = new Client(); + private Target target = new Target(); + + @Data + public static class Server { + private String url = "ws://localhost:5678"; + } + + @Data + public static class Client { + private String name = "default-client"; + private String token = ""; + } + + @Data + public static class Target { + private String defaultUrl = "http://localhost:8080"; + private Map routes; + } +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_client/config/WebSocketConfig.java b/src/main/java/dev/thinhha/tunnel_client/config/WebSocketConfig.java new file mode 100644 index 0000000..5bcd976 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/config/WebSocketConfig.java @@ -0,0 +1,38 @@ +package dev.thinhha.tunnel_client.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.ByteArrayHttpMessageConverter; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +@Configuration +public class WebSocketConfig { + + @Bean + public RestTemplate restTemplate() { + RestTemplate restTemplate = new RestTemplate(); + + // Configure message converters to handle different content types properly + StringHttpMessageConverter stringConverter = new StringHttpMessageConverter(StandardCharsets.UTF_8); + stringConverter.setWriteAcceptCharset(false); + + ByteArrayHttpMessageConverter byteArrayConverter = new ByteArrayHttpMessageConverter(); + MappingJackson2HttpMessageConverter jsonConverter = new MappingJackson2HttpMessageConverter(); + + restTemplate.setMessageConverters(Arrays.asList( + byteArrayConverter, // Handle binary data + stringConverter, // Handle text data + jsonConverter // Handle JSON data + )); + + // Configure error handler to not throw exceptions for HTTP error status codes + restTemplate.setErrorHandler(new NoOpResponseErrorHandler()); + + return restTemplate; + } +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_client/controller/HeaderRuleController.java b/src/main/java/dev/thinhha/tunnel_client/controller/HeaderRuleController.java new file mode 100644 index 0000000..8ef3ad1 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/controller/HeaderRuleController.java @@ -0,0 +1,101 @@ +package dev.thinhha.tunnel_client.controller; + +import dev.thinhha.tunnel_client.entity.HeaderRule; +import dev.thinhha.tunnel_client.service.HeaderManipulationService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/header-rules") +@RequiredArgsConstructor +public class HeaderRuleController { + + private final HeaderManipulationService headerManipulationService; + + @GetMapping + public List getAllHeaderRules() { + return headerManipulationService.getAllHeaderRules(); + } + + @PostMapping + public ResponseEntity addHeaderRule(@RequestBody HeaderRuleRequest request) { + HeaderRule rule = headerManipulationService.addHeaderRule( + request.getPathPattern(), + request.getHeaderName(), + request.getHeaderValue(), + request.getRuleType(), + request.getPriority(), + request.getDescription() + ); + return ResponseEntity.ok(rule); + } + + @PostMapping("/cors") + public ResponseEntity addCorsHeaders(@RequestBody CorsRequest request) { + headerManipulationService.addCorsHeaders( + request.getPathPattern(), + request.getAllowedOrigins(), + request.getAllowedMethods(), + request.getAllowedHeaders() + ); + return ResponseEntity.ok("CORS headers added successfully"); + } + + @DeleteMapping + public ResponseEntity removeHeaderRule(@RequestParam String pathPattern, @RequestParam String headerName) { + headerManipulationService.removeHeaderRule(pathPattern, headerName); + return ResponseEntity.ok().build(); + } + + public static class HeaderRuleRequest { + private String pathPattern; + private String headerName; + private String headerValue; + private HeaderRule.HeaderRuleType ruleType; + private Integer priority; + private String description; + + // Getters and setters + public String getPathPattern() { return pathPattern; } + public void setPathPattern(String pathPattern) { this.pathPattern = pathPattern; } + + public String getHeaderName() { return headerName; } + public void setHeaderName(String headerName) { this.headerName = headerName; } + + public String getHeaderValue() { return headerValue; } + public void setHeaderValue(String headerValue) { this.headerValue = headerValue; } + + public HeaderRule.HeaderRuleType getRuleType() { return ruleType; } + public void setRuleType(HeaderRule.HeaderRuleType ruleType) { this.ruleType = ruleType; } + + public Integer getPriority() { return priority; } + public void setPriority(Integer priority) { this.priority = priority; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + } + + public static class CorsRequest { + private String pathPattern; + private String allowedOrigins = "*"; + private String allowedMethods = "GET, POST, PUT, DELETE, OPTIONS"; + private String allowedHeaders = "Origin, Content-Type, Accept, Authorization"; + + // Getters and setters + public String getPathPattern() { return pathPattern; } + public void setPathPattern(String pathPattern) { this.pathPattern = pathPattern; } + + public String getAllowedOrigins() { return allowedOrigins; } + public void setAllowedOrigins(String allowedOrigins) { this.allowedOrigins = allowedOrigins; } + + public String getAllowedMethods() { return allowedMethods; } + public void setAllowedMethods(String allowedMethods) { this.allowedMethods = allowedMethods; } + + public String getAllowedHeaders() { return allowedHeaders; } + public void setAllowedHeaders(String allowedHeaders) { this.allowedHeaders = allowedHeaders; } + } +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_client/controller/RouteController.java b/src/main/java/dev/thinhha/tunnel_client/controller/RouteController.java new file mode 100644 index 0000000..e6d2ab9 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/controller/RouteController.java @@ -0,0 +1,64 @@ +package dev.thinhha.tunnel_client.controller; + +import dev.thinhha.tunnel_client.entity.RouteConfig; +import dev.thinhha.tunnel_client.service.RouteResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/routes") +@RequiredArgsConstructor +public class RouteController { + + private final RouteResolver routeResolver; + + @GetMapping + public List getAllRoutes() { + return routeResolver.getAllRoutes(); + } + + @PostMapping + public ResponseEntity addRoute(@RequestBody RouteRequest request) { + RouteConfig route = routeResolver.addRoute( + request.getPathPattern(), + request.getTargetUrl(), + request.getPriority(), + request.getDescription() + ); + return ResponseEntity.ok(route); + } + + @DeleteMapping("/{pathPattern}") + public ResponseEntity removeRoute(@PathVariable String pathPattern) { + routeResolver.removeRoute(pathPattern); + return ResponseEntity.ok().build(); + } + + @GetMapping("/resolve/{path}") + public ResponseEntity resolveRoute(@PathVariable String path) { + String targetUrl = routeResolver.resolveTargetUrl("/" + path); + return ResponseEntity.ok(targetUrl); + } + + public static class RouteRequest { + private String pathPattern; + private String targetUrl; + private Integer priority; + private String description; + + public String getPathPattern() { return pathPattern; } + public void setPathPattern(String pathPattern) { this.pathPattern = pathPattern; } + + public String getTargetUrl() { return targetUrl; } + public void setTargetUrl(String targetUrl) { this.targetUrl = targetUrl; } + + public Integer getPriority() { return priority; } + public void setPriority(Integer priority) { this.priority = priority; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + } +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_client/dto/TunnelRequestDto.java b/src/main/java/dev/thinhha/tunnel_client/dto/TunnelRequestDto.java new file mode 100644 index 0000000..978d54f --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/dto/TunnelRequestDto.java @@ -0,0 +1,26 @@ +package dev.thinhha.tunnel_client.dto; + +import dev.thinhha.tunnel_client.types.HttpMethod; +import dev.thinhha.tunnel_client.types.TunnelRequestType; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TunnelRequestDto { + private String requestId; + private TunnelRequestType type = TunnelRequestType.HTTP; // Default to HTTP for backward compatibility + private HttpMethod method; + private String path; + private Map headers; + private byte[] body; + private String clientShortName; + + // WebSocket specific fields + private String wsConnectionId; // For tracking WS connections + private String wsMessageType; // TEXT or BINARY +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_client/dto/TunnelResponseDto.java b/src/main/java/dev/thinhha/tunnel_client/dto/TunnelResponseDto.java new file mode 100644 index 0000000..ab4d773 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/dto/TunnelResponseDto.java @@ -0,0 +1,24 @@ +package dev.thinhha.tunnel_client.dto; + +import dev.thinhha.tunnel_client.types.TunnelRequestType; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TunnelResponseDto { + private String requestId; + private TunnelRequestType type = TunnelRequestType.HTTP; // Default to HTTP for backward compatibility + private int statusCode; + private Map headers; + private byte[] body; + + // WebSocket specific fields + private String wsConnectionId; + private String wsMessageType; // TEXT or BINARY + private boolean wsConnectionEstablished; +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_client/entity/HeaderRule.java b/src/main/java/dev/thinhha/tunnel_client/entity/HeaderRule.java new file mode 100644 index 0000000..4409916 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/entity/HeaderRule.java @@ -0,0 +1,48 @@ +package dev.thinhha.tunnel_client.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Entity +@Table(name = "header_rule") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class HeaderRule { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "path_pattern", nullable = false) + private String pathPattern; + + @Column(name = "header_name", nullable = false) + private String headerName; + + @Column(name = "header_value", nullable = false) + private String headerValue; + + @Column(name = "rule_type", nullable = false) + @Enumerated(EnumType.STRING) + private HeaderRuleType ruleType = HeaderRuleType.ADD; + + @Column(name = "priority", nullable = false) + private Integer priority = 0; + + @Column(name = "enabled", nullable = false) + private Boolean enabled = true; + + @Column(name = "description") + private String description; + + public enum HeaderRuleType { + ADD, // Add header (keep existing if present) + SET, // Set header (replace existing) + REMOVE // Remove header + } +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_client/entity/RouteConfig.java b/src/main/java/dev/thinhha/tunnel_client/entity/RouteConfig.java new file mode 100644 index 0000000..004f8da --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/entity/RouteConfig.java @@ -0,0 +1,41 @@ +package dev.thinhha.tunnel_client.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Entity +@Table(name = "route_config") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RouteConfig { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "path_pattern", nullable = false, unique = true) + private String pathPattern; + + @Column(name = "target_url", nullable = false) + private String targetUrl; + + @Column(name = "priority", nullable = false) + private Integer priority = 0; + + @Column(name = "enabled", nullable = false) + private Boolean enabled = true; + + @Column(name = "description") + private String description; + + public RouteConfig(String pathPattern, String targetUrl, Integer priority) { + this.pathPattern = pathPattern; + this.targetUrl = targetUrl; + this.priority = priority; + } +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_client/repository/HeaderRuleRepository.java b/src/main/java/dev/thinhha/tunnel_client/repository/HeaderRuleRepository.java new file mode 100644 index 0000000..c2769d0 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/repository/HeaderRuleRepository.java @@ -0,0 +1,20 @@ +package dev.thinhha.tunnel_client.repository; + +import dev.thinhha.tunnel_client.entity.HeaderRule; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface HeaderRuleRepository extends JpaRepository { + + List findByEnabledTrueOrderByPriorityDescPathPatternDesc(); + + @Query("SELECT h FROM HeaderRule h WHERE h.enabled = true AND ?1 LIKE CONCAT(h.pathPattern, '%') ORDER BY h.priority DESC, LENGTH(h.pathPattern) DESC") + List findMatchingRules(String path); + + List findByPathPatternAndEnabledTrue(String pathPattern); +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_client/repository/RouteConfigRepository.java b/src/main/java/dev/thinhha/tunnel_client/repository/RouteConfigRepository.java new file mode 100644 index 0000000..a3a92c9 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/repository/RouteConfigRepository.java @@ -0,0 +1,20 @@ +package dev.thinhha.tunnel_client.repository; + +import dev.thinhha.tunnel_client.entity.RouteConfig; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface RouteConfigRepository extends JpaRepository { + + List findByEnabledTrueOrderByPriorityDescPathPatternDesc(); + + @Query("SELECT r FROM RouteConfig r WHERE r.enabled = true AND ?1 LIKE CONCAT(r.pathPattern, '%') ORDER BY r.priority DESC, LENGTH(r.pathPattern) DESC") + List findMatchingRoutes(String path); + + boolean existsByPathPattern(String pathPattern); +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_client/service/HeaderManipulationService.java b/src/main/java/dev/thinhha/tunnel_client/service/HeaderManipulationService.java new file mode 100644 index 0000000..cbd1475 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/service/HeaderManipulationService.java @@ -0,0 +1,90 @@ +package dev.thinhha.tunnel_client.service; + +import dev.thinhha.tunnel_client.entity.HeaderRule; +import dev.thinhha.tunnel_client.repository.HeaderRuleRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class HeaderManipulationService { + + private final HeaderRuleRepository headerRuleRepository; + + public Map processResponseHeaders(String path, Map originalHeaders) { + Map processedHeaders = new HashMap<>(originalHeaders); + + List matchingRules = headerRuleRepository.findMatchingRules(path); + + for (HeaderRule rule : matchingRules) { + applyHeaderRule(processedHeaders, rule); + log.debug("Applied header rule: {} {} -> {}", rule.getRuleType(), rule.getHeaderName(), rule.getHeaderValue()); + } + + return processedHeaders; + } + + private void applyHeaderRule(Map headers, HeaderRule rule) { + String headerName = rule.getHeaderName(); + String headerValue = rule.getHeaderValue(); + + switch (rule.getRuleType()) { + case ADD -> { + // Add header only if not already present + if (!headers.containsKey(headerName)) { + headers.put(headerName, headerValue); + } + } + case SET -> { + // Set header (replace if exists) + headers.put(headerName, headerValue); + } + case REMOVE -> { + // Remove header + headers.remove(headerName); + } + } + } + + public HeaderRule addHeaderRule(String pathPattern, String headerName, String headerValue, + HeaderRule.HeaderRuleType ruleType, Integer priority, String description) { + HeaderRule rule = new HeaderRule(); + rule.setPathPattern(pathPattern); + rule.setHeaderName(headerName); + rule.setHeaderValue(headerValue); + rule.setRuleType(ruleType); + rule.setPriority(priority != null ? priority : 0); + rule.setDescription(description); + rule.setEnabled(true); + + return headerRuleRepository.save(rule); + } + + public void removeHeaderRule(String pathPattern, String headerName) { + List rules = headerRuleRepository.findByPathPatternAndEnabledTrue(pathPattern); + rules.stream() + .filter(rule -> rule.getHeaderName().equalsIgnoreCase(headerName)) + .forEach(rule -> { + rule.setEnabled(false); + headerRuleRepository.save(rule); + }); + } + + public List getAllHeaderRules() { + return headerRuleRepository.findByEnabledTrueOrderByPriorityDescPathPatternDesc(); + } + + public void addCorsHeaders(String pathPattern, String allowedOrigins, String allowedMethods, String allowedHeaders) { + // Add CORS headers + addHeaderRule(pathPattern, "Access-Control-Allow-Origin", allowedOrigins, HeaderRule.HeaderRuleType.SET, 100, "CORS - Allowed Origins"); + addHeaderRule(pathPattern, "Access-Control-Allow-Methods", allowedMethods, HeaderRule.HeaderRuleType.SET, 100, "CORS - Allowed Methods"); + addHeaderRule(pathPattern, "Access-Control-Allow-Headers", allowedHeaders, HeaderRule.HeaderRuleType.SET, 100, "CORS - Allowed Headers"); + addHeaderRule(pathPattern, "Access-Control-Allow-Credentials", "true", HeaderRule.HeaderRuleType.SET, 100, "CORS - Allow Credentials"); + } +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_client/service/RouteResolver.java b/src/main/java/dev/thinhha/tunnel_client/service/RouteResolver.java new file mode 100644 index 0000000..bf530e2 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/service/RouteResolver.java @@ -0,0 +1,86 @@ +package dev.thinhha.tunnel_client.service; + +import dev.thinhha.tunnel_client.config.TunnelConfig; +import dev.thinhha.tunnel_client.entity.RouteConfig; +import dev.thinhha.tunnel_client.repository.RouteConfigRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RouteResolver { + + private final TunnelConfig tunnelConfig; + private final RouteConfigRepository routeConfigRepository; + + public String resolveTargetUrl(String path) { + // First check database routes + List matchingRoutes = routeConfigRepository.findMatchingRoutes(path); + if (!matchingRoutes.isEmpty()) { + RouteConfig bestMatch = matchingRoutes.get(0); + log.debug("Resolved path '{}' to database target: {}", path, bestMatch.getTargetUrl()); + return bestMatch.getTargetUrl(); + } + + // Fallback to configuration routes + if (tunnelConfig.getTarget().getRoutes() != null && !tunnelConfig.getTarget().getRoutes().isEmpty()) { + String bestMatch = null; + String bestMatchUrl = null; + + for (Map.Entry route : tunnelConfig.getTarget().getRoutes().entrySet()) { + String routePath = route.getKey(); + String targetUrl = route.getValue(); + + if (path.startsWith(routePath)) { + if (bestMatch == null || routePath.length() > bestMatch.length()) { + bestMatch = routePath; + bestMatchUrl = targetUrl; + } + } + } + + if (bestMatchUrl != null) { + log.debug("Resolved path '{}' to config target: {}", path, bestMatchUrl); + return bestMatchUrl; + } + } + + log.debug("No route match for path '{}', using default target: {}", path, tunnelConfig.getTarget().getDefaultUrl()); + return tunnelConfig.getTarget().getDefaultUrl(); + } + + public boolean isWebSocketTarget(String targetUrl) { + return targetUrl.startsWith("ws://") || targetUrl.startsWith("wss://"); + } + + public RouteConfig addRoute(String pathPattern, String targetUrl, Integer priority, String description) { + RouteConfig route = new RouteConfig(); + route.setPathPattern(pathPattern); + route.setTargetUrl(targetUrl); + route.setPriority(priority != null ? priority : 0); + route.setDescription(description); + route.setEnabled(true); + + return routeConfigRepository.save(route); + } + + public void removeRoute(String pathPattern) { + routeConfigRepository.findByEnabledTrueOrderByPriorityDescPathPatternDesc() + .stream() + .filter(route -> route.getPathPattern().equals(pathPattern)) + .findFirst() + .ifPresent(route -> { + route.setEnabled(false); + routeConfigRepository.save(route); + }); + } + + public List getAllRoutes() { + return routeConfigRepository.findByEnabledTrueOrderByPriorityDescPathPatternDesc(); + } +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_client/service/TunnelClient.java b/src/main/java/dev/thinhha/tunnel_client/service/TunnelClient.java new file mode 100644 index 0000000..dd4e465 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/service/TunnelClient.java @@ -0,0 +1,435 @@ +package dev.thinhha.tunnel_client.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.thinhha.tunnel_client.config.TunnelConfig; +import dev.thinhha.tunnel_client.dto.TunnelRequestDto; +import dev.thinhha.tunnel_client.dto.TunnelResponseDto; +import dev.thinhha.tunnel_client.types.TunnelRequestType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.socket.*; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.handler.BinaryWebSocketHandler; + +import java.io.IOException; +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; + +@Service +@Slf4j +public class TunnelClient extends BinaryWebSocketHandler { + + private final TunnelConfig tunnelConfig; + private final RouteResolver routeResolver; + private final HeaderManipulationService headerManipulationService; + private final ObjectMapper objectMapper; + private final RestTemplate restTemplate; + private WebSocketSession session; + private final CountDownLatch connectionLatch = new CountDownLatch(1); + + // Track WebSocket connections: wsConnectionId -> target WebSocket session + private final Map webSocketConnections = new ConcurrentHashMap<>(); + + public TunnelClient(TunnelConfig tunnelConfig, RouteResolver routeResolver, HeaderManipulationService headerManipulationService, ObjectMapper objectMapper, RestTemplate restTemplate) { + this.tunnelConfig = tunnelConfig; + this.routeResolver = routeResolver; + this.headerManipulationService = headerManipulationService; + this.objectMapper = objectMapper; + this.restTemplate = restTemplate; + } + + public void connect() { + try { + StandardWebSocketClient client = new StandardWebSocketClient(); + WebSocketHttpHeaders headers = new WebSocketHttpHeaders(); + + if (!tunnelConfig.getClient().getToken().isEmpty()) { + headers.add("Authorization", "Bearer " + tunnelConfig.getClient().getToken()); + } + + URI serverUri = URI.create(tunnelConfig.getServer().getUrl() + "/client"); + log.info("Connecting to tunnel server at: {}", serverUri); + + client.execute(this, headers, serverUri); + + connectionLatch.await(); + log.info("Connected to tunnel server as client: {}", tunnelConfig.getClient().getName()); + + } catch (Exception e) { + log.error("Failed to connect to tunnel server", e); + } + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + this.session = session; + log.info("WebSocket connection established with session: {}", session.getId()); + connectionLatch.countDown(); + } + + @Override + public void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception { + try { + byte[] payload = message.getPayload().array(); + TunnelRequestDto request = objectMapper.readValue(payload, TunnelRequestDto.class); + + log.info("Received tunnel request: {} {} {} {}", request.getRequestId(), request.getType(), request.getMethod(), request.getPath()); + + TunnelResponseDto response = switch (request.getType()) { + case HTTP -> handleHttpTunnelRequest(request); + case WS_CONNECT -> handleWebSocketConnect(request); + case WS_MESSAGE -> handleWebSocketMessage(request); + case WS_CLOSE -> handleWebSocketClose(request); + }; + + byte[] responseBytes = objectMapper.writeValueAsBytes(response); + session.sendMessage(new BinaryMessage(responseBytes)); + + } catch (Exception e) { + log.error("Error processing tunnel request", e); + } + } + + private TunnelResponseDto handleHttpTunnelRequest(TunnelRequestDto request) { + try { + String targetUrl = routeResolver.resolveTargetUrl(request.getPath()); + String fullUrl = targetUrl + request.getPath(); + + log.info("Forwarding request {} {} to: {}", request.getMethod(), request.getPath(), fullUrl); + + HttpHeaders headers = new HttpHeaders(); + if (request.getHeaders() != null) { + request.getHeaders().forEach((key, value) -> { + // Handle Content-Type specifically to ensure proper parsing + if ("Content-Type".equalsIgnoreCase(key)) { + headers.set(key, value); + log.debug("Setting Content-Type: {}", value); + } else if ("Content-Length".equalsIgnoreCase(key)) { + // Skip Content-Length as RestTemplate will set it automatically + log.debug("Skipping Content-Length header (will be set automatically)"); + } else { + headers.add(key, value); + } + }); + } + + // Create HTTP entity with proper body handling + final HttpEntity httpEntity = createHttpEntity(request, headers); + + // Use byte[] to handle all response types properly + ResponseEntity response = restTemplate.exchange( + fullUrl, + HttpMethod.valueOf(request.getMethod().name()), + httpEntity, + byte[].class + ); + + // Extract and process response headers + Map responseHeaders = extractAndProcessHeaders(response, request.getPath()); + + int statusCode = response.getStatusCode().value(); + log.info("Target service responded: {} {} -> {} {}", + request.getMethod(), request.getPath(), statusCode, + getStatusCodeDescription(statusCode)); + + TunnelResponseDto tunnelResponse = new TunnelResponseDto(); + tunnelResponse.setRequestId(request.getRequestId()); + tunnelResponse.setType(TunnelRequestType.HTTP); + tunnelResponse.setStatusCode(statusCode); + tunnelResponse.setHeaders(responseHeaders); + tunnelResponse.setBody(response.getBody()); + + return tunnelResponse; + + } catch (Exception e) { + log.error("Error forwarding request to target service", e); + + Map errorHeaders = new HashMap<>(); + errorHeaders.put("Content-Type", "text/plain"); + + TunnelResponseDto errorResponse = new TunnelResponseDto(); + errorResponse.setRequestId(request.getRequestId()); + errorResponse.setType(TunnelRequestType.HTTP); + errorResponse.setStatusCode(500); + errorResponse.setHeaders(errorHeaders); + errorResponse.setBody(("Internal Server Error: " + e.getMessage()).getBytes()); + + return errorResponse; + } + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { + log.error("Transport error: {}", exception.getMessage()); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception { + log.info("Connection closed with status: {}", closeStatus); + this.session = null; + } + + @Override + public boolean supportsPartialMessages() { + return false; + } + + private String getStatusCodeDescription(int statusCode) { + return switch (statusCode / 100) { + case 2 -> "Success"; + case 3 -> "Redirection"; + case 4 -> "Client Error"; + case 5 -> "Server Error"; + default -> "Unknown"; + }; + } + + private HttpEntity createHttpEntity(TunnelRequestDto request, HttpHeaders headers) { + if (request.getBody() != null && request.getBody().length > 0) { + String contentType = headers.getFirst("Content-Type"); + if (contentType != null && contentType.startsWith("application/json")) { + // For JSON, convert bytes to string for proper handling + String jsonBody = new String(request.getBody()); + return new HttpEntity<>(jsonBody, headers); + } else if (contentType != null && contentType.startsWith("application/x-www-form-urlencoded")) { + // For form data, convert bytes to string + String formBody = new String(request.getBody()); + return new HttpEntity<>(formBody, headers); + } else { + // For binary data or other content types, use byte array + return new HttpEntity<>(request.getBody(), headers); + } + } else { + return new HttpEntity<>(headers); + } + } + + private Map extractAndProcessHeaders(ResponseEntity response, String path) { + Map responseHeaders = new HashMap<>(); + response.getHeaders().forEach((key, values) -> { + if (!values.isEmpty()) { + responseHeaders.put(key, values.get(0)); + } + }); + + // Apply header manipulation rules + return headerManipulationService.processResponseHeaders(path, responseHeaders); + } + + private TunnelResponseDto handleWebSocketConnect(TunnelRequestDto request) { + try { + String targetUrl = routeResolver.resolveTargetUrl(request.getPath()); + String wsUrl = targetUrl.replace("http://", "ws://").replace("https://", "wss://") + request.getPath(); + + log.info("Establishing WebSocket connection to: {}", wsUrl); + + StandardWebSocketClient client = new StandardWebSocketClient(); + WebSocketHttpHeaders headers = new WebSocketHttpHeaders(); + + if (request.getHeaders() != null) { + request.getHeaders().forEach(headers::add); + } + + // Create handler for target WebSocket + WebSocketHandler targetHandler = new WebSocketHandler() { + @Override + public void afterConnectionEstablished(WebSocketSession targetSession) throws Exception { + webSocketConnections.put(request.getWsConnectionId(), targetSession); + log.info("WebSocket connection established: {}", request.getWsConnectionId()); + + // Send success response back to tunnel server + TunnelResponseDto response = new TunnelResponseDto(); + response.setRequestId(request.getRequestId()); + response.setType(TunnelRequestType.WS_CONNECT); + response.setStatusCode(101); // WebSocket upgrade + response.setHeaders(new HashMap<>()); + response.setBody(new byte[0]); + response.setWsConnectionId(request.getWsConnectionId()); + response.setWsConnectionEstablished(true); + + try { + byte[] responseBytes = objectMapper.writeValueAsBytes(response); + session.sendMessage(new BinaryMessage(responseBytes)); + } catch (Exception e) { + log.error("Error sending WebSocket connect response", e); + } + } + + @Override + public void handleMessage(WebSocketSession targetSession, WebSocketMessage message) throws Exception { + // Forward message from target to tunnel server + String messageType; + byte[] messageBody; + + if (message instanceof TextMessage textMsg) { + messageType = "TEXT"; + messageBody = textMsg.getPayload().getBytes(); + } else if (message instanceof BinaryMessage binaryMsg) { + messageType = "BINARY"; + messageBody = binaryMsg.getPayload().array(); + } else { + return; // Unknown message type + } + + TunnelResponseDto response = new TunnelResponseDto( + java.util.UUID.randomUUID().toString(), + TunnelRequestType.WS_MESSAGE, + 200, + new HashMap<>(), + messageBody, + request.getWsConnectionId(), + messageType, + false + ); + + try { + byte[] responseBytes = objectMapper.writeValueAsBytes(response); + session.sendMessage(new BinaryMessage(responseBytes)); + } catch (Exception e) { + log.error("Error forwarding WebSocket message", e); + } + } + + @Override + public void afterConnectionClosed(WebSocketSession targetSession, CloseStatus closeStatus) throws Exception { + webSocketConnections.remove(request.getWsConnectionId()); + log.info("WebSocket connection closed: {}", request.getWsConnectionId()); + + // Notify tunnel server of connection close + TunnelResponseDto response = new TunnelResponseDto( + java.util.UUID.randomUUID().toString(), + TunnelRequestType.WS_CLOSE, + closeStatus.getCode(), + new HashMap<>(), + new byte[0], + request.getWsConnectionId(), + null, + false + ); + + try { + byte[] responseBytes = objectMapper.writeValueAsBytes(response); + session.sendMessage(new BinaryMessage(responseBytes)); + } catch (Exception e) { + log.error("Error sending WebSocket close notification", e); + } + } + + @Override + public void handleTransportError(WebSocketSession targetSession, Throwable exception) throws Exception { + log.error("WebSocket transport error: {}", exception.getMessage()); + } + + @Override + public boolean supportsPartialMessages() { + return false; + } + }; + + client.execute(targetHandler, headers, URI.create(wsUrl)); + + // Return immediate response (actual connection established response sent in handler) + TunnelResponseDto response = new TunnelResponseDto(); + response.setRequestId(request.getRequestId()); + response.setType(TunnelRequestType.WS_CONNECT); + response.setStatusCode(102); // Processing + response.setHeaders(new HashMap<>()); + response.setBody(new byte[0]); + response.setWsConnectionId(request.getWsConnectionId()); + response.setWsConnectionEstablished(false); + return response; + + } catch (Exception e) { + log.error("Error establishing WebSocket connection", e); + TunnelResponseDto response = new TunnelResponseDto(); + response.setRequestId(request.getRequestId()); + response.setType(TunnelRequestType.WS_CONNECT); + response.setStatusCode(500); + response.setHeaders(new HashMap<>()); + response.setBody(("WebSocket connection failed: " + e.getMessage()).getBytes()); + response.setWsConnectionId(request.getWsConnectionId()); + response.setWsConnectionEstablished(false); + return response; + } + } + + private TunnelResponseDto handleWebSocketMessage(TunnelRequestDto request) { + try { + WebSocketSession targetSession = webSocketConnections.get(request.getWsConnectionId()); + if (targetSession == null || !targetSession.isOpen()) { + log.warn("WebSocket connection not found or closed: {}", request.getWsConnectionId()); + TunnelResponseDto response = new TunnelResponseDto(); + response.setRequestId(request.getRequestId()); + response.setType(TunnelRequestType.WS_MESSAGE); + response.setStatusCode(404); + response.setHeaders(new HashMap<>()); + response.setBody("WebSocket connection not found".getBytes()); + response.setWsConnectionId(request.getWsConnectionId()); + return response; + } + + // Forward message to target + if ("TEXT".equals(request.getWsMessageType())) { + String textPayload = new String(request.getBody()); + targetSession.sendMessage(new TextMessage(textPayload)); + } else { + targetSession.sendMessage(new BinaryMessage(request.getBody())); + } + + TunnelResponseDto response = new TunnelResponseDto(); + response.setRequestId(request.getRequestId()); + response.setType(TunnelRequestType.WS_MESSAGE); + response.setStatusCode(200); + response.setHeaders(new HashMap<>()); + response.setBody(new byte[0]); + response.setWsConnectionId(request.getWsConnectionId()); + return response; + + } catch (Exception e) { + log.error("Error handling WebSocket message", e); + TunnelResponseDto response = new TunnelResponseDto(); + response.setRequestId(request.getRequestId()); + response.setType(TunnelRequestType.WS_MESSAGE); + response.setStatusCode(500); + response.setHeaders(new HashMap<>()); + response.setBody(("WebSocket message failed: " + e.getMessage()).getBytes()); + response.setWsConnectionId(request.getWsConnectionId()); + return response; + } + } + + private TunnelResponseDto handleWebSocketClose(TunnelRequestDto request) { + try { + WebSocketSession targetSession = webSocketConnections.remove(request.getWsConnectionId()); + if (targetSession != null && targetSession.isOpen()) { + targetSession.close(); + log.info("Closed WebSocket connection: {}", request.getWsConnectionId()); + } + + TunnelResponseDto response = new TunnelResponseDto(); + response.setRequestId(request.getRequestId()); + response.setType(TunnelRequestType.WS_CLOSE); + response.setStatusCode(200); + response.setHeaders(new HashMap<>()); + response.setBody(new byte[0]); + response.setWsConnectionId(request.getWsConnectionId()); + return response; + + } catch (Exception e) { + log.error("Error closing WebSocket connection", e); + TunnelResponseDto response = new TunnelResponseDto(); + response.setRequestId(request.getRequestId()); + response.setType(TunnelRequestType.WS_CLOSE); + response.setStatusCode(500); + response.setHeaders(new HashMap<>()); + response.setBody(new byte[0]); + response.setWsConnectionId(request.getWsConnectionId()); + return response; + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_client/service/WebSocketTunnelService.java b/src/main/java/dev/thinhha/tunnel_client/service/WebSocketTunnelService.java new file mode 100644 index 0000000..3679ade --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/service/WebSocketTunnelService.java @@ -0,0 +1,133 @@ +package dev.thinhha.tunnel_client.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.socket.*; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; + +import java.io.IOException; +import java.net.URI; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Service +@Slf4j +public class WebSocketTunnelService { + + @Value("${tunnel.target.url:http://localhost:8080}") + private String targetUrl; + + private final Map clientSessions = new ConcurrentHashMap<>(); + private final Map targetSessions = new ConcurrentHashMap<>(); + + public void handleWebSocketTunnelRequest(String requestId, String path, Map headers, + WebSocketSession clientSession) { + try { + String targetWsUrl = targetUrl.replace("http://", "ws://").replace("https://", "wss://") + path; + + StandardWebSocketClient client = new StandardWebSocketClient(); + WebSocketHttpHeaders wsHeaders = new WebSocketHttpHeaders(); + + if (headers != null) { + headers.forEach(wsHeaders::add); + } + + WebSocketHandler targetHandler = new WebSocketHandler() { + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + log.info("WebSocket tunnel established: {} -> {}", requestId, targetWsUrl); + targetSessions.put(requestId, session); + } + + @Override + public void handleMessage(WebSocketSession session, WebSocketMessage message) throws Exception { + if(message.getClass().isAssignableFrom(BinaryMessage.class)) { + handleBinaryMessage(session, (BinaryMessage) message); + } else if(message.getClass().isAssignableFrom(TextMessage.class)) { + handleTextMessage(session, (TextMessage) message); + } + } + + public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { + WebSocketSession clientWs = clientSessions.get(requestId); + if (clientWs != null && clientWs.isOpen()) { + clientWs.sendMessage(message); + } + } + + public void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception { + WebSocketSession clientWs = clientSessions.get(requestId); + if (clientWs != null && clientWs.isOpen()) { + clientWs.sendMessage(message); + } + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception { + log.info("Target WebSocket closed: {}", requestId); + targetSessions.remove(requestId); + + WebSocketSession clientWs = clientSessions.get(requestId); + if (clientWs != null && clientWs.isOpen()) { + clientWs.close(closeStatus); + } + clientSessions.remove(requestId); + } + + @Override + public boolean supportsPartialMessages() { + return false; + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { + log.error("WebSocket tunnel error: {}", exception.getMessage()); + } + }; + + client.execute(targetHandler, wsHeaders, URI.create(targetWsUrl)); + clientSessions.put(requestId, clientSession); + + } catch (Exception e) { + log.error("Failed to establish WebSocket tunnel", e); + try { + clientSession.close(CloseStatus.SERVER_ERROR); + } catch (IOException ex) { + log.error("Failed to close client session", ex); + } + } + } + + public void forwardToTarget(String requestId, WebSocketMessage message) { + WebSocketSession targetSession = targetSessions.get(requestId); + if (targetSession != null && targetSession.isOpen()) { + try { + targetSession.sendMessage(message); + } catch (IOException e) { + log.error("Failed to forward message to target", e); + } + } + } + + public void closeWebSocketTunnel(String requestId) { + WebSocketSession clientSession = clientSessions.remove(requestId); + WebSocketSession targetSession = targetSessions.remove(requestId); + + if (clientSession != null && clientSession.isOpen()) { + try { + clientSession.close(); + } catch (IOException e) { + log.error("Failed to close client session", e); + } + } + + if (targetSession != null && targetSession.isOpen()) { + try { + targetSession.close(); + } catch (IOException e) { + log.error("Failed to close target session", e); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_client/types/HttpMethod.java b/src/main/java/dev/thinhha/tunnel_client/types/HttpMethod.java new file mode 100644 index 0000000..2b07d73 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/types/HttpMethod.java @@ -0,0 +1,5 @@ +package dev.thinhha.tunnel_client.types; + +public enum HttpMethod { + GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE +} \ No newline at end of file diff --git a/src/main/java/dev/thinhha/tunnel_client/types/TunnelRequestType.java b/src/main/java/dev/thinhha/tunnel_client/types/TunnelRequestType.java new file mode 100644 index 0000000..e144e54 --- /dev/null +++ b/src/main/java/dev/thinhha/tunnel_client/types/TunnelRequestType.java @@ -0,0 +1,8 @@ +package dev.thinhha.tunnel_client.types; + +public enum TunnelRequestType { + HTTP, // Regular HTTP request + WS_CONNECT, // WebSocket connection request + WS_MESSAGE, // WebSocket message + WS_CLOSE // WebSocket close +} \ No newline at end of file diff --git a/src/main/resources/application-docker.yaml b/src/main/resources/application-docker.yaml new file mode 100644 index 0000000..890707c --- /dev/null +++ b/src/main/resources/application-docker.yaml @@ -0,0 +1,47 @@ +spring: + application: + name: Tunnel Client + datasource: + url: ${SPRING_DATASOURCE_URL:jdbc:h2:file:/app/data/tunnel_client} + driver-class-name: org.h2.Driver + username: ${SPRING_DATASOURCE_USERNAME:sa} + password: ${SPRING_DATASOURCE_PASSWORD:tunnel_client_password} + jpa: + hibernate: + ddl-auto: update + show-sql: false + h2: + console: + enabled: true + path: /h2-console + +server: + port: 8765 + +tunnel: + server: + url: ${TUNNEL_SERVER_URL:ws://tunnel-server:5678} + client: + name: ${TUNNEL_CLIENT_NAME:docker-client} + token: ${TUNNEL_CLIENT_TOKEN:} + target: + default-url: ${TUNNEL_TARGET_DEFAULT_URL:http://host.docker.internal:8080} + routes: + /api/v1: ${TUNNEL_TARGET_API_V1:http://host.docker.internal:3000} + /api/v2: ${TUNNEL_TARGET_API_V2:http://host.docker.internal:3001} + /admin: ${TUNNEL_TARGET_ADMIN:http://host.docker.internal:4000} + /ws: ${TUNNEL_TARGET_WS:ws://host.docker.internal:8080} + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: always + +logging: + level: + dev.thinhha: INFO + org.springframework.web.socket: DEBUG \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index a73b6f6..941f3dd 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,6 +1,36 @@ spring: application: name: Tunnel Client + datasource: + url: jdbc:h2:file:./data/tunnel_client + driver-class-name: org.h2.Driver + username: sa + password: tunnel_client_password + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + format_sql: true + h2: + console: + enabled: true + path: /h2-console server: port: 8765 + +tunnel: + server: + url: ws://localhost:5678 + client: + name: client1 + token: eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiY2xpZW50MSIsImlzcyI6InR1bm5lbC1zZXJ2ZXIiLCJzdWIiOiJjbGllbnQxIiwiZXhwIjoyNjE1Njg3MDQwLCJpYXQiOjE3NTE2ODcwNDB9.0UsKSSa3Ep0s8ILp_9iAC4y8DrY5Rv-B8p9uCPKGOHo + target: + default-url: http://localhost:8080 + routes: + /api/v1: http://localhost:3000 + /api/v2: http://localhost:3001 + /admin: http://localhost:4000 + /ws: ws://localhost:8080