Working HTTP tunnel
This commit is contained in:
@ -2,6 +2,7 @@ package dev.thinhha.tunnel_server;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||
|
||||
@SpringBootApplication
|
||||
public class TunnelServerApplication {
|
||||
|
14
src/main/java/dev/thinhha/tunnel_server/config/Constant.java
Normal file
14
src/main/java/dev/thinhha/tunnel_server/config/Constant.java
Normal file
@ -0,0 +1,14 @@
|
||||
package dev.thinhha.tunnel_server.config;
|
||||
|
||||
public class Constant {
|
||||
public static final String ALPHANUMERIC = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
public static final String UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
public static final String LOWERCASE = "abcdefghijklmnopqrstuvwxyz";
|
||||
public static final String DIGITS = "0123456789";
|
||||
public static final String SPECIAL_CHARS = "!@#$%^&*()_+-=[]{}|;:,.<>?";
|
||||
public static final String ALL_CHARS = ALPHANUMERIC + SPECIAL_CHARS;
|
||||
|
||||
public static final int CLIENT_SECRET_LENGTH = 64;
|
||||
private Constant() {
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package dev.thinhha.tunnel_server.config;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.handler.BinaryWebSocketHandler;
|
||||
|
||||
@Component
|
||||
public class FacadeWebSocketHandler extends BinaryWebSocketHandler {
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
package dev.thinhha.tunnel_server.config;
|
||||
|
||||
import dev.thinhha.tunnel_server.service.JwtService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.server.ServerHttpRequest;
|
||||
import org.springframework.http.server.ServerHttpResponse;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.WebSocketHandler;
|
||||
import org.springframework.web.socket.server.HandshakeInterceptor;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class JwtWebSocketHandshakeInterceptor implements HandshakeInterceptor {
|
||||
|
||||
private final JwtService jwtService;
|
||||
|
||||
@Override
|
||||
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
|
||||
WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
|
||||
try {
|
||||
String token = extractTokenFromRequest(request);
|
||||
if (token == null) {
|
||||
log.warn("No JWT token found in WebSocket handshake");
|
||||
return false;
|
||||
}
|
||||
|
||||
String clientName = jwtService.extractClientName(token);
|
||||
attributes.put("clientName", clientName);
|
||||
log.info("WebSocket handshake authenticated for client: {}", clientName);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("JWT authentication failed for WebSocket handshake", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
|
||||
WebSocketHandler wsHandler, Exception exception) {
|
||||
// No action needed after handshake
|
||||
}
|
||||
|
||||
private String extractTokenFromRequest(ServerHttpRequest request) {
|
||||
// Try to get token from Authorization header
|
||||
String authHeader = request.getHeaders().getFirst("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
// Try to get token from query parameter
|
||||
String query = request.getURI().getQuery();
|
||||
if (query != null) {
|
||||
Map<String, String> params = UriComponentsBuilder.fromUriString("?" + query)
|
||||
.build()
|
||||
.getQueryParams()
|
||||
.toSingleValueMap();
|
||||
return params.get("token");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
@ -0,0 +1,95 @@
|
||||
package dev.thinhha.tunnel_server.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.thinhha.tunnel_server.dto.TunnelResponseDto;
|
||||
import dev.thinhha.tunnel_server.service.TunnelService;
|
||||
import lombok.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.BinaryMessage;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import org.springframework.web.socket.handler.BinaryWebSocketHandler;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArraySet;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class WebSocketHandler extends BinaryWebSocketHandler {
|
||||
|
||||
private final Set<WebSocketSession> sessions = new CopyOnWriteArraySet<>();
|
||||
private final ConcurrentHashMap<String, WebSocketSession> clientSessions = new ConcurrentHashMap<>();
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TunnelService tunnelService;
|
||||
|
||||
public WebSocketHandler(ObjectMapper objectMapper,
|
||||
TunnelService tunnelService) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.tunnelService = tunnelService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception {
|
||||
try {
|
||||
byte[] payload = message.getPayload().array();
|
||||
TunnelResponseDto response = objectMapper.readValue(payload, TunnelResponseDto.class);
|
||||
|
||||
if (tunnelService != null) {
|
||||
tunnelService.handleTunnelResponse(response);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing binary message from session {}: {}", session.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
|
||||
log.error("Transport error for session {}: {}", session.getId(), exception.getMessage());
|
||||
sessions.remove(session);
|
||||
session.close();
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(final @NonNull WebSocketSession session) throws Exception {
|
||||
sessions.add(session);
|
||||
|
||||
// Extract client name from session attributes (set by JWT interceptor)
|
||||
String clientName = (String) session.getAttributes().get("clientName");
|
||||
if (clientName != null) {
|
||||
clientSessions.put(clientName, session);
|
||||
log.info("Client {} connected with name: {}", session.getId(), clientName);
|
||||
} else {
|
||||
log.warn("Client {} connected without valid client name", session.getId());
|
||||
clientSessions.put(session.getId(), session);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(final @NonNull WebSocketSession session, final @NonNull CloseStatus status) throws Exception {
|
||||
sessions.remove(session);
|
||||
|
||||
// Remove from clientSessions by finding the entry with this session
|
||||
String clientName = (String) session.getAttributes().get("clientName");
|
||||
if (clientName != null) {
|
||||
clientSessions.remove(clientName);
|
||||
log.info("Client {} disconnected (name: {})", session.getId(), clientName);
|
||||
} else {
|
||||
clientSessions.remove(session.getId());
|
||||
log.info("Client {} disconnected", session.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsPartialMessages() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public WebSocketSession getClientSession(String clientShortName) {
|
||||
return clientSessions.get(clientShortName);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,230 @@
|
||||
package dev.thinhha.tunnel_server.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.thinhha.tunnel_server.dto.TunnelRequestDto;
|
||||
import dev.thinhha.tunnel_server.dto.TunnelResponseDto;
|
||||
import dev.thinhha.tunnel_server.service.TunnelService;
|
||||
import dev.thinhha.tunnel_server.types.TunnelRequestType;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class WebSocketTunnelHandler implements org.springframework.web.socket.WebSocketHandler {
|
||||
|
||||
private final TunnelService tunnelService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public WebSocketTunnelHandler(TunnelService tunnelService, ObjectMapper objectMapper) {
|
||||
this.tunnelService = tunnelService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
// Track WebSocket connections: clientId/path -> Map<wsConnectionId, WebSocketSession>
|
||||
private final Map<String, Map<String, WebSocketSession>> webSocketConnections = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
||||
String path = session.getUri().getPath();
|
||||
String clientId = extractClientIdFromPath(path);
|
||||
String wsPath = extractWsPathFromPath(path);
|
||||
String wsConnectionId = UUID.randomUUID().toString();
|
||||
|
||||
log.info("WebSocket connection established for client: {} path: {} wsId: {}",
|
||||
clientId, wsPath, wsConnectionId);
|
||||
|
||||
// Store WebSocket session
|
||||
webSocketConnections.computeIfAbsent(clientId + wsPath, k -> new ConcurrentHashMap<>())
|
||||
.put(wsConnectionId, session);
|
||||
|
||||
// Store connection ID in session attributes
|
||||
session.getAttributes().put("wsConnectionId", wsConnectionId);
|
||||
session.getAttributes().put("clientId", clientId);
|
||||
session.getAttributes().put("wsPath", wsPath);
|
||||
|
||||
// Send WebSocket connection request to tunnel client
|
||||
TunnelRequestDto tunnelRequest = new TunnelRequestDto();
|
||||
tunnelRequest.setRequestId(UUID.randomUUID().toString());
|
||||
tunnelRequest.setType(TunnelRequestType.WS_CONNECT);
|
||||
tunnelRequest.setPath(wsPath);
|
||||
tunnelRequest.setHeaders(extractHeaders(session));
|
||||
tunnelRequest.setClientShortName(clientId);
|
||||
tunnelRequest.setWsConnectionId(wsConnectionId);
|
||||
|
||||
try {
|
||||
tunnelService.sendTunnelRequest(tunnelRequest);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send WebSocket connect request to tunnel client", e);
|
||||
session.close(CloseStatus.SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
|
||||
if (message instanceof TextMessage textMessage) {
|
||||
handleWebSocketMessage(session, textMessage.getPayload().getBytes(), "TEXT");
|
||||
} else if (message instanceof BinaryMessage binaryMessage) {
|
||||
handleWebSocketMessage(session, binaryMessage.getPayload().array(), "BINARY");
|
||||
}
|
||||
}
|
||||
|
||||
private void handleWebSocketMessage(WebSocketSession session, byte[] messageBody, String messageType) throws Exception {
|
||||
String wsConnectionId = (String) session.getAttributes().get("wsConnectionId");
|
||||
String clientId = (String) session.getAttributes().get("clientId");
|
||||
String wsPath = (String) session.getAttributes().get("wsPath");
|
||||
|
||||
if (wsConnectionId == null || clientId == null) {
|
||||
log.warn("WebSocket message received without proper connection tracking");
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward message to tunnel client
|
||||
TunnelRequestDto tunnelRequest = new TunnelRequestDto();
|
||||
tunnelRequest.setRequestId(UUID.randomUUID().toString());
|
||||
tunnelRequest.setType(TunnelRequestType.WS_MESSAGE);
|
||||
tunnelRequest.setPath(wsPath);
|
||||
tunnelRequest.setBody(messageBody);
|
||||
tunnelRequest.setClientShortName(clientId);
|
||||
tunnelRequest.setWsConnectionId(wsConnectionId);
|
||||
tunnelRequest.setWsMessageType(messageType);
|
||||
|
||||
try {
|
||||
tunnelService.sendTunnelRequest(tunnelRequest);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to forward WebSocket message to tunnel client", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
|
||||
String wsConnectionId = (String) session.getAttributes().get("wsConnectionId");
|
||||
String clientId = (String) session.getAttributes().get("clientId");
|
||||
String wsPath = (String) session.getAttributes().get("wsPath");
|
||||
|
||||
if (wsConnectionId != null && clientId != null && wsPath != null) {
|
||||
// Remove from tracking
|
||||
Map<String, WebSocketSession> clientConnections = webSocketConnections.get(clientId + wsPath);
|
||||
if (clientConnections != null) {
|
||||
clientConnections.remove(wsConnectionId);
|
||||
if (clientConnections.isEmpty()) {
|
||||
webSocketConnections.remove(clientId + wsPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Send close request to tunnel client
|
||||
TunnelRequestDto tunnelRequest = new TunnelRequestDto();
|
||||
tunnelRequest.setRequestId(UUID.randomUUID().toString());
|
||||
tunnelRequest.setType(TunnelRequestType.WS_CLOSE);
|
||||
tunnelRequest.setPath(wsPath);
|
||||
tunnelRequest.setClientShortName(clientId);
|
||||
tunnelRequest.setWsConnectionId(wsConnectionId);
|
||||
|
||||
try {
|
||||
tunnelService.sendTunnelRequest(tunnelRequest);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send WebSocket close request to tunnel client", e);
|
||||
}
|
||||
|
||||
log.info("WebSocket connection closed for client: {} wsId: {}", clientId, wsConnectionId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
|
||||
log.error("WebSocket transport error: {}", exception.getMessage());
|
||||
String wsConnectionId = (String) session.getAttributes().get("wsConnectionId");
|
||||
if (wsConnectionId != null) {
|
||||
log.error("Error on WebSocket connection: {}", wsConnectionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Method to handle responses from tunnel client
|
||||
public void handleWebSocketResponse(TunnelResponseDto response) {
|
||||
String wsConnectionId = response.getWsConnectionId();
|
||||
if (wsConnectionId == null) return;
|
||||
|
||||
// Find the WebSocket session
|
||||
WebSocketSession targetSession = findWebSocketSession(wsConnectionId);
|
||||
if (targetSession == null || !targetSession.isOpen()) {
|
||||
log.warn("WebSocket session not found or closed: {}", wsConnectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (response.getType()) {
|
||||
case WS_CONNECT -> {
|
||||
if (response.getStatusCode() != 101) {
|
||||
// Connection failed, close the session
|
||||
targetSession.close(CloseStatus.SERVER_ERROR);
|
||||
}
|
||||
// Connection successful - no action needed, session already established
|
||||
}
|
||||
case WS_MESSAGE -> {
|
||||
// Forward message to WebSocket client
|
||||
if ("TEXT".equals(response.getWsMessageType())) {
|
||||
String textMessage = new String(response.getBody());
|
||||
targetSession.sendMessage(new TextMessage(textMessage));
|
||||
} else {
|
||||
targetSession.sendMessage(new BinaryMessage(response.getBody()));
|
||||
}
|
||||
}
|
||||
case WS_CLOSE -> {
|
||||
// Close the WebSocket session
|
||||
targetSession.close(new CloseStatus(response.getStatusCode(), ""));
|
||||
}
|
||||
case HTTP -> {
|
||||
// HTTP responses should not be handled here
|
||||
log.warn("Received HTTP response in WebSocket handler: {}", response.getRequestId());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error handling WebSocket response", e);
|
||||
}
|
||||
}
|
||||
|
||||
private WebSocketSession findWebSocketSession(String wsConnectionId) {
|
||||
return webSocketConnections.values().stream()
|
||||
.flatMap(map -> map.entrySet().stream())
|
||||
.filter(entry -> entry.getKey().equals(wsConnectionId))
|
||||
.map(Map.Entry::getValue)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String extractClientIdFromPath(String path) {
|
||||
// Extract client ID from path like /ws/{clientId}/some/path
|
||||
String[] parts = path.split("/");
|
||||
return parts.length > 2 ? parts[2] : "unknown";
|
||||
}
|
||||
|
||||
private String extractWsPathFromPath(String path) {
|
||||
// Extract WebSocket path after client ID: /ws/{clientId}/path -> /path
|
||||
String[] parts = path.split("/");
|
||||
if (parts.length > 3) {
|
||||
return "/" + String.join("/", java.util.Arrays.copyOfRange(parts, 3, parts.length));
|
||||
}
|
||||
return "/";
|
||||
}
|
||||
|
||||
private Map<String, String> extractHeaders(WebSocketSession session) {
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
session.getHandshakeHeaders().forEach((key, values) -> {
|
||||
if (!values.isEmpty()) {
|
||||
headers.put(key, values.get(0));
|
||||
}
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsPartialMessages() {
|
||||
return false;
|
||||
}
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
package dev.thinhha.tunnel_server.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSocket
|
||||
public class WebsocketConfig implements WebSocketConfigurer {
|
||||
private final WebSocketHandler webSocketHandler;
|
||||
private final FacadeWebSocketHandler facadeWebSocketHandler;
|
||||
private final WebSocketTunnelHandler webSocketTunnelHandler;
|
||||
private final JwtWebSocketHandshakeInterceptor jwtHandshakeInterceptor;
|
||||
|
||||
public WebsocketConfig(WebSocketHandler webSocketHandler,
|
||||
FacadeWebSocketHandler facadeWebSocketHandler,
|
||||
WebSocketTunnelHandler webSocketTunnelHandler,
|
||||
JwtWebSocketHandshakeInterceptor jwtHandshakeInterceptor) {
|
||||
this.webSocketHandler = webSocketHandler;
|
||||
this.facadeWebSocketHandler = facadeWebSocketHandler;
|
||||
this.webSocketTunnelHandler = webSocketTunnelHandler;
|
||||
this.jwtHandshakeInterceptor = jwtHandshakeInterceptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||
registry.addHandler(webSocketHandler, "/client")
|
||||
.addInterceptors(jwtHandshakeInterceptor)
|
||||
.setAllowedOriginPatterns("*");
|
||||
|
||||
registry.addHandler(webSocketTunnelHandler, "/ws/**") // WebSocket tunnel endpoints
|
||||
.setAllowedOriginPatterns("*");
|
||||
|
||||
registry.addHandler(facadeWebSocketHandler, "*")
|
||||
.setAllowedOriginPatterns("*");
|
||||
}
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
package dev.thinhha.tunnel_server.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.thinhha.tunnel_server.dto.ClientDto;
|
||||
import dev.thinhha.tunnel_server.entity.Client;
|
||||
import dev.thinhha.tunnel_server.mapper.ClientMapper;
|
||||
import dev.thinhha.tunnel_server.repository.ClientRepository;
|
||||
import dev.thinhha.tunnel_server.service.JwtService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/auth")
|
||||
@Slf4j
|
||||
public class AuthController {
|
||||
|
||||
private final ClientRepository clientRepository;
|
||||
private final ClientMapper clientMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final JwtService jwtService;
|
||||
|
||||
public AuthController(ClientRepository clientRepository,
|
||||
ClientMapper clientMapper,
|
||||
ObjectMapper objectMapper,
|
||||
JwtService jwtService) {
|
||||
this.clientRepository = clientRepository;
|
||||
this.clientMapper = clientMapper;
|
||||
this.objectMapper = objectMapper;
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<ClientDto> registerClient(@RequestBody ClientDto clientDto) {
|
||||
try {
|
||||
// Generate JWT token for the client
|
||||
String token = jwtService.generateToken(clientDto.getClientName());
|
||||
clientDto.setJwtToken(token);
|
||||
|
||||
// Save client to database
|
||||
Client client = objectMapper.convertValue(clientDto, Client.class);
|
||||
Client savedClient = clientRepository.save(client);
|
||||
|
||||
ClientDto responseDto = objectMapper.convertValue(savedClient, ClientDto.class);
|
||||
log.info("Client registered: {}", clientDto.getClientName());
|
||||
|
||||
return ResponseEntity.ok(responseDto);
|
||||
} catch (Exception e) {
|
||||
log.error("Error registering client", e);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/token")
|
||||
public ResponseEntity<String> generateToken(@RequestParam String clientName) {
|
||||
try {
|
||||
// Check if client exists
|
||||
Optional<Client> clientOpt = clientRepository.findByClientName(clientName);
|
||||
if (clientOpt.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// Generate new token
|
||||
String token = jwtService.generateToken(clientName);
|
||||
|
||||
// Update client with new token
|
||||
Client client = clientOpt.get();
|
||||
client.setJwtToken(token);
|
||||
clientRepository.save(client);
|
||||
|
||||
log.info("Token generated for client: {}", clientName);
|
||||
return ResponseEntity.ok(token);
|
||||
} catch (Exception e) {
|
||||
log.error("Error generating token", e);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/validate")
|
||||
public ResponseEntity<String> validateToken(@RequestParam String token) {
|
||||
try {
|
||||
String clientName = jwtService.extractClientName(token);
|
||||
return ResponseEntity.ok("Token valid for client: " + clientName);
|
||||
} catch (Exception e) {
|
||||
log.error("Token validation failed", e);
|
||||
return ResponseEntity.badRequest().body("Invalid token");
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
package dev.thinhha.tunnel_server.controller;
|
||||
|
||||
import dev.thinhha.tunnel_server.dto.ClientDto;
|
||||
import dev.thinhha.tunnel_server.service.ClientService;
|
||||
import dev.thinhha.tunnel_server.utils.ResourceUtils;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.net.URISyntaxException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/clients")
|
||||
public class ClientController {
|
||||
|
||||
private final ClientService clientService;
|
||||
|
||||
public ClientController(ClientService clientService) {
|
||||
this.clientService = clientService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ClientDto> createClient(final @Valid @RequestBody ClientDto body) throws URISyntaxException {
|
||||
var result = clientService.createClient(body);
|
||||
return ResponseEntity.created(ResourceUtils.buildResultUri()).body(result);
|
||||
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
package dev.thinhha.tunnel_server.controller;
|
||||
|
||||
import dev.thinhha.tunnel_server.dto.TunnelRequestDto;
|
||||
import dev.thinhha.tunnel_server.dto.TunnelResponseDto;
|
||||
import dev.thinhha.tunnel_server.service.TunnelService;
|
||||
import dev.thinhha.tunnel_server.types.HttpMethod;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/tunnel")
|
||||
@RequiredArgsConstructor
|
||||
public class TunnelController {
|
||||
|
||||
private final TunnelService tunnelService;
|
||||
|
||||
@RequestMapping(value = "/{clientId}/**", method = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.PATCH, RequestMethod.HEAD, RequestMethod.OPTIONS})
|
||||
public ResponseEntity<?> tunnelRequest(@PathVariable String clientId, HttpServletRequest request) throws IOException {
|
||||
String requestId = UUID.randomUUID().toString();
|
||||
|
||||
// Extract path after /tunnel/{clientId}
|
||||
String fullPath = request.getRequestURI();
|
||||
String tunnelPath = fullPath.substring(("/tunnel/" + clientId).length());
|
||||
|
||||
// Create tunnel request DTO
|
||||
TunnelRequestDto tunnelRequest = new TunnelRequestDto();
|
||||
tunnelRequest.setRequestId(requestId);
|
||||
tunnelRequest.setMethod(HttpMethod.valueOf(request.getMethod()));
|
||||
tunnelRequest.setPath(tunnelPath);
|
||||
tunnelRequest.setHeaders(tunnelService.extractHeaders(request));
|
||||
tunnelRequest.setBody(request.getInputStream().readAllBytes());
|
||||
tunnelRequest.setClientShortName(clientId);
|
||||
|
||||
try {
|
||||
// Send request to WebSocket client and wait for response
|
||||
CompletableFuture<TunnelResponseDto> responseFuture = tunnelService.sendTunnelRequest(tunnelRequest);
|
||||
TunnelResponseDto response = responseFuture.get(30, TimeUnit.SECONDS);
|
||||
|
||||
return ResponseEntity.status(response.getStatusCode())
|
||||
.headers(httpHeaders -> response.getHeaders().forEach(httpHeaders::add))
|
||||
.body(response.getBody());
|
||||
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(502).body("Tunnel request failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
18
src/main/java/dev/thinhha/tunnel_server/dto/ClientDto.java
Normal file
18
src/main/java/dev/thinhha/tunnel_server/dto/ClientDto.java
Normal file
@ -0,0 +1,18 @@
|
||||
package dev.thinhha.tunnel_server.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* DTO for {@link dev.thinhha.tunnel_server.entity.Client}
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public class ClientDto implements Serializable {
|
||||
private UUID id;
|
||||
private String clientName;
|
||||
private String jwtToken;
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
package dev.thinhha.tunnel_server.dto;
|
||||
|
||||
import dev.thinhha.tunnel_server.types.HttpMethod;
|
||||
import dev.thinhha.tunnel_server.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_server.dto;
|
||||
|
||||
import dev.thinhha.tunnel_server.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;
|
||||
}
|
41
src/main/java/dev/thinhha/tunnel_server/entity/Client.java
Normal file
41
src/main/java/dev/thinhha/tunnel_server/entity/Client.java
Normal file
@ -0,0 +1,41 @@
|
||||
package dev.thinhha.tunnel_server.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.proxy.HibernateProxy;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "clients")
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Client {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
private String clientName;
|
||||
|
||||
private String jwtToken;
|
||||
|
||||
@Override
|
||||
public final boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null) return false;
|
||||
Class<?> oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass();
|
||||
Class<?> thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass();
|
||||
if (thisEffectiveClass != oEffectiveClass) return false;
|
||||
Client client = (Client) o;
|
||||
return getId() != null && Objects.equals(getId(), client.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public final int hashCode() {
|
||||
return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode();
|
||||
}
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
package dev.thinhha.tunnel_server.mapper;
|
||||
|
||||
import dev.thinhha.tunnel_server.dto.ClientDto;
|
||||
import dev.thinhha.tunnel_server.entity.Client;
|
||||
import org.mapstruct.*;
|
||||
|
||||
@Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE, componentModel = MappingConstants.ComponentModel.SPRING)
|
||||
public interface ClientMapper {
|
||||
Client toEntity(ClientDto clientDto);
|
||||
|
||||
ClientDto toDto(Client client);
|
||||
|
||||
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
|
||||
Client partialUpdate(ClientDto clientDto, @MappingTarget Client client);
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
package dev.thinhha.tunnel_server.repository;
|
||||
|
||||
import dev.thinhha.tunnel_server.entity.Client;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface ClientRepository extends JpaRepository<Client, UUID>, JpaSpecificationExecutor<Client> {
|
||||
Optional<Client> findByClientName(String clientName);
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package dev.thinhha.tunnel_server.service;
|
||||
|
||||
import dev.thinhha.tunnel_server.dto.ClientDto;
|
||||
|
||||
public interface ClientService {
|
||||
ClientDto createClient(ClientDto clientDto);
|
||||
ClientDto updateClient(ClientDto clientDto);
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
package dev.thinhha.tunnel_server.service;
|
||||
|
||||
import com.nimbusds.jose.*;
|
||||
import com.nimbusds.jose.crypto.MACSigner;
|
||||
import com.nimbusds.jose.crypto.MACVerifier;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Date;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class JwtService {
|
||||
|
||||
@Value("${jwt.secret:your-256-bit-secret-key-here-must-be-at-least-32-chars}")
|
||||
private String jwtSecret;
|
||||
|
||||
@Value("${jwt.expiration:864000000}")
|
||||
private long jwtExpiration;
|
||||
|
||||
public String generateToken(String clientName) {
|
||||
try {
|
||||
JWSSigner signer = new MACSigner(jwtSecret.getBytes());
|
||||
|
||||
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
|
||||
.subject(clientName)
|
||||
.claim("name", clientName)
|
||||
.issuer("tunnel-server")
|
||||
.issueTime(new Date())
|
||||
.expirationTime(Date.from(Instant.now().plus(jwtExpiration, ChronoUnit.SECONDS)))
|
||||
.build();
|
||||
|
||||
SignedJWT signedJWT = new SignedJWT(
|
||||
new JWSHeader(JWSAlgorithm.HS256),
|
||||
claimsSet);
|
||||
|
||||
signedJWT.sign(signer);
|
||||
|
||||
return signedJWT.serialize();
|
||||
} catch (JOSEException e) {
|
||||
log.error("Error generating JWT token", e);
|
||||
throw new RuntimeException("Failed to generate JWT token", e);
|
||||
}
|
||||
}
|
||||
|
||||
public JWTClaimsSet validateToken(String token) {
|
||||
try {
|
||||
SignedJWT signedJWT = SignedJWT.parse(token);
|
||||
JWSVerifier verifier = new MACVerifier(jwtSecret.getBytes());
|
||||
|
||||
if (!signedJWT.verify(verifier)) {
|
||||
throw new RuntimeException("Invalid JWT signature");
|
||||
}
|
||||
|
||||
JWTClaimsSet claimsSet = signedJWT.getJWTClaimsSet();
|
||||
|
||||
// Check expiration
|
||||
if (claimsSet.getExpirationTime().before(new Date())) {
|
||||
throw new RuntimeException("JWT token has expired");
|
||||
}
|
||||
|
||||
return claimsSet;
|
||||
} catch (Exception e) {
|
||||
log.error("Error validating JWT token", e);
|
||||
throw new RuntimeException("Invalid JWT token", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String extractClientName(String token) {
|
||||
try {
|
||||
JWTClaimsSet claimsSet = validateToken(token);
|
||||
return claimsSet.getStringClaim("name");
|
||||
} catch (Exception e) {
|
||||
log.error("Error extracting client name from JWT", e);
|
||||
throw new RuntimeException("Failed to extract client name", e);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,104 @@
|
||||
package dev.thinhha.tunnel_server.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.thinhha.tunnel_server.config.WebSocketHandler;
|
||||
import dev.thinhha.tunnel_server.config.WebSocketTunnelHandler;
|
||||
import dev.thinhha.tunnel_server.dto.TunnelRequestDto;
|
||||
import dev.thinhha.tunnel_server.dto.TunnelResponseDto;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.socket.BinaryMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class TunnelService {
|
||||
|
||||
private final WebSocketHandler webSocketHandler;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final WebSocketTunnelHandler webSocketTunnelHandler;
|
||||
|
||||
private final Map<String, CompletableFuture<TunnelResponseDto>> pendingResponses = new ConcurrentHashMap<>();
|
||||
|
||||
public TunnelService(@Lazy WebSocketHandler webSocketHandler,
|
||||
ObjectMapper objectMapper,
|
||||
@Lazy WebSocketTunnelHandler webSocketTunnelHandler) {
|
||||
this.webSocketHandler = webSocketHandler;
|
||||
this.objectMapper = objectMapper;
|
||||
this.webSocketTunnelHandler = webSocketTunnelHandler;
|
||||
}
|
||||
|
||||
|
||||
public Map<String, String> extractHeaders(HttpServletRequest request) {
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
Enumeration<String> headerNames = request.getHeaderNames();
|
||||
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String headerName = headerNames.nextElement();
|
||||
String headerValue = request.getHeader(headerName);
|
||||
headers.put(headerName, headerValue);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
public CompletableFuture<TunnelResponseDto> sendTunnelRequest(TunnelRequestDto tunnelRequest) throws IOException {
|
||||
CompletableFuture<TunnelResponseDto> responseFuture = new CompletableFuture<>();
|
||||
|
||||
// Store the future for when response comes back
|
||||
pendingResponses.put(tunnelRequest.getRequestId(), responseFuture);
|
||||
|
||||
// Find the WebSocket session for the client
|
||||
WebSocketSession session = webSocketHandler.getClientSession(tunnelRequest.getClientShortName());
|
||||
|
||||
if (session == null || !session.isOpen()) {
|
||||
responseFuture.completeExceptionally(new RuntimeException("Client not connected"));
|
||||
return responseFuture;
|
||||
}
|
||||
|
||||
// Serialize and send the request
|
||||
byte[] messageBytes = objectMapper.writeValueAsBytes(tunnelRequest);
|
||||
BinaryMessage message = new BinaryMessage(messageBytes);
|
||||
|
||||
try {
|
||||
session.sendMessage(message);
|
||||
log.info("Sent tunnel request {} to client {}", tunnelRequest.getRequestId(), tunnelRequest.getClientShortName());
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to send tunnel request", e);
|
||||
pendingResponses.remove(tunnelRequest.getRequestId());
|
||||
responseFuture.completeExceptionally(e);
|
||||
}
|
||||
|
||||
return responseFuture;
|
||||
}
|
||||
|
||||
public void handleTunnelResponse(TunnelResponseDto response) {
|
||||
// Handle WebSocket responses separately
|
||||
if (response.getType() != null && response.getType() != dev.thinhha.tunnel_server.types.TunnelRequestType.HTTP) {
|
||||
if (webSocketTunnelHandler != null) {
|
||||
webSocketTunnelHandler.handleWebSocketResponse(response);
|
||||
log.info("Handled WebSocket response: {} type: {}", response.getRequestId(), response.getType());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle HTTP responses
|
||||
CompletableFuture<TunnelResponseDto> responseFuture = pendingResponses.remove(response.getRequestId());
|
||||
|
||||
if (responseFuture != null) {
|
||||
responseFuture.complete(response);
|
||||
log.info("Completed tunnel response {}", response.getRequestId());
|
||||
} else {
|
||||
log.warn("Received response for unknown request {}", response.getRequestId());
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
package dev.thinhha.tunnel_server.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.thinhha.tunnel_server.dto.ClientDto;
|
||||
import dev.thinhha.tunnel_server.entity.Client;
|
||||
import dev.thinhha.tunnel_server.mapper.ClientMapper;
|
||||
import dev.thinhha.tunnel_server.repository.ClientRepository;
|
||||
import dev.thinhha.tunnel_server.service.ClientService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class ClientServiceImpl implements ClientService {
|
||||
private final ClientMapper clientMapper;
|
||||
private final ClientRepository clientRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ClientServiceImpl(ClientMapper clientMapper,
|
||||
ClientRepository clientRepository,
|
||||
ObjectMapper objectMapper) {
|
||||
this.clientMapper = clientMapper;
|
||||
this.clientRepository = clientRepository;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientDto createClient(ClientDto clientDto) {
|
||||
var client = objectMapper.convertValue(clientDto, Client.class);
|
||||
return objectMapper.convertValue(
|
||||
clientRepository.save(client), ClientDto.class
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientDto updateClient(ClientDto clientDto) {
|
||||
return null;
|
||||
}
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
package dev.thinhha.tunnel_server.types;
|
||||
|
||||
public enum ConnectionType {
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
package dev.thinhha.tunnel_server.types;
|
||||
|
||||
public enum HttpMethod {
|
||||
GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package dev.thinhha.tunnel_server.types;
|
||||
|
||||
public enum TunnelRequestType {
|
||||
HTTP, // Regular HTTP request
|
||||
WS_CONNECT, // WebSocket connection request
|
||||
WS_MESSAGE, // WebSocket message
|
||||
WS_CLOSE // WebSocket close
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
package dev.thinhha.tunnel_server.utils;
|
||||
|
||||
import dev.thinhha.tunnel_server.config.Constant;
|
||||
import lombok.NonNull;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
|
||||
public class RandomUtils {
|
||||
private static final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
private RandomUtils() {
|
||||
}
|
||||
|
||||
public static @NonNull String generateAlphaNumericString(final int length) {
|
||||
return generateFromCharset(Constant.ALPHANUMERIC, length);
|
||||
}
|
||||
|
||||
public static @NonNull String generateFromCharset(final @NonNull String charset, final int length) {
|
||||
var builder = new StringBuilder();
|
||||
for (int i = 0; i < length; i++) {
|
||||
var index = secureRandom.nextInt(charset.length());
|
||||
builder.append(charset.charAt(index));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
package dev.thinhha.tunnel_server.utils;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
|
||||
public class ResourceUtils {
|
||||
private ResourceUtils() {}
|
||||
|
||||
public static URI buildResultUri() throws URISyntaxException {
|
||||
return new URI("abc");
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user