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