Compare commits

..

9 Commits

Author SHA1 Message Date
litoral05 af55458ad4 Add router provisioning result endpoint 2026-05-07 17:32:52 +01:00
litoral05 6712a26b2a Improve VPS health response DTO and metrics 2026-05-07 17:12:04 +01:00
litoral05 ad9a053099 Add WireGuard VPS sync bootstrap flow 2026-05-07 16:39:25 +01:00
litoral05 fe2faa4784 Move VPN provisioning onto router model 2026-05-07 16:05:32 +01:00
litoral05 66adf1b42b Add VPS health endpoint 2026-05-07 15:38:47 +01:00
litoral05 8b849e6560 Apply WireGuard peers to VPS safely 2026-05-07 15:10:09 +01:00
litoral05 f93a4c8402 Use VPS as source of truth for VPN IP allocation 2026-05-07 14:42:16 +01:00
litoral05 c88ef29449 Add VPS-aware VPN IP allocation 2026-05-07 14:17:40 +01:00
litoral05 a09587950e Add global exception handling 2026-05-07 13:37:41 +01:00
34 changed files with 1100 additions and 21 deletions
+5
View File
@@ -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
) {
}
@@ -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()
);
}
}
@@ -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;
}
}
@@ -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);
}
} }
@@ -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
) {
}
@@ -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
) { ) {
} }
@@ -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
);
}
}
@@ -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
) {
}
+11
View File
@@ -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;