Compare commits
9 Commits
8d065e4112
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| af55458ad4 | |||
| 6712a26b2a | |||
| ad9a053099 | |||
| fe2faa4784 | |||
| 66adf1b42b | |||
| 8b849e6560 | |||
| f93a4c8402 | |||
| c88ef29449 | |||
| a09587950e |
@@ -80,6 +80,11 @@
|
|||||||
<artifactId>spring-security-test</artifactId>
|
<artifactId>spring-security-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.jcraft</groupId>
|
||||||
|
<artifactId>jsch</artifactId>
|
||||||
|
<version>0.1.55</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import jakarta.servlet.ServletException;
|
|||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
@@ -58,7 +59,7 @@ public class ApiKeyAuthFilter extends OncePerRequestFilter {
|
|||||||
new UsernamePasswordAuthenticationToken(
|
new UsernamePasswordAuthenticationToken(
|
||||||
"api-key-client",
|
"api-key-client",
|
||||||
null,
|
null,
|
||||||
List.of()
|
List.of(new SimpleGrantedAuthority("ROLE_API"))
|
||||||
);
|
);
|
||||||
|
|
||||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.common.exception;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record ApiErrorResponse(
|
||||||
|
Instant timestamp,
|
||||||
|
int status,
|
||||||
|
String error,
|
||||||
|
String message,
|
||||||
|
String path
|
||||||
|
) {
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.common.exception;
|
||||||
|
|
||||||
|
import com.litoralregas.vpnprovisioner.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(ResourceNotFoundException.class)
|
||||||
|
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||||
|
public ApiErrorResponse handleNotFound(
|
||||||
|
ResourceNotFoundException exception,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
return new ApiErrorResponse(
|
||||||
|
Instant.now(),
|
||||||
|
HttpStatus.NOT_FOUND.value(),
|
||||||
|
"Not Found",
|
||||||
|
exception.getMessage(),
|
||||||
|
request.getRequestURI()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.common.exception;
|
||||||
|
|
||||||
|
public class ResourceNotFoundException extends RuntimeException {
|
||||||
|
|
||||||
|
public ResourceNotFoundException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,15 @@ import org.springframework.context.annotation.Bean;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
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.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties(AppSecurityProperties.class)
|
@EnableConfigurationProperties({
|
||||||
|
AppSecurityProperties.class,
|
||||||
|
VpsSshProperties.class
|
||||||
|
})
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
private final AppSecurityProperties securityProperties;
|
private final AppSecurityProperties securityProperties;
|
||||||
@@ -24,9 +28,16 @@ public class SecurityConfig {
|
|||||||
|
|
||||||
return http
|
return http
|
||||||
.csrf(csrf -> csrf.disable())
|
.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
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/actuator/health").permitAll()
|
.requestMatchers("/actuator/health").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().permitAll()
|
||||||
)
|
)
|
||||||
.addFilterBefore(apiKeyAuthFilter, UsernamePasswordAuthenticationFilter.class)
|
.addFilterBefore(apiKeyAuthFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-4
@@ -1,9 +1,9 @@
|
|||||||
package com.litoralregas.vpnprovisioner.router.controller;
|
package com.litoralregas.vpnprovisioner.router.controller;
|
||||||
|
|
||||||
import com.litoralregas.vpnprovisioner.router.dto.CreateRouterRequest;
|
import com.litoralregas.vpnprovisioner.router.dto.*;
|
||||||
import com.litoralregas.vpnprovisioner.router.dto.RouterResponse;
|
|
||||||
import com.litoralregas.vpnprovisioner.router.dto.UpdateRouterRequest;
|
|
||||||
import com.litoralregas.vpnprovisioner.router.service.RouterService;
|
import com.litoralregas.vpnprovisioner.router.service.RouterService;
|
||||||
|
import com.litoralregas.vpnprovisioner.router.service.RouterSyncService;
|
||||||
|
import com.litoralregas.vpnprovisioner.router.service.RouterVpnProvisioningService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -16,9 +16,13 @@ import java.util.UUID;
|
|||||||
public class RouterController {
|
public class RouterController {
|
||||||
|
|
||||||
private final RouterService routerService;
|
private final RouterService routerService;
|
||||||
|
private final RouterVpnProvisioningService routerVpnProvisioningService;
|
||||||
|
private final RouterSyncService routerSyncService;
|
||||||
|
|
||||||
public RouterController(RouterService routerService) {
|
public RouterController(RouterService routerService, RouterVpnProvisioningService routerVpnProvisioningService, RouterSyncService routerSyncService) {
|
||||||
this.routerService = routerService;
|
this.routerService = routerService;
|
||||||
|
this.routerVpnProvisioningService = routerVpnProvisioningService;
|
||||||
|
this.routerSyncService = routerSyncService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@@ -50,4 +54,25 @@ public class RouterController {
|
|||||||
public void delete(@PathVariable UUID id) {
|
public void delete(@PathVariable UUID id) {
|
||||||
routerService.delete(id);
|
routerService.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/vpn-peer")
|
||||||
|
public RouterResponse provisionVpnPeer(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@Valid @RequestBody ProvisionRouterVpnRequest request
|
||||||
|
) {
|
||||||
|
return routerVpnProvisioningService.provision(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/sync-from-vps")
|
||||||
|
public SyncRoutersFromVpsResponse syncFromVps() {
|
||||||
|
return routerSyncService.syncFromVps();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/provisioning-result")
|
||||||
|
public RouterResponse submitProvisioningResult(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@Valid @RequestBody RouterProvisioningResultRequest request
|
||||||
|
) {
|
||||||
|
return routerService.submitProvisioningResult(id, request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.router.dto;
|
||||||
|
|
||||||
|
import com.litoralregas.vpnprovisioner.router.entity.EndpointMode;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record ProvisionRouterVpnRequest(
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
String publicKey,
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
EndpointMode endpointMode
|
||||||
|
) {
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.router.dto;
|
||||||
|
|
||||||
|
import com.litoralregas.vpnprovisioner.router.entity.RouterStatus;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record RouterProvisioningResultRequest(
|
||||||
|
@NotNull
|
||||||
|
Boolean success,
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
RouterStatus finalStatus,
|
||||||
|
|
||||||
|
String message,
|
||||||
|
String firmwareVersion,
|
||||||
|
String validationSummary,
|
||||||
|
String lastError
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.litoralregas.vpnprovisioner.router.dto;
|
package com.litoralregas.vpnprovisioner.router.dto;
|
||||||
|
|
||||||
|
import com.litoralregas.vpnprovisioner.router.entity.EndpointMode;
|
||||||
import com.litoralregas.vpnprovisioner.router.entity.RouterStatus;
|
import com.litoralregas.vpnprovisioner.router.entity.RouterStatus;
|
||||||
|
import com.litoralregas.vpnprovisioner.router.entity.RouterVpnStatus;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -12,7 +14,16 @@ public record RouterResponse(
|
|||||||
String firmwareVersion,
|
String firmwareVersion,
|
||||||
RouterStatus status,
|
RouterStatus status,
|
||||||
String vpnIp,
|
String vpnIp,
|
||||||
|
String wireguardPublicKey,
|
||||||
|
EndpointMode endpointMode,
|
||||||
|
RouterVpnStatus vpnStatus,
|
||||||
|
Instant vpnProvisionedAt,
|
||||||
Instant createdAt,
|
Instant createdAt,
|
||||||
Instant updatedAt
|
Instant updatedAt,
|
||||||
|
|
||||||
|
Instant lastProvisionedAt,
|
||||||
|
Instant lastValidatedAt,
|
||||||
|
String validationSummary,
|
||||||
|
String lastProvisioningError
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.router.dto;
|
||||||
|
|
||||||
|
public record SyncRoutersFromVpsResponse(
|
||||||
|
int seenOnVps,
|
||||||
|
int created,
|
||||||
|
int updated
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.router.entity;
|
||||||
|
|
||||||
|
public enum EndpointMode {
|
||||||
|
NORMAL_WIREGUARD,
|
||||||
|
UDP2RAW
|
||||||
|
}
|
||||||
@@ -25,12 +25,32 @@ public class Router {
|
|||||||
|
|
||||||
private String vpnIp;
|
private String vpnIp;
|
||||||
|
|
||||||
|
@Column(unique = true)
|
||||||
|
private String wireguardPublicKey;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
private EndpointMode endpointMode;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
private RouterVpnStatus vpnStatus;
|
||||||
|
|
||||||
|
private Instant vpnProvisionedAt;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private Instant updatedAt;
|
private Instant updatedAt;
|
||||||
|
|
||||||
|
private Instant lastProvisionedAt;
|
||||||
|
private Instant lastValidatedAt;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String validationSummary;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String lastProvisioningError;
|
||||||
|
|
||||||
protected Router() {
|
protected Router() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +60,7 @@ public class Router {
|
|||||||
this.hardwareModel = hardwareModel;
|
this.hardwareModel = hardwareModel;
|
||||||
this.firmwareVersion = firmwareVersion;
|
this.firmwareVersion = firmwareVersion;
|
||||||
this.status = RouterStatus.PENDING;
|
this.status = RouterStatus.PENDING;
|
||||||
|
this.vpnStatus = RouterVpnStatus.NOT_PROVISIONED;
|
||||||
this.createdAt = Instant.now();
|
this.createdAt = Instant.now();
|
||||||
this.updatedAt = Instant.now();
|
this.updatedAt = Instant.now();
|
||||||
}
|
}
|
||||||
@@ -49,18 +70,147 @@ public class Router {
|
|||||||
this.updatedAt = Instant.now();
|
this.updatedAt = Instant.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
public UUID getId() { return id; }
|
|
||||||
public String getName() { return name; }
|
|
||||||
public String getHardwareModel() { return hardwareModel; }
|
|
||||||
public String getFirmwareVersion() { return firmwareVersion; }
|
|
||||||
public RouterStatus getStatus() { return status; }
|
|
||||||
public String getVpnIp() { return vpnIp; }
|
|
||||||
public Instant getCreatedAt() { return createdAt; }
|
|
||||||
public Instant getUpdatedAt() { return updatedAt; }
|
|
||||||
|
|
||||||
public void updateDetails(String name, String hardwareModel, String firmwareVersion) {
|
public void updateDetails(String name, String hardwareModel, String firmwareVersion) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.hardwareModel = hardwareModel;
|
this.hardwareModel = hardwareModel;
|
||||||
this.firmwareVersion = firmwareVersion;
|
this.firmwareVersion = firmwareVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void assignVpnIp(String vpnIp) {
|
||||||
|
this.vpnIp = vpnIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markVpnApplying(String publicKey, String vpnIp, EndpointMode endpointMode) {
|
||||||
|
this.wireguardPublicKey = publicKey;
|
||||||
|
this.vpnIp = vpnIp;
|
||||||
|
this.endpointMode = endpointMode;
|
||||||
|
this.vpnStatus = RouterVpnStatus.APPLYING;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markVpnApplied() {
|
||||||
|
this.vpnStatus = RouterVpnStatus.APPLIED;
|
||||||
|
this.vpnProvisionedAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markVpnFailed() {
|
||||||
|
this.vpnStatus = RouterVpnStatus.FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasVpnPeer() {
|
||||||
|
return this.wireguardPublicKey != null && this.vpnIp != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getHardwareModel() {
|
||||||
|
return hardwareModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFirmwareVersion() {
|
||||||
|
return firmwareVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RouterStatus getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getVpnIp() {
|
||||||
|
return vpnIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getWireguardPublicKey() {
|
||||||
|
return wireguardPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EndpointMode getEndpointMode() {
|
||||||
|
return endpointMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RouterVpnStatus getVpnStatus() {
|
||||||
|
return vpnStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getVpnProvisionedAt() {
|
||||||
|
return vpnProvisionedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Router importedVpnRouter(String publicKey, String vpnIp) {
|
||||||
|
Router router = new Router(
|
||||||
|
"Imported Router " + vpnIp,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
router.wireguardPublicKey = publicKey;
|
||||||
|
router.vpnIp = vpnIp;
|
||||||
|
router.endpointMode = EndpointMode.UDP2RAW;
|
||||||
|
router.vpnStatus = RouterVpnStatus.APPLIED;
|
||||||
|
router.vpnProvisionedAt = Instant.now();
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void syncVpnPeer(String publicKey, String vpnIp) {
|
||||||
|
this.wireguardPublicKey = publicKey;
|
||||||
|
this.vpnIp = vpnIp;
|
||||||
|
this.endpointMode = EndpointMode.UDP2RAW;
|
||||||
|
this.vpnStatus = RouterVpnStatus.APPLIED;
|
||||||
|
|
||||||
|
if (this.vpnProvisionedAt == null) {
|
||||||
|
this.vpnProvisionedAt = Instant.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void registerProvisioningResult(
|
||||||
|
boolean success,
|
||||||
|
RouterStatus finalStatus,
|
||||||
|
String message,
|
||||||
|
String firmwareVersion,
|
||||||
|
String validationSummary,
|
||||||
|
String lastError
|
||||||
|
) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
|
||||||
|
this.status = finalStatus;
|
||||||
|
this.lastValidatedAt = now;
|
||||||
|
this.firmwareVersion = firmwareVersion;
|
||||||
|
this.validationSummary = validationSummary;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
this.lastProvisionedAt = now;
|
||||||
|
this.lastProvisioningError = null;
|
||||||
|
} else {
|
||||||
|
this.lastProvisioningError = lastError != null ? lastError : message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getLastProvisionedAt() {
|
||||||
|
return lastProvisionedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getLastValidatedAt() {
|
||||||
|
return lastValidatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getValidationSummary() {
|
||||||
|
return validationSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLastProvisioningError() {
|
||||||
|
return lastProvisioningError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.router.entity;
|
||||||
|
|
||||||
|
public enum RouterVpnStatus {
|
||||||
|
NOT_PROVISIONED,
|
||||||
|
APPLYING,
|
||||||
|
APPLIED,
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ package com.litoralregas.vpnprovisioner.router.repository;
|
|||||||
import com.litoralregas.vpnprovisioner.router.entity.Router;
|
import com.litoralregas.vpnprovisioner.router.entity.Router;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface RouterRepository extends JpaRepository<Router, UUID> {
|
public interface RouterRepository extends JpaRepository<Router, UUID> {
|
||||||
|
Optional<Router> findByWireguardPublicKey(String wireguardPublicKey);
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
package com.litoralregas.vpnprovisioner.router.service;
|
package com.litoralregas.vpnprovisioner.router.service;
|
||||||
|
|
||||||
import com.litoralregas.vpnprovisioner.router.dto.CreateRouterRequest;
|
import com.litoralregas.vpnprovisioner.router.dto.CreateRouterRequest;
|
||||||
|
import com.litoralregas.vpnprovisioner.router.dto.RouterProvisioningResultRequest;
|
||||||
import com.litoralregas.vpnprovisioner.router.dto.RouterResponse;
|
import com.litoralregas.vpnprovisioner.router.dto.RouterResponse;
|
||||||
import com.litoralregas.vpnprovisioner.router.dto.UpdateRouterRequest;
|
import com.litoralregas.vpnprovisioner.router.dto.UpdateRouterRequest;
|
||||||
import com.litoralregas.vpnprovisioner.router.entity.Router;
|
import com.litoralregas.vpnprovisioner.router.entity.Router;
|
||||||
import com.litoralregas.vpnprovisioner.router.repository.RouterRepository;
|
import com.litoralregas.vpnprovisioner.router.repository.RouterRepository;
|
||||||
|
import com.litoralregas.vpnprovisioner.common.exception.ResourceNotFoundException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -43,7 +46,7 @@ public class RouterService {
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public RouterResponse findById(UUID id) {
|
public RouterResponse findById(UUID id) {
|
||||||
Router router = routerRepository.findById(id)
|
Router router = routerRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Router not found: " + id));
|
.orElseThrow(() -> new ResourceNotFoundException("Router not found: " + id));
|
||||||
|
|
||||||
return toResponse(router);
|
return toResponse(router);
|
||||||
}
|
}
|
||||||
@@ -51,7 +54,7 @@ public class RouterService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public RouterResponse update(UUID id, UpdateRouterRequest request) {
|
public RouterResponse update(UUID id, UpdateRouterRequest request) {
|
||||||
Router router = routerRepository.findById(id)
|
Router router = routerRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Router not found: " + id));
|
.orElseThrow(() -> new ResourceNotFoundException("Router not found: " + id));
|
||||||
|
|
||||||
router.updateDetails(
|
router.updateDetails(
|
||||||
request.name(),
|
request.name(),
|
||||||
@@ -65,7 +68,7 @@ public class RouterService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void delete(UUID id) {
|
public void delete(UUID id) {
|
||||||
Router router = routerRepository.findById(id)
|
Router router = routerRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Router not found: " + id));
|
.orElseThrow(() -> new ResourceNotFoundException("Router not found: " + id));
|
||||||
|
|
||||||
routerRepository.delete(router);
|
routerRepository.delete(router);
|
||||||
}
|
}
|
||||||
@@ -78,8 +81,37 @@ public class RouterService {
|
|||||||
router.getFirmwareVersion(),
|
router.getFirmwareVersion(),
|
||||||
router.getStatus(),
|
router.getStatus(),
|
||||||
router.getVpnIp(),
|
router.getVpnIp(),
|
||||||
|
router.getWireguardPublicKey(),
|
||||||
|
router.getEndpointMode(),
|
||||||
|
router.getVpnStatus(),
|
||||||
|
router.getVpnProvisionedAt(),
|
||||||
router.getCreatedAt(),
|
router.getCreatedAt(),
|
||||||
router.getUpdatedAt()
|
router.getUpdatedAt(),
|
||||||
|
|
||||||
|
router.getLastProvisionedAt(),
|
||||||
|
router.getLastValidatedAt(),
|
||||||
|
router.getValidationSummary(),
|
||||||
|
router.getLastProvisioningError()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public RouterResponse submitProvisioningResult(
|
||||||
|
UUID id,
|
||||||
|
RouterProvisioningResultRequest request
|
||||||
|
) {
|
||||||
|
Router router = routerRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Router not found: " + id));
|
||||||
|
|
||||||
|
router.registerProvisioningResult(
|
||||||
|
request.success(),
|
||||||
|
request.finalStatus(),
|
||||||
|
request.message(),
|
||||||
|
request.firmwareVersion(),
|
||||||
|
request.validationSummary(),
|
||||||
|
request.lastError()
|
||||||
|
);
|
||||||
|
|
||||||
|
return toResponse(router);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.router.service;
|
||||||
|
|
||||||
|
import com.litoralregas.vpnprovisioner.router.dto.SyncRoutersFromVpsResponse;
|
||||||
|
import com.litoralregas.vpnprovisioner.router.entity.Router;
|
||||||
|
import com.litoralregas.vpnprovisioner.router.repository.RouterRepository;
|
||||||
|
import com.litoralregas.vpnprovisioner.vps.WireGuardVpsService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class RouterSyncService {
|
||||||
|
|
||||||
|
private static final Pattern LINE_PATTERN =
|
||||||
|
Pattern.compile("^(.+?)\\s+([0-9.]+/32)$");
|
||||||
|
|
||||||
|
private final WireGuardVpsService wireGuardVpsService;
|
||||||
|
private final RouterRepository routerRepository;
|
||||||
|
|
||||||
|
public RouterSyncService(
|
||||||
|
WireGuardVpsService wireGuardVpsService,
|
||||||
|
RouterRepository routerRepository
|
||||||
|
) {
|
||||||
|
this.wireGuardVpsService = wireGuardVpsService;
|
||||||
|
this.routerRepository = routerRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public SyncRoutersFromVpsResponse syncFromVps() {
|
||||||
|
|
||||||
|
String output = wireGuardVpsService.showAllowedIps();
|
||||||
|
|
||||||
|
List<String> lines = output.lines()
|
||||||
|
.filter(line -> !line.isBlank())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
int created = 0;
|
||||||
|
int updated = 0;
|
||||||
|
|
||||||
|
for (String line : lines) {
|
||||||
|
|
||||||
|
Matcher matcher = LINE_PATTERN.matcher(line.trim());
|
||||||
|
|
||||||
|
if (!matcher.matches()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String publicKey = matcher.group(1).trim();
|
||||||
|
String allowedIp = matcher.group(2).trim();
|
||||||
|
|
||||||
|
String vpnIp = allowedIp.replace("/32", "");
|
||||||
|
|
||||||
|
Router router = routerRepository
|
||||||
|
.findByWireguardPublicKey(publicKey)
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (router == null) {
|
||||||
|
|
||||||
|
Router imported = Router.importedVpnRouter(
|
||||||
|
publicKey,
|
||||||
|
vpnIp
|
||||||
|
);
|
||||||
|
|
||||||
|
routerRepository.save(imported);
|
||||||
|
|
||||||
|
created++;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
router.syncVpnPeer(publicKey, vpnIp);
|
||||||
|
|
||||||
|
routerRepository.save(router);
|
||||||
|
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SyncRoutersFromVpsResponse(
|
||||||
|
lines.size(),
|
||||||
|
created,
|
||||||
|
updated
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.router.service;
|
||||||
|
|
||||||
|
import com.litoralregas.vpnprovisioner.common.exception.ResourceNotFoundException;
|
||||||
|
import com.litoralregas.vpnprovisioner.router.dto.ProvisionRouterVpnRequest;
|
||||||
|
import com.litoralregas.vpnprovisioner.router.dto.RouterResponse;
|
||||||
|
import com.litoralregas.vpnprovisioner.router.entity.Router;
|
||||||
|
import com.litoralregas.vpnprovisioner.router.repository.RouterRepository;
|
||||||
|
import com.litoralregas.vpnprovisioner.vps.WireGuardVpsService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class RouterVpnProvisioningService {
|
||||||
|
|
||||||
|
private final RouterRepository routerRepository;
|
||||||
|
private final WireGuardVpsService wireGuardVpsService;
|
||||||
|
|
||||||
|
public RouterVpnProvisioningService(
|
||||||
|
RouterRepository routerRepository,
|
||||||
|
WireGuardVpsService wireGuardVpsService
|
||||||
|
) {
|
||||||
|
this.routerRepository = routerRepository;
|
||||||
|
this.wireGuardVpsService = wireGuardVpsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public RouterResponse provision(UUID routerId, ProvisionRouterVpnRequest request) {
|
||||||
|
|
||||||
|
Router router = routerRepository.findById(routerId)
|
||||||
|
.orElseThrow(() ->
|
||||||
|
new ResourceNotFoundException("Router not found: " + routerId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (router.hasVpnPeer()) {
|
||||||
|
throw new IllegalStateException("Router already has VPN provisioned");
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> usedIps = wireGuardVpsService.findUsedVpnIps();
|
||||||
|
|
||||||
|
String vpnIp = findNextAvailableIp(usedIps);
|
||||||
|
|
||||||
|
router.markVpnApplying(
|
||||||
|
request.publicKey(),
|
||||||
|
vpnIp,
|
||||||
|
request.endpointMode()
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
wireGuardVpsService.applyPeer(
|
||||||
|
request.publicKey(),
|
||||||
|
vpnIp + "/32"
|
||||||
|
);
|
||||||
|
|
||||||
|
router.markVpnApplied();
|
||||||
|
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
|
||||||
|
router.markVpnFailed();
|
||||||
|
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
return toResponse(router);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String findNextAvailableIp(Set<String> usedIps) {
|
||||||
|
|
||||||
|
for (int thirdOctet = 1; thirdOctet <= 255; thirdOctet++) {
|
||||||
|
|
||||||
|
for (int fourthOctet = 2; fourthOctet <= 254; fourthOctet++) {
|
||||||
|
|
||||||
|
String ip = "198.19." + thirdOctet + "." + fourthOctet;
|
||||||
|
|
||||||
|
if (!usedIps.contains(ip)) {
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalStateException("No VPN IPs available");
|
||||||
|
}
|
||||||
|
|
||||||
|
private RouterResponse toResponse(Router router) {
|
||||||
|
return new RouterResponse(
|
||||||
|
router.getId(),
|
||||||
|
router.getName(),
|
||||||
|
router.getHardwareModel(),
|
||||||
|
router.getFirmwareVersion(),
|
||||||
|
router.getStatus(),
|
||||||
|
router.getVpnIp(),
|
||||||
|
router.getWireguardPublicKey(),
|
||||||
|
router.getEndpointMode(),
|
||||||
|
router.getVpnStatus(),
|
||||||
|
router.getVpnProvisionedAt(),
|
||||||
|
router.getCreatedAt(),
|
||||||
|
router.getUpdatedAt(),
|
||||||
|
|
||||||
|
router.getLastProvisionedAt(),
|
||||||
|
router.getLastValidatedAt(),
|
||||||
|
router.getValidationSummary(),
|
||||||
|
router.getLastProvisioningError()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.router.startup;
|
||||||
|
|
||||||
|
import com.litoralregas.vpnprovisioner.router.service.RouterSyncService;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class RouterStartupSync implements ApplicationRunner {
|
||||||
|
|
||||||
|
private final RouterSyncService routerSyncService;
|
||||||
|
|
||||||
|
public RouterStartupSync(RouterSyncService routerSyncService) {
|
||||||
|
this.routerSyncService = routerSyncService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
|
||||||
|
routerSyncService.syncFromVps();
|
||||||
|
|
||||||
|
System.out.println("Router VPS sync completed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.vps;
|
||||||
|
|
||||||
|
public class SshCommandException extends RuntimeException {
|
||||||
|
|
||||||
|
public SshCommandException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SshCommandException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.vps;
|
||||||
|
|
||||||
|
public record SshCommandResult(
|
||||||
|
int exitCode,
|
||||||
|
String stdout,
|
||||||
|
String stderr
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.vps;
|
||||||
|
|
||||||
|
import com.jcraft.jsch.ChannelExec;
|
||||||
|
import com.jcraft.jsch.JSch;
|
||||||
|
import com.jcraft.jsch.Session;
|
||||||
|
import com.litoralregas.vpnprovisioner.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.vps;
|
||||||
|
|
||||||
|
import com.litoralregas.vpnprovisioner.vps.dto.VpsHealthResponse;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/vps")
|
||||||
|
public class VpsController {
|
||||||
|
|
||||||
|
private final WireGuardVpsService wireGuardVpsService;
|
||||||
|
|
||||||
|
public VpsController(WireGuardVpsService wireGuardVpsService) {
|
||||||
|
this.wireGuardVpsService = wireGuardVpsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(value = "/health", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
public VpsHealthResponse health() {
|
||||||
|
return wireGuardVpsService.getVpsHealth();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(
|
||||||
|
value = "/wireguard/rollback-last-backup",
|
||||||
|
produces = MediaType.APPLICATION_JSON_VALUE
|
||||||
|
)
|
||||||
|
public String rollbackLastBackup() {
|
||||||
|
return wireGuardVpsService.restoreLastWireGuardBackup();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.vps;
|
||||||
|
|
||||||
|
public record WireGuardPeerApplyResult(
|
||||||
|
String publicKey,
|
||||||
|
String allowedIps,
|
||||||
|
boolean applied
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.vps;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.litoralregas.vpnprovisioner.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 WireGuardVpsService {
|
||||||
|
|
||||||
|
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 WireGuardVpsService(SshService sshService, ObjectMapper objectMapper) {
|
||||||
|
this.sshService = sshService;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> 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<String> parseVpnIps(String output) {
|
||||||
|
Set<String> 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 '%s' '%s'
|
||||||
|
""".formatted(publicKey, allowedIps);
|
||||||
|
|
||||||
|
SshCommandResult result = sshService.executeOnConfiguredVps(command);
|
||||||
|
|
||||||
|
if (result.exitCode() != 0) {
|
||||||
|
throw new SshCommandException(
|
||||||
|
"Failed to apply WireGuard peer: " + result.stderr()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WireGuardPeerApplyResult(
|
||||||
|
publicKey,
|
||||||
|
allowedIps,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.litoralregas.vpnprovisioner.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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -4,8 +4,19 @@ server:
|
|||||||
app:
|
app:
|
||||||
security:
|
security:
|
||||||
api-key: ${APP_API_KEY:dev-api-key}
|
api-key: ${APP_API_KEY:dev-api-key}
|
||||||
|
vps:
|
||||||
|
ssh:
|
||||||
|
host: ${VPS_SSH_HOST:}
|
||||||
|
port: ${VPS_SSH_PORT:22}
|
||||||
|
username: ${VPS_SSH_USERNAME:root}
|
||||||
|
password: ${VPS_SSH_PASSWORD:}
|
||||||
|
private-key-path: ${VPS_SSH_PRIVATE_KEY_PATH:}
|
||||||
|
connect-timeout-ms: ${VPS_SSH_CONNECT_TIMEOUT_MS:10000}
|
||||||
|
command-timeout-ms: ${VPS_SSH_COMMAND_TIMEOUT_MS:15000}
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
|
config:
|
||||||
|
import: optional:file:.env[.properties]
|
||||||
datasource:
|
datasource:
|
||||||
url: ${DB_URL:jdbc:postgresql://localhost:5432/vpn_provisioner}
|
url: ${DB_URL:jdbc:postgresql://localhost:5432/vpn_provisioner}
|
||||||
username: ${DB_USER:vpn}
|
username: ${DB_USER:vpn}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE vpn_ip_allocations (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
ip_address VARCHAR(45) NOT NULL UNIQUE,
|
||||||
|
allocated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
router_id UUID NULL,
|
||||||
|
allocated_at TIMESTAMP NULL,
|
||||||
|
released_at TIMESTAMP NULL,
|
||||||
|
|
||||||
|
CONSTRAINT fk_vpn_ip_allocations_router
|
||||||
|
FOREIGN KEY (router_id)
|
||||||
|
REFERENCES routers(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_vpn_ip_allocations_allocated
|
||||||
|
ON vpn_ip_allocations(allocated);
|
||||||
|
|
||||||
|
CREATE INDEX idx_vpn_ip_allocations_router_id
|
||||||
|
ON vpn_ip_allocations(router_id);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE vpn_ip_allocations
|
||||||
|
ADD COLUMN status VARCHAR(50);
|
||||||
|
|
||||||
|
UPDATE vpn_ip_allocations
|
||||||
|
SET status = CASE
|
||||||
|
WHEN allocated = true THEN 'ALLOCATED'
|
||||||
|
ELSE 'AVAILABLE'
|
||||||
|
END;
|
||||||
|
|
||||||
|
ALTER TABLE vpn_ip_allocations
|
||||||
|
ALTER COLUMN status SET NOT NULL;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE wireguard_peers (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
router_id UUID NOT NULL UNIQUE,
|
||||||
|
public_key VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
vpn_ip VARCHAR(45) NOT NULL,
|
||||||
|
allowed_ips VARCHAR(100) NOT NULL,
|
||||||
|
endpoint_mode VARCHAR(50) NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT fk_wireguard_peers_router
|
||||||
|
FOREIGN KEY (router_id)
|
||||||
|
REFERENCES routers(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_wireguard_peers_status
|
||||||
|
ON wireguard_peers(status);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE routers
|
||||||
|
ADD COLUMN wireguard_public_key VARCHAR(255),
|
||||||
|
ADD COLUMN endpoint_mode VARCHAR(50),
|
||||||
|
ADD COLUMN vpn_status VARCHAR(50),
|
||||||
|
ADD COLUMN vpn_provisioned_at TIMESTAMP;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_routers_wireguard_public_key
|
||||||
|
ON routers(wireguard_public_key)
|
||||||
|
WHERE wireguard_public_key IS NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS wireguard_peers;
|
||||||
|
DROP TABLE IF EXISTS vpn_ip_allocations;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE routers
|
||||||
|
ADD COLUMN last_provisioned_at TIMESTAMP,
|
||||||
|
ADD COLUMN last_validated_at TIMESTAMP,
|
||||||
|
ADD COLUMN validation_summary TEXT,
|
||||||
|
ADD COLUMN last_provisioning_error TEXT;
|
||||||
Reference in New Issue
Block a user