Add WireGuard VPS sync bootstrap flow

This commit is contained in:
litoral05
2026-05-07 16:39:25 +01:00
parent fe2faa4784
commit ad9a053099
22 changed files with 174 additions and 544 deletions
@@ -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,16 +0,0 @@
package com.litoralregas.vpnprovisioner.ipam.dto;
import com.litoralregas.vpnprovisioner.ipam.entity.VpnIpAllocationStatus;
import java.time.Instant;
import java.util.UUID;
public record VpnIpAllocationResponse(
UUID id,
String ipAddress,
VpnIpAllocationStatus status,
UUID routerId,
Instant allocatedAt,
Instant releasedAt
) {
}
@@ -1,87 +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;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private VpnIpAllocationStatus status;
private UUID routerId;
private Instant allocatedAt;
private Instant releasedAt;
protected VpnIpAllocation() {
}
public VpnIpAllocation(String ipAddress) {
this.id = UUID.randomUUID();
this.ipAddress = ipAddress;
this.status = VpnIpAllocationStatus.ALLOCATED_EXTERNAL;
this.allocatedAt = Instant.now();
}
public static VpnIpAllocation backendAllocated(String ipAddress, UUID routerId) {
VpnIpAllocation allocation = new VpnIpAllocation(ipAddress);
allocation.allocateTo(routerId);
return allocation;
}
public void allocateTo(UUID routerId) {
this.status = VpnIpAllocationStatus.ALLOCATED;
this.routerId = routerId;
this.allocatedAt = Instant.now();
this.releasedAt = null;
}
public void markExternal() {
if (this.status != VpnIpAllocationStatus.ALLOCATED) {
this.status = VpnIpAllocationStatus.ALLOCATED_EXTERNAL;
this.allocatedAt = this.allocatedAt == null ? Instant.now() : this.allocatedAt;
}
}
public void release() {
this.status = VpnIpAllocationStatus.RELEASED;
this.routerId = null;
this.releasedAt = Instant.now();
}
public UUID getId() {
return id;
}
public String getIpAddress() {
return ipAddress;
}
public VpnIpAllocationStatus getStatus() {
return status;
}
public UUID getRouterId() {
return routerId;
}
public Instant getAllocatedAt() {
return allocatedAt;
}
public Instant getReleasedAt() {
return releasedAt;
}
}
@@ -1,8 +0,0 @@
package com.litoralregas.vpnprovisioner.ipam.entity;
public enum VpnIpAllocationStatus {
ALLOCATED,
ALLOCATED_EXTERNAL,
RELEASED,
RETIRED
}
@@ -1,25 +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.Optional;
import java.util.UUID;
public interface VpnIpAllocationRepository extends JpaRepository<VpnIpAllocation, UUID> {
Optional<VpnIpAllocation> findByRouterId(UUID routerId);
Optional<VpnIpAllocation> findByIpAddress(String ipAddress);
@Modifying
@Query("""
UPDATE VpnIpAllocation ip
SET ip.status = 'RELEASED',
ip.routerId = null,
ip.releasedAt = CURRENT_TIMESTAMP
WHERE ip.routerId = :routerId
""")
void releaseByRouterId(@Param("routerId") UUID routerId);
}
@@ -1,111 +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.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
public class VpnIpAllocationService {
private static final int START_THIRD_OCTET = 1;
private static final int END_THIRD_OCTET = 255;
private static final int START_FOURTH_OCTET = 1;
private static final int END_FOURTH_OCTET = 254;
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> usedOnVps = refreshUsedIpsFromVps();
Set<String> knownInDb = vpnIpAllocationRepository.findAll()
.stream()
.map(VpnIpAllocation::getIpAddress)
.collect(Collectors.toSet());
String nextIp = findNextAvailableIp(usedOnVps, knownInDb);
VpnIpAllocation allocation = VpnIpAllocation.backendAllocated(nextIp, router.getId());
VpnIpAllocation saved = vpnIpAllocationRepository.save(allocation);
router.assignVpnIp(saved.getIpAddress());
return toResponse(saved);
}
private Set<String> refreshUsedIpsFromVps() {
Set<String> usedIps = wireGuardVpsService.findUsedVpnIps();
for (String ip : usedIps) {
vpnIpAllocationRepository.findByIpAddress(ip)
.ifPresentOrElse(
VpnIpAllocation::markExternal,
() -> vpnIpAllocationRepository.save(new VpnIpAllocation(ip))
);
}
return usedIps;
}
private String findNextAvailableIp(Set<String> usedOnVps, Set<String> knownInDb) {
for (int thirdOctet = START_THIRD_OCTET; thirdOctet <= END_THIRD_OCTET; thirdOctet++) {
for (int fourthOctet = START_FOURTH_OCTET; fourthOctet <= END_FOURTH_OCTET; fourthOctet++) {
String ip = "198.19." + thirdOctet + "." + fourthOctet;
if (!usedOnVps.contains(ip) && !knownInDb.contains(ip)) {
return ip;
}
}
}
throw new IllegalStateException("No free VPN IP addresses available");
}
private VpnIpAllocationResponse toResponse(VpnIpAllocation allocation) {
return new VpnIpAllocationResponse(
allocation.getId(),
allocation.getIpAddress(),
allocation.getStatus(),
allocation.getRouterId(),
allocation.getAllocatedAt(),
allocation.getReleasedAt()
);
}
}
@@ -1,10 +1,8 @@
package com.litoralregas.vpnprovisioner.router.controller;
import com.litoralregas.vpnprovisioner.router.dto.CreateRouterRequest;
import com.litoralregas.vpnprovisioner.router.dto.ProvisionRouterVpnRequest;
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;
@@ -19,10 +17,12 @@ public class RouterController {
private final RouterService routerService;
private final RouterVpnProvisioningService routerVpnProvisioningService;
private final RouterSyncService routerSyncService;
public RouterController(RouterService routerService, RouterVpnProvisioningService routerVpnProvisioningService) {
public RouterController(RouterService routerService, RouterVpnProvisioningService routerVpnProvisioningService, RouterSyncService routerSyncService) {
this.routerService = routerService;
this.routerVpnProvisioningService = routerVpnProvisioningService;
this.routerSyncService = routerSyncService;
}
@PostMapping
@@ -62,4 +62,9 @@ public class RouterController {
) {
return routerVpnProvisioningService.provision(id, request);
}
@PostMapping("/sync-from-vps")
public SyncRoutersFromVpsResponse syncFromVps() {
return routerSyncService.syncFromVps();
}
}
@@ -0,0 +1,8 @@
package com.litoralregas.vpnprovisioner.router.dto;
public record SyncRoutersFromVpsResponse(
int seenOnVps,
int created,
int updated
) {
}
@@ -138,4 +138,31 @@ public class Router {
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();
}
}
}
@@ -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);
}
@@ -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,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.");
}
}
@@ -93,4 +93,18 @@ public class WireGuardVpsService {
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();
}
}
@@ -1,30 +0,0 @@
package com.litoralregas.vpnprovisioner.wireguard.controller;
import com.litoralregas.vpnprovisioner.wireguard.dto.CreateWireGuardPeerRequest;
import com.litoralregas.vpnprovisioner.wireguard.dto.WireGuardPeerResponse;
import com.litoralregas.vpnprovisioner.wireguard.service.WireGuardPeerService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@RestController
@RequestMapping("/api/routers/{routerId}/wireguard-peer")
public class WireGuardPeerController {
private final WireGuardPeerService wireGuardPeerService;
public WireGuardPeerController(WireGuardPeerService wireGuardPeerService) {
this.wireGuardPeerService = wireGuardPeerService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public WireGuardPeerResponse create(
@PathVariable UUID routerId,
@Valid @RequestBody CreateWireGuardPeerRequest request
) {
return wireGuardPeerService.createForRouter(routerId, request);
}
}
@@ -1,11 +0,0 @@
package com.litoralregas.vpnprovisioner.wireguard.dto;
import com.litoralregas.vpnprovisioner.wireguard.entity.EndpointMode;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record CreateWireGuardPeerRequest(
@NotBlank String publicKey,
@NotNull EndpointMode endpointMode
) {
}
@@ -1,20 +0,0 @@
package com.litoralregas.vpnprovisioner.wireguard.dto;
import com.litoralregas.vpnprovisioner.wireguard.entity.EndpointMode;
import com.litoralregas.vpnprovisioner.wireguard.entity.WireGuardPeerStatus;
import java.time.Instant;
import java.util.UUID;
public record WireGuardPeerResponse(
UUID id,
UUID routerId,
String publicKey,
String vpnIp,
String allowedIps,
EndpointMode endpointMode,
WireGuardPeerStatus status,
Instant createdAt,
Instant updatedAt
) {
}
@@ -1,6 +0,0 @@
package com.litoralregas.vpnprovisioner.wireguard.entity;
public enum EndpointMode {
NORMAL_WIREGUARD,
UDP2RAW
}
@@ -1,80 +0,0 @@
package com.litoralregas.vpnprovisioner.wireguard.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "wireguard_peers")
public class WireGuardPeer {
@Id
private UUID id;
@Column(nullable = false, unique = true)
private UUID routerId;
@Column(nullable = false, unique = true)
private String publicKey;
@Column(nullable = false)
private String vpnIp;
@Column(nullable = false)
private String allowedIps;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private EndpointMode endpointMode;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private WireGuardPeerStatus status;
@Column(nullable = false)
private Instant createdAt;
@Column(nullable = false)
private Instant updatedAt;
protected WireGuardPeer() {
}
public WireGuardPeer(UUID routerId, String publicKey, String vpnIp, EndpointMode endpointMode) {
this.id = UUID.randomUUID();
this.routerId = routerId;
this.publicKey = publicKey;
this.vpnIp = vpnIp;
this.allowedIps = vpnIp + "/32";
this.endpointMode = endpointMode;
this.status = WireGuardPeerStatus.PENDING_APPLY;
this.createdAt = Instant.now();
this.updatedAt = Instant.now();
}
@PreUpdate
public void preUpdate() {
this.updatedAt = Instant.now();
}
public UUID getId() { return id; }
public UUID getRouterId() { return routerId; }
public String getPublicKey() { return publicKey; }
public String getVpnIp() { return vpnIp; }
public String getAllowedIps() { return allowedIps; }
public EndpointMode getEndpointMode() { return endpointMode; }
public WireGuardPeerStatus getStatus() { return status; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
public void markApplied() {
this.status = WireGuardPeerStatus.APPLIED;
this.updatedAt = Instant.now();
}
public void markFailed() {
this.status = WireGuardPeerStatus.FAILED;
this.updatedAt = Instant.now();
}
}
@@ -1,8 +0,0 @@
package com.litoralregas.vpnprovisioner.wireguard.entity;
public enum WireGuardPeerStatus {
PENDING_APPLY,
APPLIED,
FAILED,
REVOKED
}
@@ -1,14 +0,0 @@
package com.litoralregas.vpnprovisioner.wireguard.repository;
import com.litoralregas.vpnprovisioner.wireguard.entity.WireGuardPeer;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface WireGuardPeerRepository extends JpaRepository<WireGuardPeer, UUID> {
Optional<WireGuardPeer> findByRouterId(UUID routerId);
boolean existsByPublicKey(String publicKey);
}
@@ -1,93 +0,0 @@
package com.litoralregas.vpnprovisioner.wireguard.service;
import com.litoralregas.vpnprovisioner.common.exception.ResourceNotFoundException;
import com.litoralregas.vpnprovisioner.ipam.dto.VpnIpAllocationResponse;
import com.litoralregas.vpnprovisioner.ipam.service.VpnIpAllocationService;
import com.litoralregas.vpnprovisioner.router.repository.RouterRepository;
import com.litoralregas.vpnprovisioner.vps.WireGuardVpsService;
import com.litoralregas.vpnprovisioner.wireguard.dto.CreateWireGuardPeerRequest;
import com.litoralregas.vpnprovisioner.wireguard.dto.WireGuardPeerResponse;
import com.litoralregas.vpnprovisioner.wireguard.entity.WireGuardPeer;
import com.litoralregas.vpnprovisioner.wireguard.repository.WireGuardPeerRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
@Service
public class WireGuardPeerService {
private final WireGuardPeerRepository wireGuardPeerRepository;
private final RouterRepository routerRepository;
private final VpnIpAllocationService vpnIpAllocationService;
private final WireGuardVpsService wireGuardVpsService;
public WireGuardPeerService(
WireGuardPeerRepository wireGuardPeerRepository,
RouterRepository routerRepository,
VpnIpAllocationService vpnIpAllocationService,
WireGuardVpsService wireGuardVpsService) {
this.wireGuardPeerRepository = wireGuardPeerRepository;
this.routerRepository = routerRepository;
this.vpnIpAllocationService = vpnIpAllocationService;
this.wireGuardVpsService = wireGuardVpsService;
}
@Transactional
public WireGuardPeerResponse createForRouter(UUID routerId, CreateWireGuardPeerRequest request) {
routerRepository.findById(routerId)
.orElseThrow(() -> new ResourceNotFoundException("Router not found: " + routerId));
return wireGuardPeerRepository.findByRouterId(routerId)
.map(this::toResponse)
.orElseGet(() -> createNewPeer(routerId, request));
}
private WireGuardPeerResponse createNewPeer(UUID routerId, CreateWireGuardPeerRequest request) {
if (wireGuardPeerRepository.existsByPublicKey(request.publicKey())) {
throw new IllegalArgumentException("WireGuard public key already exists");
}
VpnIpAllocationResponse allocation =
vpnIpAllocationService.allocateForRouter(routerId);
WireGuardPeer peer = new WireGuardPeer(
routerId,
request.publicKey(),
allocation.ipAddress(),
request.endpointMode()
);
WireGuardPeer saved = wireGuardPeerRepository.save(peer);
try {
wireGuardVpsService.applyPeer(
saved.getPublicKey(),
saved.getAllowedIps()
);
saved.markApplied();
} catch (RuntimeException exception) {
saved.markFailed();
throw exception;
}
return toResponse(saved);
}
private WireGuardPeerResponse toResponse(WireGuardPeer peer) {
return new WireGuardPeerResponse(
peer.getId(),
peer.getRouterId(),
peer.getPublicKey(),
peer.getVpnIp(),
peer.getAllowedIps(),
peer.getEndpointMode(),
peer.getStatus(),
peer.getCreatedAt(),
peer.getUpdatedAt()
);
}
}
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS wireguard_peers;
DROP TABLE IF EXISTS vpn_ip_allocations;