From 2545f285eb68d8c5113f124e7cc5b720e0259b00 Mon Sep 17 00:00:00 2001 From: litoral05 Date: Fri, 8 May 2026 14:14:23 +0100 Subject: [PATCH] Stable core service --- .../auth/ApiKeyAuthFilter.java | 69 ++++++++++ .../common/exception/ApiErrorResponse.java | 12 ++ .../exception/GlobalExceptionHandler.java | 65 +++++++++ .../config/AppSecurityProperties.java | 17 +++ .../config/SecurityConfig.java | 45 +++++++ .../config/VpsSshProperties.java | 71 ++++++++++ .../vpnorchestrator/vpn/VpnController.java | 59 ++++++++ .../vpn/VpnIpAllocatorService.java | 23 ++++ .../vpn/dto/AvailableVpnIpResponse.java | 6 + .../vpn/dto/PeerApplyResponse.java | 8 ++ .../vpn/dto/UpsertPeerRequest.java | 22 +++ .../vpn/dto/UsedVpnIpsResponse.java | 9 ++ .../vps/SshCommandException.java | 12 ++ .../vpnorchestrator/vps/SshCommandResult.java | 8 ++ .../vpnorchestrator/vps/SshService.java | 126 ++++++++++++++++++ .../vpnorchestrator/vps/VpsController.java | 29 ++++ .../vps/WireGuardPeerApplyResult.java | 8 ++ .../vpnorchestrator/vps/WireGuardService.java | 126 ++++++++++++++++++ .../vps/dto/VpsHealthResponse.java | 17 +++ src/main/resources/application.yaml | 22 +++ 20 files changed, 754 insertions(+) create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/auth/ApiKeyAuthFilter.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/common/exception/ApiErrorResponse.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/common/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/config/AppSecurityProperties.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/config/SecurityConfig.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/config/VpsSshProperties.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vpn/VpnController.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vpn/VpnIpAllocatorService.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vpn/dto/AvailableVpnIpResponse.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vpn/dto/PeerApplyResponse.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vpn/dto/UpsertPeerRequest.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vpn/dto/UsedVpnIpsResponse.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/SshCommandException.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/SshCommandResult.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/SshService.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/VpsController.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/WireGuardPeerApplyResult.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/WireGuardService.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/dto/VpsHealthResponse.java diff --git a/src/main/java/com/litoralregas/vpnorchestrator/auth/ApiKeyAuthFilter.java b/src/main/java/com/litoralregas/vpnorchestrator/auth/ApiKeyAuthFilter.java new file mode 100644 index 0000000..be9f495 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/auth/ApiKeyAuthFilter.java @@ -0,0 +1,69 @@ +package com.litoralregas.vpnorchestrator.auth; + +import com.litoralregas.vpnorchestrator.config.AppSecurityProperties; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +public class ApiKeyAuthFilter extends OncePerRequestFilter { + + private static final String API_KEY_HEADER = "X-API-Key"; + + private final AppSecurityProperties securityProperties; + + public ApiKeyAuthFilter(AppSecurityProperties securityProperties) { + this.securityProperties = securityProperties; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return "/actuator/health".equals(request.getRequestURI()); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String providedApiKey = request.getHeader(API_KEY_HEADER); + String expectedApiKey = securityProperties.getApiKey(); + + if (!StringUtils.hasText(expectedApiKey)) { + response.sendError( + HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "API key is not configured" + ); + return; + } + + if (!expectedApiKey.equals(providedApiKey)) { + response.sendError( + HttpServletResponse.SC_UNAUTHORIZED, + "Invalid API key" + ); + return; + } + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + "api-key-client", + null, + List.of(new SimpleGrantedAuthority("ROLE_API")) + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/common/exception/ApiErrorResponse.java b/src/main/java/com/litoralregas/vpnorchestrator/common/exception/ApiErrorResponse.java new file mode 100644 index 0000000..52127e3 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/common/exception/ApiErrorResponse.java @@ -0,0 +1,12 @@ +package com.litoralregas.vpnorchestrator.common.exception; + +import java.time.Instant; + +public record ApiErrorResponse( + Instant timestamp, + int status, + String error, + String message, + String path +) { +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/common/exception/GlobalExceptionHandler.java b/src/main/java/com/litoralregas/vpnorchestrator/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..51b95e6 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,65 @@ +package com.litoralregas.vpnorchestrator.common.exception; + +import com.litoralregas.vpnorchestrator.vps.SshCommandException; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiErrorResponse handleValidation( + MethodArgumentNotValidException exception, + HttpServletRequest request + ) { + String message = exception.getBindingResult() + .getFieldErrors() + .stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .collect(Collectors.joining(", ")); + + return new ApiErrorResponse( + Instant.now(), + HttpStatus.BAD_REQUEST.value(), + "Bad Request", + message, + request.getRequestURI() + ); + } + + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiErrorResponse handleIllegalArgument( + IllegalArgumentException exception, + HttpServletRequest request + ) { + return new ApiErrorResponse( + Instant.now(), + HttpStatus.BAD_REQUEST.value(), + "Bad Request", + exception.getMessage(), + request.getRequestURI() + ); + } + + @ExceptionHandler(SshCommandException.class) + @ResponseStatus(HttpStatus.BAD_GATEWAY) + public ApiErrorResponse handleSshCommand( + SshCommandException exception, + HttpServletRequest request + ) { + return new ApiErrorResponse( + Instant.now(), + HttpStatus.BAD_GATEWAY.value(), + "Bad Gateway", + exception.getMessage(), + request.getRequestURI() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/config/AppSecurityProperties.java b/src/main/java/com/litoralregas/vpnorchestrator/config/AppSecurityProperties.java new file mode 100644 index 0000000..1c6ea79 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/config/AppSecurityProperties.java @@ -0,0 +1,17 @@ +package com.litoralregas.vpnorchestrator.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.security") +public class AppSecurityProperties { + + private String apiKey; + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/config/SecurityConfig.java b/src/main/java/com/litoralregas/vpnorchestrator/config/SecurityConfig.java new file mode 100644 index 0000000..9157e99 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/config/SecurityConfig.java @@ -0,0 +1,45 @@ +package com.litoralregas.vpnorchestrator.config; + +import com.litoralregas.vpnorchestrator.auth.ApiKeyAuthFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableConfigurationProperties({ + AppSecurityProperties.class, + VpsSshProperties.class +}) +public class SecurityConfig { + + private final AppSecurityProperties securityProperties; + + public SecurityConfig(AppSecurityProperties securityProperties) { + this.securityProperties = securityProperties; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + ApiKeyAuthFilter apiKeyAuthFilter = new ApiKeyAuthFilter(securityProperties); + + return http + .csrf(csrf -> csrf.disable()) + .cors(cors -> cors.disable()) + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + .logout(logout -> logout.disable()) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/health").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(apiKeyAuthFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/config/VpsSshProperties.java b/src/main/java/com/litoralregas/vpnorchestrator/config/VpsSshProperties.java new file mode 100644 index 0000000..f9ad060 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/config/VpsSshProperties.java @@ -0,0 +1,71 @@ +package com.litoralregas.vpnorchestrator.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.vps.ssh") +public class VpsSshProperties { + + private String host; + private int port = 22; + private String username = "root"; + private String password; + private String privateKeyPath; + private int connectTimeoutMs = 10_000; + private int commandTimeoutMs = 15_000; + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getPrivateKeyPath() { + return privateKeyPath; + } + + public void setPrivateKeyPath(String privateKeyPath) { + this.privateKeyPath = privateKeyPath; + } + + public int getConnectTimeoutMs() { + return connectTimeoutMs; + } + + public void setConnectTimeoutMs(int connectTimeoutMs) { + this.connectTimeoutMs = connectTimeoutMs; + } + + public int getCommandTimeoutMs() { + return commandTimeoutMs; + } + + public void setCommandTimeoutMs(int commandTimeoutMs) { + this.commandTimeoutMs = commandTimeoutMs; + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vpn/VpnController.java b/src/main/java/com/litoralregas/vpnorchestrator/vpn/VpnController.java new file mode 100644 index 0000000..57251d7 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vpn/VpnController.java @@ -0,0 +1,59 @@ +package com.litoralregas.vpnorchestrator.vpn; + +import com.litoralregas.vpnorchestrator.vpn.dto.AvailableVpnIpResponse; +import com.litoralregas.vpnorchestrator.vpn.dto.PeerApplyResponse; +import com.litoralregas.vpnorchestrator.vpn.dto.UpsertPeerRequest; +import com.litoralregas.vpnorchestrator.vpn.dto.UsedVpnIpsResponse; +import com.litoralregas.vpnorchestrator.vps.WireGuardService; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.util.Set; + +@RestController +@RequestMapping("/api/vpn") +public class VpnController { + + private final WireGuardService wireGuardService; + private final VpnIpAllocatorService vpnIpAllocatorService; + + public VpnController( + WireGuardService wireGuardService, + VpnIpAllocatorService vpnIpAllocatorService + ) { + this.wireGuardService = wireGuardService; + this.vpnIpAllocatorService = vpnIpAllocatorService; + } + + @GetMapping("/available-ip") + public AvailableVpnIpResponse availableIp() { + Set usedIps = wireGuardService.findUsedVpnIps(); + String vpnIp = vpnIpAllocatorService.nextAvailableIp(usedIps); + + return new AvailableVpnIpResponse(vpnIp); + } + + @PostMapping("/peers") + public PeerApplyResponse upsertPeer(@Valid @RequestBody UpsertPeerRequest request) { + wireGuardService.applyPeer( + request.publicKey(), + request.vpnIp() + "/32" + ); + + return new PeerApplyResponse( + request.vpnIp(), + request.publicKey(), + true + ); + } + + @GetMapping("/used-ips") + public UsedVpnIpsResponse usedIps() { + Set usedIps = wireGuardService.findUsedVpnIps(); + + return new UsedVpnIpsResponse( + usedIps, + usedIps.size() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vpn/VpnIpAllocatorService.java b/src/main/java/com/litoralregas/vpnorchestrator/vpn/VpnIpAllocatorService.java new file mode 100644 index 0000000..f41c547 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vpn/VpnIpAllocatorService.java @@ -0,0 +1,23 @@ +package com.litoralregas.vpnorchestrator.vpn; + +import org.springframework.stereotype.Service; + +import java.util.Set; + +@Service +public class VpnIpAllocatorService { + + public String nextAvailableIp(Set usedIps) { + for (int third = 1; third <= 254; third++) { + for (int fourth = 1; fourth <= 254; fourth++) { + String candidate = "198.19." + third + "." + fourth; + + if (!usedIps.contains(candidate)) { + return candidate; + } + } + } + + throw new IllegalStateException("No available VPN IPs in 198.19.0.0/16 pool"); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vpn/dto/AvailableVpnIpResponse.java b/src/main/java/com/litoralregas/vpnorchestrator/vpn/dto/AvailableVpnIpResponse.java new file mode 100644 index 0000000..77a874f --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vpn/dto/AvailableVpnIpResponse.java @@ -0,0 +1,6 @@ +package com.litoralregas.vpnorchestrator.vpn.dto; + +public record AvailableVpnIpResponse( + String vpnIp +) { +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vpn/dto/PeerApplyResponse.java b/src/main/java/com/litoralregas/vpnorchestrator/vpn/dto/PeerApplyResponse.java new file mode 100644 index 0000000..e64b9f1 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vpn/dto/PeerApplyResponse.java @@ -0,0 +1,8 @@ +package com.litoralregas.vpnorchestrator.vpn.dto; + +public record PeerApplyResponse( + String vpnIp, + String publicKey, + boolean applied +) { +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vpn/dto/UpsertPeerRequest.java b/src/main/java/com/litoralregas/vpnorchestrator/vpn/dto/UpsertPeerRequest.java new file mode 100644 index 0000000..1be55d5 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vpn/dto/UpsertPeerRequest.java @@ -0,0 +1,22 @@ +package com.litoralregas.vpnorchestrator.vpn.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record UpsertPeerRequest( + + @NotBlank + @Pattern( + regexp = "^198\\.19\\.(?:[1-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-4])\\.(?:[1-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-4])$", + message = "VPN IP must be inside allocatable range 198.19.1.1 - 198.19.254.254" + ) + String vpnIp, + + @NotBlank + @Pattern( + regexp = "^[A-Za-z0-9+/]{43}=$", + message = "Invalid WireGuard public key format" + ) + String publicKey +) { +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vpn/dto/UsedVpnIpsResponse.java b/src/main/java/com/litoralregas/vpnorchestrator/vpn/dto/UsedVpnIpsResponse.java new file mode 100644 index 0000000..642c2c6 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vpn/dto/UsedVpnIpsResponse.java @@ -0,0 +1,9 @@ +package com.litoralregas.vpnorchestrator.vpn.dto; + +import java.util.Set; + +public record UsedVpnIpsResponse( + Set usedIps, + int count +) { +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/SshCommandException.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/SshCommandException.java new file mode 100644 index 0000000..daf27ec --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/SshCommandException.java @@ -0,0 +1,12 @@ +package com.litoralregas.vpnorchestrator.vps; + +public class SshCommandException extends RuntimeException { + + public SshCommandException(String message) { + super(message); + } + + public SshCommandException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/SshCommandResult.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/SshCommandResult.java new file mode 100644 index 0000000..7dbcfa7 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/SshCommandResult.java @@ -0,0 +1,8 @@ +package com.litoralregas.vpnorchestrator.vps; + +public record SshCommandResult( + int exitCode, + String stdout, + String stderr +) { +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/SshService.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/SshService.java new file mode 100644 index 0000000..171ef84 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/SshService.java @@ -0,0 +1,126 @@ +package com.litoralregas.vpnorchestrator.vps; + +import com.jcraft.jsch.ChannelExec; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.Session; +import com.litoralregas.vpnorchestrator.config.VpsSshProperties; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Properties; + +@Service +public class SshService { + + private final VpsSshProperties properties; + + public SshService(VpsSshProperties properties) { + this.properties = properties; + } + + public SshCommandResult executeOnConfiguredVps(String command) { + validateConfiguredVps(); + return execute( + properties.getHost(), + properties.getPort(), + properties.getUsername(), + properties.getPassword(), + properties.getPrivateKeyPath(), + command, + properties.getConnectTimeoutMs(), + properties.getCommandTimeoutMs() + ); + } + + public SshCommandResult execute( + String host, + int port, + String username, + String password, + String privateKeyPath, + String command, + int connectTimeoutMs, + int commandTimeoutMs + ) { + Session session = null; + ChannelExec channel = null; + + try { + JSch jsch = new JSch(); + + if (StringUtils.hasText(privateKeyPath)) { + jsch.addIdentity(privateKeyPath); + } + + session = jsch.getSession(username, host, port); + + if (StringUtils.hasText(password)) { + session.setPassword(password); + } + + Properties config = new Properties(); + config.put("StrictHostKeyChecking", "no"); + session.setConfig(config); + + session.connect(connectTimeoutMs); + + channel = (ChannelExec) session.openChannel("exec"); + channel.setCommand(command); + channel.setInputStream(null); + + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + + channel.setOutputStream(stdout); + channel.setErrStream(stderr); + + channel.connect(connectTimeoutMs); + + long deadline = System.currentTimeMillis() + commandTimeoutMs; + + while (!channel.isClosed()) { + if (System.currentTimeMillis() > deadline) { + throw new SshCommandException("SSH command timed out: " + command); + } + + Thread.sleep(100); + } + + return new SshCommandResult( + channel.getExitStatus(), + stdout.toString(StandardCharsets.UTF_8), + stderr.toString(StandardCharsets.UTF_8) + ); + + } catch (SshCommandException exception) { + throw exception; + } catch (Exception exception) { + throw new SshCommandException("SSH command failed: " + command, exception); + } finally { + if (channel != null) { + channel.disconnect(); + } + + if (session != null) { + session.disconnect(); + } + } + } + + private void validateConfiguredVps() { + if (!StringUtils.hasText(properties.getHost())) { + throw new SshCommandException("VPS SSH host is not configured"); + } + + if (!StringUtils.hasText(properties.getUsername())) { + throw new SshCommandException("VPS SSH username is not configured"); + } + + if (!StringUtils.hasText(properties.getPassword()) + && !StringUtils.hasText(properties.getPrivateKeyPath())) { + throw new SshCommandException("Either VPS SSH password or private key path must be configured"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/VpsController.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/VpsController.java new file mode 100644 index 0000000..a7d61e3 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/VpsController.java @@ -0,0 +1,29 @@ +package com.litoralregas.vpnorchestrator.vps; + +import com.litoralregas.vpnorchestrator.vps.dto.VpsHealthResponse; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/vps") +public class VpsController { + + private final WireGuardService wireGuardService; + + public VpsController(WireGuardService wireGuardService) { + this.wireGuardService = wireGuardService; + } + + @GetMapping(value = "/health", produces = MediaType.APPLICATION_JSON_VALUE) + public VpsHealthResponse health() { + return wireGuardService.getVpsHealth(); + } + + @PostMapping( + value = "/wireguard/rollback-last-backup", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public String rollbackLastBackup() { + return wireGuardService.restoreLastWireGuardBackup(); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/WireGuardPeerApplyResult.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/WireGuardPeerApplyResult.java new file mode 100644 index 0000000..0f48d37 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/WireGuardPeerApplyResult.java @@ -0,0 +1,8 @@ +package com.litoralregas.vpnorchestrator.vps; + +public record WireGuardPeerApplyResult( + String publicKey, + String allowedIps, + boolean applied +) { +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/WireGuardService.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/WireGuardService.java new file mode 100644 index 0000000..07675bf --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/WireGuardService.java @@ -0,0 +1,126 @@ +package com.litoralregas.vpnorchestrator.vps; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.litoralregas.vpnorchestrator.vps.dto.VpsHealthResponse; +import org.springframework.stereotype.Service; + +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +public class WireGuardService { + + private static final Pattern VPN_IP_PATTERN = + Pattern.compile("\\b198\\.19\\.\\d{1,3}\\.\\d{1,3}\\b"); + + private final SshService sshService; + private final ObjectMapper objectMapper; + + public WireGuardService(SshService sshService, ObjectMapper objectMapper) { + this.sshService = sshService; + this.objectMapper = objectMapper; + } + + public Set findUsedVpnIps() { + SshCommandResult result = sshService.executeOnConfiguredVps( + "sudo /usr/local/sbin/lr-wg-used-ips" + ); + + if (result.exitCode() != 0) { + throw new SshCommandException( + "Failed to query WireGuard allowed IPs: " + result.stderr() + ); + } + + return parseVpnIps(result.stdout()); + } + + Set parseVpnIps(String output) { + Set ips = new HashSet<>(); + + Matcher matcher = VPN_IP_PATTERN.matcher(output); + + while (matcher.find()) { + ips.add(matcher.group()); + } + + return ips; + } + + public WireGuardPeerApplyResult applyPeer(String publicKey, String allowedIps) { + + String command = "sudo /usr/local/sbin/lr-wg-add-peer " + + shellQuote(publicKey) + " " + + shellQuote(allowedIps); + + SshCommandResult result = sshService.executeOnConfiguredVps(command); + + if (result.exitCode() != 0) { + throw new SshCommandException( + "Failed to apply WireGuard peer: " + result.stderr() + result.stdout() + ); + } + + return new WireGuardPeerApplyResult( + publicKey, + allowedIps, + true + ); + } + + private String shellQuote(String value) { + return "'" + value.replace("'", "'\"'\"'") + "'"; + } + + public VpsHealthResponse getVpsHealth() { + SshCommandResult result = sshService.executeOnConfiguredVps( + "sudo /usr/local/sbin/lr-vps-health" + ); + + if (result.exitCode() != 0) { + throw new SshCommandException( + "Failed to query VPS health: " + result.stderr() + ); + } + + try { + return objectMapper.readValue(result.stdout(), VpsHealthResponse.class); + } catch (JsonProcessingException e) { + throw new IllegalStateException( + "Invalid VPS health JSON returned by script", + e + ); + } + } + + public String restoreLastWireGuardBackup() { + SshCommandResult result = sshService.executeOnConfiguredVps( + "sudo /usr/local/sbin/lr-wg-restore-last-backup" + ); + + if (result.exitCode() != 0) { + throw new SshCommandException( + "Failed to restore WireGuard backup: " + result.stderr() + ); + } + + return result.stdout(); + } + + public String showAllowedIps() { + SshCommandResult result = sshService.executeOnConfiguredVps( + "sudo /usr/local/sbin/lr-wg-used-ips" + ); + + if (result.exitCode() != 0) { + throw new SshCommandException( + "Failed to query WireGuard allowed IPs: " + result.stderr() + ); + } + + return result.stdout(); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/VpsHealthResponse.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/VpsHealthResponse.java new file mode 100644 index 0000000..be96e7b --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/VpsHealthResponse.java @@ -0,0 +1,17 @@ +package com.litoralregas.vpnorchestrator.vps.dto; + +public record VpsHealthResponse( + String wireGuardInterface, + boolean wireGuardRunning, + int wireGuardPeerCount, + boolean wireGuardConfigExists, + String udp2rawService, + boolean udp2rawActive, + String latestWireGuardBackup, + String systemUptime, + int diskUsagePercent, + int memoryUsagePercent, + String loadAverage, + String publicIp +) { +} \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index ef34e0c..e69c952 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,25 @@ spring: application: name: vpn-orchestrator + +server: + port: 8080 + +management: + endpoints: + web: + exposure: + include: health + +app: + security: + api-key: dev-api-key + + vps: + ssh: + host: 146.59.230.190 + port: 22 + username: lr-vpn + password: hidrotek2026 + connect-timeout-ms: 10000 + command-timeout-ms: 15000 \ No newline at end of file