Compare commits

..

7 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
30 changed files with 694 additions and 291 deletions
@@ -6,6 +6,7 @@ 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;
@@ -58,7 +59,7 @@ public class ApiKeyAuthFilter extends OncePerRequestFilter {
new UsernamePasswordAuthenticationToken(
"api-key-client",
null,
List.of()
List.of(new SimpleGrantedAuthority("ROLE_API"))
);
SecurityContextHolder.getContext().setAuthentication(authentication);
@@ -5,6 +5,7 @@ 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;
@@ -27,11 +28,16 @@ public class SecurityConfig {
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()
.anyRequest().permitAll()
)
.addFilterBefore(apiKeyAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
@@ -1,30 +0,0 @@
package com.litoralregas.vpnprovisioner.ipam.controller;
import com.litoralregas.vpnprovisioner.ipam.dto.VpnIpAllocationResponse;
import com.litoralregas.vpnprovisioner.ipam.service.VpnIpAllocationService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@RestController
@RequestMapping("/api/ipam")
public class VpnIpAllocationController {
private final VpnIpAllocationService service;
public VpnIpAllocationController(VpnIpAllocationService service) {
this.service = service;
}
@PostMapping("/routers/{routerId}/allocate")
public VpnIpAllocationResponse allocate(@PathVariable UUID routerId) {
return service.allocateForRouter(routerId);
}
@PostMapping("/routers/{routerId}/release")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void release(@PathVariable UUID routerId) {
service.releaseForRouter(routerId);
}
}
@@ -1,14 +0,0 @@
package com.litoralregas.vpnprovisioner.ipam.dto;
import java.time.Instant;
import java.util.UUID;
public record VpnIpAllocationResponse(
UUID id,
String ipAddress,
boolean allocated,
UUID routerId,
Instant allocatedAt,
Instant releasedAt
) {
}
@@ -1,72 +0,0 @@
package com.litoralregas.vpnprovisioner.ipam.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "vpn_ip_allocations")
public class VpnIpAllocation {
@Id
private UUID id;
@Column(nullable = false, unique = true)
private String ipAddress;
@Column(nullable = false)
private boolean allocated;
private UUID routerId;
private Instant allocatedAt;
private Instant releasedAt;
protected VpnIpAllocation() {
}
public VpnIpAllocation(String ipAddress) {
this.id = UUID.randomUUID();
this.ipAddress = ipAddress;
this.allocated = false;
}
public void allocateTo(UUID routerId) {
this.allocated = true;
this.routerId = routerId;
this.allocatedAt = Instant.now();
this.releasedAt = null;
}
public void release() {
this.allocated = false;
this.routerId = null;
this.releasedAt = Instant.now();
}
public UUID getId() {
return id;
}
public String getIpAddress() {
return ipAddress;
}
public boolean isAllocated() {
return allocated;
}
public UUID getRouterId() {
return routerId;
}
public Instant getAllocatedAt() {
return allocatedAt;
}
public Instant getReleasedAt() {
return releasedAt;
}
}
@@ -1,51 +0,0 @@
package com.litoralregas.vpnprovisioner.ipam.repository;
import com.litoralregas.vpnprovisioner.ipam.entity.VpnIpAllocation;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface VpnIpAllocationRepository extends JpaRepository<VpnIpAllocation, UUID> {
Optional<VpnIpAllocation> findByRouterId(UUID routerId);
@Query(
value = """
SELECT *
FROM vpn_ip_allocations
WHERE allocated = false
ORDER BY ip_address
LIMIT 1
FOR UPDATE SKIP LOCKED
""",
nativeQuery = true
)
Optional<VpnIpAllocation> findNextFreeForUpdate();
@Modifying
@Query("""
UPDATE VpnIpAllocation ip
SET ip.allocated = false,
ip.routerId = null,
ip.releasedAt = CURRENT_TIMESTAMP
WHERE ip.routerId = :routerId
""")
void releaseByRouterId(@Param("routerId") UUID routerId);
@Query(
value = """
SELECT *
FROM vpn_ip_allocations
WHERE allocated = false
ORDER BY ip_address
FOR UPDATE SKIP LOCKED
""",
nativeQuery = true
)
List<VpnIpAllocation> findFreeCandidatesForUpdate();
Optional<VpnIpAllocation> findByIpAddress(String ipAddress);
}
@@ -1,73 +0,0 @@
package com.litoralregas.vpnprovisioner.ipam.service;
import com.litoralregas.vpnprovisioner.common.exception.ResourceNotFoundException;
import com.litoralregas.vpnprovisioner.ipam.dto.VpnIpAllocationResponse;
import com.litoralregas.vpnprovisioner.ipam.entity.VpnIpAllocation;
import com.litoralregas.vpnprovisioner.ipam.repository.VpnIpAllocationRepository;
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.Set;
import java.util.UUID;
@Service
public class VpnIpAllocationService {
private final VpnIpAllocationRepository vpnIpAllocationRepository;
private final RouterRepository routerRepository;
private final WireGuardVpsService wireGuardVpsService;
public VpnIpAllocationService(
VpnIpAllocationRepository vpnIpAllocationRepository,
RouterRepository routerRepository,
WireGuardVpsService wireGuardVpsService) {
this.vpnIpAllocationRepository = vpnIpAllocationRepository;
this.routerRepository = routerRepository;
this.wireGuardVpsService = wireGuardVpsService;
}
@Transactional
public VpnIpAllocationResponse allocateForRouter(UUID routerId) {
Router router = routerRepository.findById(routerId)
.orElseThrow(() -> new ResourceNotFoundException("Router not found: " + routerId));
return vpnIpAllocationRepository.findByRouterId(routerId)
.map(this::toResponse)
.orElseGet(() -> allocateNewIp(router));
}
@Transactional
public void releaseForRouter(UUID routerId) {
vpnIpAllocationRepository.releaseByRouterId(routerId);
}
private VpnIpAllocationResponse allocateNewIp(Router router) {
Set<String> usedIps = wireGuardVpsService.findUsedVpnIps();
VpnIpAllocation allocation = vpnIpAllocationRepository
.findFreeCandidatesForUpdate()
.stream()
.filter(ip -> !usedIps.contains(ip.getIpAddress()))
.findFirst()
.orElseThrow(() -> new IllegalStateException("No free VPN IP addresses available"));
allocation.allocateTo(router.getId());
return toResponse(allocation);
}
private VpnIpAllocationResponse toResponse(VpnIpAllocation allocation) {
return new VpnIpAllocationResponse(
allocation.getId(),
allocation.getIpAddress(),
allocation.isAllocated(),
allocation.getRouterId(),
allocation.getAllocatedAt(),
allocation.getReleasedAt()
);
}
}
@@ -1,32 +0,0 @@
package com.litoralregas.vpnprovisioner.ipam.service;
import com.litoralregas.vpnprovisioner.ipam.entity.VpnIpAllocation;
import com.litoralregas.vpnprovisioner.ipam.repository.VpnIpAllocationRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class VpnIpPoolSeeder implements CommandLineRunner {
private final VpnIpAllocationRepository repository;
public VpnIpPoolSeeder(VpnIpAllocationRepository repository) {
this.repository = repository;
}
@Override
public void run(String... args) {
for (int thirdOctet = 1; thirdOctet <= 1; thirdOctet++) {
for (int fourthOctet = 2; fourthOctet <= 254; fourthOctet++) {
String ip = "198.19." + thirdOctet + "." + fourthOctet;
if (repository.findByIpAddress(ip).isEmpty()) {
repository.save(new VpnIpAllocation(ip));
}
}
}
}
}
@@ -1,9 +1,9 @@
package com.litoralregas.vpnprovisioner.router.controller;
import com.litoralregas.vpnprovisioner.router.dto.CreateRouterRequest;
import com.litoralregas.vpnprovisioner.router.dto.RouterResponse;
import com.litoralregas.vpnprovisioner.router.dto.UpdateRouterRequest;
import com.litoralregas.vpnprovisioner.router.dto.*;
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 org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@@ -16,9 +16,13 @@ import java.util.UUID;
public class RouterController {
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.routerVpnProvisioningService = routerVpnProvisioningService;
this.routerSyncService = routerSyncService;
}
@PostMapping
@@ -50,4 +54,25 @@ public class RouterController {
public void delete(@PathVariable UUID 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;
import com.litoralregas.vpnprovisioner.router.entity.EndpointMode;
import com.litoralregas.vpnprovisioner.router.entity.RouterStatus;
import com.litoralregas.vpnprovisioner.router.entity.RouterVpnStatus;
import java.time.Instant;
import java.util.UUID;
@@ -12,7 +14,16 @@ public record RouterResponse(
String firmwareVersion,
RouterStatus status,
String vpnIp,
String wireguardPublicKey,
EndpointMode endpointMode,
RouterVpnStatus vpnStatus,
Instant vpnProvisionedAt,
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;
@Column(unique = true)
private String wireguardPublicKey;
@Enumerated(EnumType.STRING)
private EndpointMode endpointMode;
@Enumerated(EnumType.STRING)
private RouterVpnStatus vpnStatus;
private Instant vpnProvisionedAt;
@Column(nullable = false)
private Instant createdAt;
@Column(nullable = false)
private Instant updatedAt;
private Instant lastProvisionedAt;
private Instant lastValidatedAt;
@Column(columnDefinition = "TEXT")
private String validationSummary;
@Column(columnDefinition = "TEXT")
private String lastProvisioningError;
protected Router() {
}
@@ -40,6 +60,7 @@ public class Router {
this.hardwareModel = hardwareModel;
this.firmwareVersion = firmwareVersion;
this.status = RouterStatus.PENDING;
this.vpnStatus = RouterVpnStatus.NOT_PROVISIONED;
this.createdAt = Instant.now();
this.updatedAt = Instant.now();
}
@@ -49,18 +70,147 @@ public class Router {
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) {
this.name = name;
this.hardwareModel = hardwareModel;
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 org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface RouterRepository extends JpaRepository<Router, UUID> {
Optional<Router> findByWireguardPublicKey(String wireguardPublicKey);
}
@@ -1,6 +1,7 @@
package com.litoralregas.vpnprovisioner.router.service;
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.UpdateRouterRequest;
import com.litoralregas.vpnprovisioner.router.entity.Router;
@@ -9,6 +10,7 @@ import com.litoralregas.vpnprovisioner.common.exception.ResourceNotFoundExceptio
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@@ -79,8 +81,37 @@ public class RouterService {
router.getFirmwareVersion(),
router.getStatus(),
router.getVpnIp(),
router.getWireguardPublicKey(),
router.getEndpointMode(),
router.getVpnStatus(),
router.getVpnProvisionedAt(),
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,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
) {
}
@@ -1,5 +1,8 @@
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;
@@ -14,14 +17,16 @@ public class WireGuardVpsService {
Pattern.compile("\\b198\\.19\\.\\d{1,3}\\.\\d{1,3}\\b");
private final SshService sshService;
private final ObjectMapper objectMapper;
public WireGuardVpsService(SshService sshService) {
public WireGuardVpsService(SshService sshService, ObjectMapper objectMapper) {
this.sshService = sshService;
this.objectMapper = objectMapper;
}
public Set<String> findUsedVpnIps() {
SshCommandResult result = sshService.executeOnConfiguredVps(
"sudo wg show wg0 allowed-ips"
"sudo /usr/local/sbin/lr-wg-used-ips"
);
if (result.exitCode() != 0) {
@@ -44,4 +49,74 @@ public class WireGuardVpsService {
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
) {
}
@@ -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;