Use VPS as source of truth for VPN IP allocation

This commit is contained in:
litoral05
2026-05-07 14:42:16 +01:00
parent c88ef29449
commit f93a4c8402
8 changed files with 98 additions and 78 deletions
@@ -1,12 +1,14 @@
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,
boolean allocated,
VpnIpAllocationStatus status,
UUID routerId,
Instant allocatedAt,
Instant releasedAt
@@ -15,8 +15,9 @@ public class VpnIpAllocation {
@Column(nullable = false, unique = true)
private String ipAddress;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private boolean allocated;
private VpnIpAllocationStatus status;
private UUID routerId;
@@ -30,18 +31,32 @@ public class VpnIpAllocation {
public VpnIpAllocation(String ipAddress) {
this.id = UUID.randomUUID();
this.ipAddress = ipAddress;
this.allocated = false;
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.allocated = true;
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.allocated = false;
this.status = VpnIpAllocationStatus.RELEASED;
this.routerId = null;
this.releasedAt = Instant.now();
}
@@ -54,8 +69,8 @@ public class VpnIpAllocation {
return ipAddress;
}
public boolean isAllocated() {
return allocated;
public VpnIpAllocationStatus getStatus() {
return status;
}
public UUID getRouterId() {
@@ -0,0 +1,8 @@
package com.litoralregas.vpnprovisioner.ipam.entity;
public enum VpnIpAllocationStatus {
ALLOCATED,
ALLOCATED_EXTERNAL,
RELEASED,
RETIRED
}
@@ -4,7 +4,6 @@ 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;
@@ -12,40 +11,15 @@ public interface VpnIpAllocationRepository extends JpaRepository<VpnIpAllocation
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();
Optional<VpnIpAllocation> findByIpAddress(String ipAddress);
@Modifying
@Query("""
UPDATE VpnIpAllocation ip
SET ip.allocated = false,
SET ip.status = 'RELEASED',
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);
}
@@ -10,13 +10,18 @@ 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;
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;
@@ -24,7 +29,8 @@ public class VpnIpAllocationService {
public VpnIpAllocationService(
VpnIpAllocationRepository vpnIpAllocationRepository,
RouterRepository routerRepository,
WireGuardVpsService wireGuardVpsService) {
WireGuardVpsService wireGuardVpsService
) {
this.vpnIpAllocationRepository = vpnIpAllocationRepository;
this.routerRepository = routerRepository;
this.wireGuardVpsService = wireGuardVpsService;
@@ -46,25 +52,57 @@ public class VpnIpAllocationService {
}
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();
VpnIpAllocation allocation = vpnIpAllocationRepository
.findFreeCandidatesForUpdate()
.stream()
.filter(ip -> !usedIps.contains(ip.getIpAddress()))
.findFirst()
.orElseThrow(() -> new IllegalStateException("No free VPN IP addresses available"));
for (String ip : usedIps) {
vpnIpAllocationRepository.findByIpAddress(ip)
.ifPresentOrElse(
VpnIpAllocation::markExternal,
() -> vpnIpAllocationRepository.save(new VpnIpAllocation(ip))
);
}
allocation.allocateTo(router.getId());
return usedIps;
}
return toResponse(allocation);
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.isAllocated(),
allocation.getStatus(),
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));
}
}
}
}
}
@@ -63,4 +63,8 @@ public class Router {
this.hardwareModel = hardwareModel;
this.firmwareVersion = firmwareVersion;
}
public void assignVpnIp(String vpnIp) {
this.vpnIp = vpnIp;
}
}
@@ -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;