Add VPS-aware VPN IP allocation

This commit is contained in:
litoral05
2026-05-07 14:17:40 +01:00
parent a09587950e
commit c88ef29449
16 changed files with 592 additions and 1 deletions
+5
View File
@@ -80,6 +80,11 @@
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.jcraft</groupId>
<artifactId>jsch</artifactId>
<version>0.1.55</version>
</dependency>
</dependencies>
<build>
@@ -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()
);
}
}
@@ -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()
@@ -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;
}
}
@@ -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);
}
}
@@ -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
) {
}
@@ -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;
}
}
@@ -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<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);
}
@@ -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<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()
);
}
}
@@ -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));
}
}
}
}
}
@@ -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);
}
}
@@ -0,0 +1,8 @@
package com.litoralregas.vpnprovisioner.vps;
public record SshCommandResult(
int exitCode,
String stdout,
String stderr
) {
}
@@ -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");
}
}
}
@@ -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<String> 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<String> parseVpnIps(String output) {
Set<String> ips = new HashSet<>();
Matcher matcher = VPN_IP_PATTERN.matcher(output);
while (matcher.find()) {
ips.add(matcher.group());
}
return ips;
}
}
+11
View File
@@ -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}
@@ -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);