Add VPS-aware VPN IP allocation
This commit is contained in:
@@ -80,6 +80,11 @@
|
|||||||
<artifactId>spring-security-test</artifactId>
|
<artifactId>spring-security-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.jcraft</groupId>
|
||||||
|
<artifactId>jsch</artifactId>
|
||||||
|
<version>0.1.55</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
+16
@@ -1,5 +1,6 @@
|
|||||||
package com.litoralregas.vpnprovisioner.common.exception;
|
package com.litoralregas.vpnprovisioner.common.exception;
|
||||||
|
|
||||||
|
import com.litoralregas.vpnprovisioner.vps.SshCommandException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
@@ -61,4 +62,19 @@ public class GlobalExceptionHandler {
|
|||||||
request.getRequestURI()
|
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;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties(AppSecurityProperties.class)
|
@EnableConfigurationProperties({
|
||||||
|
AppSecurityProperties.class,
|
||||||
|
VpsSshProperties.class
|
||||||
|
})
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
private final AppSecurityProperties securityProperties;
|
private final AppSecurityProperties securityProperties;
|
||||||
@@ -24,6 +27,8 @@ public class SecurityConfig {
|
|||||||
|
|
||||||
return http
|
return http
|
||||||
.csrf(csrf -> csrf.disable())
|
.csrf(csrf -> csrf.disable())
|
||||||
|
.formLogin(form -> form.disable())
|
||||||
|
.httpBasic(basic -> basic.disable())
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/actuator/health").permitAll()
|
.requestMatchers("/actuator/health").permitAll()
|
||||||
.anyRequest().authenticated()
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -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);
|
||||||
|
}
|
||||||
+73
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,19 @@ server:
|
|||||||
app:
|
app:
|
||||||
security:
|
security:
|
||||||
api-key: ${APP_API_KEY:dev-api-key}
|
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:
|
spring:
|
||||||
|
config:
|
||||||
|
import: optional:file:.env[.properties]
|
||||||
datasource:
|
datasource:
|
||||||
url: ${DB_URL:jdbc:postgresql://localhost:5432/vpn_provisioner}
|
url: ${DB_URL:jdbc:postgresql://localhost:5432/vpn_provisioner}
|
||||||
username: ${DB_USER:vpn}
|
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);
|
||||||
Reference in New Issue
Block a user