From f93a4c8402a369aeb575db1df6b1425bd4ce6b33 Mon Sep 17 00:00:00 2001 From: litoral05 Date: Thu, 7 May 2026 14:42:16 +0100 Subject: [PATCH] Use VPS as source of truth for VPN IP allocation --- .../ipam/dto/VpnIpAllocationResponse.java | 4 +- .../ipam/entity/VpnIpAllocation.java | 27 +++++++-- .../ipam/entity/VpnIpAllocationStatus.java | 8 +++ .../repository/VpnIpAllocationRepository.java | 30 +--------- .../ipam/service/VpnIpAllocationService.java | 60 +++++++++++++++---- .../ipam/service/VpnIpPoolSeeder.java | 32 ---------- .../vpnprovisioner/router/entity/Router.java | 4 ++ .../db/migration/V3__ip_allocation_status.sql | 11 ++++ 8 files changed, 98 insertions(+), 78 deletions(-) create mode 100644 src/main/java/com/litoralregas/vpnprovisioner/ipam/entity/VpnIpAllocationStatus.java delete mode 100644 src/main/java/com/litoralregas/vpnprovisioner/ipam/service/VpnIpPoolSeeder.java create mode 100644 src/main/resources/db/migration/V3__ip_allocation_status.sql diff --git a/src/main/java/com/litoralregas/vpnprovisioner/ipam/dto/VpnIpAllocationResponse.java b/src/main/java/com/litoralregas/vpnprovisioner/ipam/dto/VpnIpAllocationResponse.java index b81254e..0632c22 100644 --- a/src/main/java/com/litoralregas/vpnprovisioner/ipam/dto/VpnIpAllocationResponse.java +++ b/src/main/java/com/litoralregas/vpnprovisioner/ipam/dto/VpnIpAllocationResponse.java @@ -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 diff --git a/src/main/java/com/litoralregas/vpnprovisioner/ipam/entity/VpnIpAllocation.java b/src/main/java/com/litoralregas/vpnprovisioner/ipam/entity/VpnIpAllocation.java index 1c3a1b6..1121eab 100644 --- a/src/main/java/com/litoralregas/vpnprovisioner/ipam/entity/VpnIpAllocation.java +++ b/src/main/java/com/litoralregas/vpnprovisioner/ipam/entity/VpnIpAllocation.java @@ -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() { diff --git a/src/main/java/com/litoralregas/vpnprovisioner/ipam/entity/VpnIpAllocationStatus.java b/src/main/java/com/litoralregas/vpnprovisioner/ipam/entity/VpnIpAllocationStatus.java new file mode 100644 index 0000000..6545340 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnprovisioner/ipam/entity/VpnIpAllocationStatus.java @@ -0,0 +1,8 @@ +package com.litoralregas.vpnprovisioner.ipam.entity; + +public enum VpnIpAllocationStatus { + ALLOCATED, + ALLOCATED_EXTERNAL, + RELEASED, + RETIRED +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnprovisioner/ipam/repository/VpnIpAllocationRepository.java b/src/main/java/com/litoralregas/vpnprovisioner/ipam/repository/VpnIpAllocationRepository.java index 6bdb305..746a455 100644 --- a/src/main/java/com/litoralregas/vpnprovisioner/ipam/repository/VpnIpAllocationRepository.java +++ b/src/main/java/com/litoralregas/vpnprovisioner/ipam/repository/VpnIpAllocationRepository.java @@ -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 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 findNextFreeForUpdate(); + Optional 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 findFreeCandidatesForUpdate(); - - Optional findByIpAddress(String ipAddress); } \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnprovisioner/ipam/service/VpnIpAllocationService.java b/src/main/java/com/litoralregas/vpnprovisioner/ipam/service/VpnIpAllocationService.java index c6d2cdb..979ad08 100644 --- a/src/main/java/com/litoralregas/vpnprovisioner/ipam/service/VpnIpAllocationService.java +++ b/src/main/java/com/litoralregas/vpnprovisioner/ipam/service/VpnIpAllocationService.java @@ -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 usedOnVps = refreshUsedIpsFromVps(); + + Set 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 refreshUsedIpsFromVps() { Set 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 usedOnVps, Set 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() diff --git a/src/main/java/com/litoralregas/vpnprovisioner/ipam/service/VpnIpPoolSeeder.java b/src/main/java/com/litoralregas/vpnprovisioner/ipam/service/VpnIpPoolSeeder.java deleted file mode 100644 index e61cc48..0000000 --- a/src/main/java/com/litoralregas/vpnprovisioner/ipam/service/VpnIpPoolSeeder.java +++ /dev/null @@ -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)); - } - } - } - } -} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnprovisioner/router/entity/Router.java b/src/main/java/com/litoralregas/vpnprovisioner/router/entity/Router.java index 76da5f1..ab307c3 100644 --- a/src/main/java/com/litoralregas/vpnprovisioner/router/entity/Router.java +++ b/src/main/java/com/litoralregas/vpnprovisioner/router/entity/Router.java @@ -63,4 +63,8 @@ public class Router { this.hardwareModel = hardwareModel; this.firmwareVersion = firmwareVersion; } + + public void assignVpnIp(String vpnIp) { + this.vpnIp = vpnIp; + } } \ No newline at end of file diff --git a/src/main/resources/db/migration/V3__ip_allocation_status.sql b/src/main/resources/db/migration/V3__ip_allocation_status.sql new file mode 100644 index 0000000..577ea80 --- /dev/null +++ b/src/main/resources/db/migration/V3__ip_allocation_status.sql @@ -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; \ No newline at end of file