diff --git a/pom.xml b/pom.xml
index e25f630..4bf2e6b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -80,6 +80,11 @@
spring-security-test
test
+
+ com.jcraft
+ jsch
+ 0.1.55
+
diff --git a/src/main/java/com/litoralregas/vpnprovisioner/common/exception/GlobalExceptionHandler.java b/src/main/java/com/litoralregas/vpnprovisioner/common/exception/GlobalExceptionHandler.java
index fff5f96..90ef47a 100644
--- a/src/main/java/com/litoralregas/vpnprovisioner/common/exception/GlobalExceptionHandler.java
+++ b/src/main/java/com/litoralregas/vpnprovisioner/common/exception/GlobalExceptionHandler.java
@@ -1,5 +1,6 @@
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;
@@ -61,4 +62,19 @@ public class GlobalExceptionHandler {
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()
+ );
+ }
}
\ No newline at end of file
diff --git a/src/main/java/com/litoralregas/vpnprovisioner/config/SecurityConfig.java b/src/main/java/com/litoralregas/vpnprovisioner/config/SecurityConfig.java
index 3253f9e..01ebf96 100644
--- a/src/main/java/com/litoralregas/vpnprovisioner/config/SecurityConfig.java
+++ b/src/main/java/com/litoralregas/vpnprovisioner/config/SecurityConfig.java
@@ -9,7 +9,10 @@ import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
-@EnableConfigurationProperties(AppSecurityProperties.class)
+@EnableConfigurationProperties({
+ AppSecurityProperties.class,
+ VpsSshProperties.class
+})
public class SecurityConfig {
private final AppSecurityProperties securityProperties;
@@ -24,6 +27,8 @@ public class SecurityConfig {
return http
.csrf(csrf -> csrf.disable())
+ .formLogin(form -> form.disable())
+ .httpBasic(basic -> basic.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated()
diff --git a/src/main/java/com/litoralregas/vpnprovisioner/config/VpsSshProperties.java b/src/main/java/com/litoralregas/vpnprovisioner/config/VpsSshProperties.java
new file mode 100644
index 0000000..f0d7be9
--- /dev/null
+++ b/src/main/java/com/litoralregas/vpnprovisioner/config/VpsSshProperties.java
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/litoralregas/vpnprovisioner/ipam/controller/VpnIpAllocationController.java b/src/main/java/com/litoralregas/vpnprovisioner/ipam/controller/VpnIpAllocationController.java
new file mode 100644
index 0000000..1c4a13e
--- /dev/null
+++ b/src/main/java/com/litoralregas/vpnprovisioner/ipam/controller/VpnIpAllocationController.java
@@ -0,0 +1,30 @@
+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);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/litoralregas/vpnprovisioner/ipam/dto/VpnIpAllocationResponse.java b/src/main/java/com/litoralregas/vpnprovisioner/ipam/dto/VpnIpAllocationResponse.java
new file mode 100644
index 0000000..b81254e
--- /dev/null
+++ b/src/main/java/com/litoralregas/vpnprovisioner/ipam/dto/VpnIpAllocationResponse.java
@@ -0,0 +1,14 @@
+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
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/com/litoralregas/vpnprovisioner/ipam/entity/VpnIpAllocation.java b/src/main/java/com/litoralregas/vpnprovisioner/ipam/entity/VpnIpAllocation.java
new file mode 100644
index 0000000..1c3a1b6
--- /dev/null
+++ b/src/main/java/com/litoralregas/vpnprovisioner/ipam/entity/VpnIpAllocation.java
@@ -0,0 +1,72 @@
+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;
+ }
+}
\ 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
new file mode 100644
index 0000000..6bdb305
--- /dev/null
+++ b/src/main/java/com/litoralregas/vpnprovisioner/ipam/repository/VpnIpAllocationRepository.java
@@ -0,0 +1,51 @@
+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 {
+
+ Optional 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();
+
+ @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 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
new file mode 100644
index 0000000..c6d2cdb
--- /dev/null
+++ b/src/main/java/com/litoralregas/vpnprovisioner/ipam/service/VpnIpAllocationService.java
@@ -0,0 +1,73 @@
+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 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()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/litoralregas/vpnprovisioner/ipam/service/VpnIpPoolSeeder.java b/src/main/java/com/litoralregas/vpnprovisioner/ipam/service/VpnIpPoolSeeder.java
new file mode 100644
index 0000000..e61cc48
--- /dev/null
+++ b/src/main/java/com/litoralregas/vpnprovisioner/ipam/service/VpnIpPoolSeeder.java
@@ -0,0 +1,32 @@
+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/vps/SshCommandException.java b/src/main/java/com/litoralregas/vpnprovisioner/vps/SshCommandException.java
new file mode 100644
index 0000000..e658c7a
--- /dev/null
+++ b/src/main/java/com/litoralregas/vpnprovisioner/vps/SshCommandException.java
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/litoralregas/vpnprovisioner/vps/SshCommandResult.java b/src/main/java/com/litoralregas/vpnprovisioner/vps/SshCommandResult.java
new file mode 100644
index 0000000..9ee5763
--- /dev/null
+++ b/src/main/java/com/litoralregas/vpnprovisioner/vps/SshCommandResult.java
@@ -0,0 +1,8 @@
+package com.litoralregas.vpnprovisioner.vps;
+
+public record SshCommandResult(
+ int exitCode,
+ String stdout,
+ String stderr
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/com/litoralregas/vpnprovisioner/vps/SshService.java b/src/main/java/com/litoralregas/vpnprovisioner/vps/SshService.java
new file mode 100644
index 0000000..bc89bc6
--- /dev/null
+++ b/src/main/java/com/litoralregas/vpnprovisioner/vps/SshService.java
@@ -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");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/litoralregas/vpnprovisioner/vps/WireGuardVpsService.java b/src/main/java/com/litoralregas/vpnprovisioner/vps/WireGuardVpsService.java
new file mode 100644
index 0000000..5c1146f
--- /dev/null
+++ b/src/main/java/com/litoralregas/vpnprovisioner/vps/WireGuardVpsService.java
@@ -0,0 +1,47 @@
+package com.litoralregas.vpnprovisioner.vps;
+
+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;
+
+ public WireGuardVpsService(SshService sshService) {
+ this.sshService = sshService;
+ }
+
+ public Set findUsedVpnIps() {
+ SshCommandResult result = sshService.executeOnConfiguredVps(
+ "sudo wg show wg0 allowed-ips"
+ );
+
+ if (result.exitCode() != 0) {
+ throw new SshCommandException(
+ "Failed to query WireGuard allowed IPs: " + result.stderr()
+ );
+ }
+
+ return parseVpnIps(result.stdout());
+ }
+
+ Set parseVpnIps(String output) {
+ Set ips = new HashSet<>();
+
+ Matcher matcher = VPN_IP_PATTERN.matcher(output);
+
+ while (matcher.find()) {
+ ips.add(matcher.group());
+ }
+
+ return ips;
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
index 3f979df..42cb123 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -4,8 +4,19 @@ server:
app:
security:
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:
+ config:
+ import: optional:file:.env[.properties]
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/vpn_provisioner}
username: ${DB_USER:vpn}
diff --git a/src/main/resources/db/migration/V2__create_vpn_ip_allocations.sql b/src/main/resources/db/migration/V2__create_vpn_ip_allocations.sql
new file mode 100644
index 0000000..5ddc953
--- /dev/null
+++ b/src/main/resources/db/migration/V2__create_vpn_ip_allocations.sql
@@ -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);
\ No newline at end of file