From fe2faa47844bd8c353039b8a8e924d5851b00767 Mon Sep 17 00:00:00 2001 From: litoral05 Date: Thu, 7 May 2026 16:05:32 +0100 Subject: [PATCH] Move VPN provisioning onto router model --- .../router/controller/RouterController.java | 14 ++- .../router/dto/ProvisionRouterVpnRequest.java | 15 +++ .../router/dto/RouterResponse.java | 6 + .../router/entity/EndpointMode.java | 6 + .../vpnprovisioner/router/entity/Router.java | 89 +++++++++++++-- .../router/entity/RouterVpnStatus.java | 8 ++ .../router/service/RouterService.java | 4 + .../service/RouterVpnProvisioningService.java | 105 ++++++++++++++++++ .../vpnprovisioner/vps/VpsController.java | 8 ++ .../vps/WireGuardVpsService.java | 14 +++ .../V5__add_vpn_fields_to_routers.sql | 9 ++ 11 files changed, 268 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/litoralregas/vpnprovisioner/router/dto/ProvisionRouterVpnRequest.java create mode 100644 src/main/java/com/litoralregas/vpnprovisioner/router/entity/EndpointMode.java create mode 100644 src/main/java/com/litoralregas/vpnprovisioner/router/entity/RouterVpnStatus.java create mode 100644 src/main/java/com/litoralregas/vpnprovisioner/router/service/RouterVpnProvisioningService.java create mode 100644 src/main/resources/db/migration/V5__add_vpn_fields_to_routers.sql diff --git a/src/main/java/com/litoralregas/vpnprovisioner/router/controller/RouterController.java b/src/main/java/com/litoralregas/vpnprovisioner/router/controller/RouterController.java index 5e72479..c02c481 100644 --- a/src/main/java/com/litoralregas/vpnprovisioner/router/controller/RouterController.java +++ b/src/main/java/com/litoralregas/vpnprovisioner/router/controller/RouterController.java @@ -1,9 +1,11 @@ package com.litoralregas.vpnprovisioner.router.controller; import com.litoralregas.vpnprovisioner.router.dto.CreateRouterRequest; +import com.litoralregas.vpnprovisioner.router.dto.ProvisionRouterVpnRequest; import com.litoralregas.vpnprovisioner.router.dto.RouterResponse; import com.litoralregas.vpnprovisioner.router.dto.UpdateRouterRequest; import com.litoralregas.vpnprovisioner.router.service.RouterService; +import com.litoralregas.vpnprovisioner.router.service.RouterVpnProvisioningService; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; @@ -16,9 +18,11 @@ import java.util.UUID; public class RouterController { private final RouterService routerService; + private final RouterVpnProvisioningService routerVpnProvisioningService; - public RouterController(RouterService routerService) { + public RouterController(RouterService routerService, RouterVpnProvisioningService routerVpnProvisioningService) { this.routerService = routerService; + this.routerVpnProvisioningService = routerVpnProvisioningService; } @PostMapping @@ -50,4 +54,12 @@ public class RouterController { public void delete(@PathVariable UUID id) { routerService.delete(id); } + + @PostMapping("/{id}/vpn-peer") + public RouterResponse provisionVpnPeer( + @PathVariable UUID id, + @Valid @RequestBody ProvisionRouterVpnRequest request + ) { + return routerVpnProvisioningService.provision(id, request); + } } \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnprovisioner/router/dto/ProvisionRouterVpnRequest.java b/src/main/java/com/litoralregas/vpnprovisioner/router/dto/ProvisionRouterVpnRequest.java new file mode 100644 index 0000000..e917031 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnprovisioner/router/dto/ProvisionRouterVpnRequest.java @@ -0,0 +1,15 @@ +package com.litoralregas.vpnprovisioner.router.dto; + +import com.litoralregas.vpnprovisioner.router.entity.EndpointMode; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ProvisionRouterVpnRequest( + + @NotBlank + String publicKey, + + @NotNull + EndpointMode endpointMode +) { +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnprovisioner/router/dto/RouterResponse.java b/src/main/java/com/litoralregas/vpnprovisioner/router/dto/RouterResponse.java index f176d34..1bb2a0c 100644 --- a/src/main/java/com/litoralregas/vpnprovisioner/router/dto/RouterResponse.java +++ b/src/main/java/com/litoralregas/vpnprovisioner/router/dto/RouterResponse.java @@ -1,6 +1,8 @@ package com.litoralregas.vpnprovisioner.router.dto; +import com.litoralregas.vpnprovisioner.router.entity.EndpointMode; import com.litoralregas.vpnprovisioner.router.entity.RouterStatus; +import com.litoralregas.vpnprovisioner.router.entity.RouterVpnStatus; import java.time.Instant; import java.util.UUID; @@ -12,6 +14,10 @@ public record RouterResponse( String firmwareVersion, RouterStatus status, String vpnIp, + String wireguardPublicKey, + EndpointMode endpointMode, + RouterVpnStatus vpnStatus, + Instant vpnProvisionedAt, Instant createdAt, Instant updatedAt ) { diff --git a/src/main/java/com/litoralregas/vpnprovisioner/router/entity/EndpointMode.java b/src/main/java/com/litoralregas/vpnprovisioner/router/entity/EndpointMode.java new file mode 100644 index 0000000..151b921 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnprovisioner/router/entity/EndpointMode.java @@ -0,0 +1,6 @@ +package com.litoralregas.vpnprovisioner.router.entity; + +public enum EndpointMode { + NORMAL_WIREGUARD, + UDP2RAW +} \ 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 ab307c3..2dfaa4f 100644 --- a/src/main/java/com/litoralregas/vpnprovisioner/router/entity/Router.java +++ b/src/main/java/com/litoralregas/vpnprovisioner/router/entity/Router.java @@ -25,6 +25,17 @@ public class Router { private String vpnIp; + @Column(unique = true) + private String wireguardPublicKey; + + @Enumerated(EnumType.STRING) + private EndpointMode endpointMode; + + @Enumerated(EnumType.STRING) + private RouterVpnStatus vpnStatus; + + private Instant vpnProvisionedAt; + @Column(nullable = false) private Instant createdAt; @@ -40,6 +51,7 @@ public class Router { this.hardwareModel = hardwareModel; this.firmwareVersion = firmwareVersion; this.status = RouterStatus.PENDING; + this.vpnStatus = RouterVpnStatus.NOT_PROVISIONED; this.createdAt = Instant.now(); this.updatedAt = Instant.now(); } @@ -49,15 +61,6 @@ public class Router { this.updatedAt = Instant.now(); } - public UUID getId() { return id; } - public String getName() { return name; } - public String getHardwareModel() { return hardwareModel; } - public String getFirmwareVersion() { return firmwareVersion; } - public RouterStatus getStatus() { return status; } - public String getVpnIp() { return vpnIp; } - public Instant getCreatedAt() { return createdAt; } - public Instant getUpdatedAt() { return updatedAt; } - public void updateDetails(String name, String hardwareModel, String firmwareVersion) { this.name = name; this.hardwareModel = hardwareModel; @@ -67,4 +70,72 @@ public class Router { public void assignVpnIp(String vpnIp) { this.vpnIp = vpnIp; } + + public void markVpnApplying(String publicKey, String vpnIp, EndpointMode endpointMode) { + this.wireguardPublicKey = publicKey; + this.vpnIp = vpnIp; + this.endpointMode = endpointMode; + this.vpnStatus = RouterVpnStatus.APPLYING; + } + + public void markVpnApplied() { + this.vpnStatus = RouterVpnStatus.APPLIED; + this.vpnProvisionedAt = Instant.now(); + } + + public void markVpnFailed() { + this.vpnStatus = RouterVpnStatus.FAILED; + } + + public boolean hasVpnPeer() { + return this.wireguardPublicKey != null && this.vpnIp != null; + } + + public UUID getId() { + return id; + } + + public String getName() { + return name; + } + + public String getHardwareModel() { + return hardwareModel; + } + + public String getFirmwareVersion() { + return firmwareVersion; + } + + public RouterStatus getStatus() { + return status; + } + + public String getVpnIp() { + return vpnIp; + } + + public String getWireguardPublicKey() { + return wireguardPublicKey; + } + + public EndpointMode getEndpointMode() { + return endpointMode; + } + + public RouterVpnStatus getVpnStatus() { + return vpnStatus; + } + + public Instant getVpnProvisionedAt() { + return vpnProvisionedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } } \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnprovisioner/router/entity/RouterVpnStatus.java b/src/main/java/com/litoralregas/vpnprovisioner/router/entity/RouterVpnStatus.java new file mode 100644 index 0000000..0c0a5cb --- /dev/null +++ b/src/main/java/com/litoralregas/vpnprovisioner/router/entity/RouterVpnStatus.java @@ -0,0 +1,8 @@ +package com.litoralregas.vpnprovisioner.router.entity; + +public enum RouterVpnStatus { + NOT_PROVISIONED, + APPLYING, + APPLIED, + FAILED +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnprovisioner/router/service/RouterService.java b/src/main/java/com/litoralregas/vpnprovisioner/router/service/RouterService.java index ad4e719..c424606 100644 --- a/src/main/java/com/litoralregas/vpnprovisioner/router/service/RouterService.java +++ b/src/main/java/com/litoralregas/vpnprovisioner/router/service/RouterService.java @@ -79,6 +79,10 @@ public class RouterService { router.getFirmwareVersion(), router.getStatus(), router.getVpnIp(), + router.getWireguardPublicKey(), + router.getEndpointMode(), + router.getVpnStatus(), + router.getVpnProvisionedAt(), router.getCreatedAt(), router.getUpdatedAt() ); diff --git a/src/main/java/com/litoralregas/vpnprovisioner/router/service/RouterVpnProvisioningService.java b/src/main/java/com/litoralregas/vpnprovisioner/router/service/RouterVpnProvisioningService.java new file mode 100644 index 0000000..606e20c --- /dev/null +++ b/src/main/java/com/litoralregas/vpnprovisioner/router/service/RouterVpnProvisioningService.java @@ -0,0 +1,105 @@ +package com.litoralregas.vpnprovisioner.router.service; + +import com.litoralregas.vpnprovisioner.common.exception.ResourceNotFoundException; +import com.litoralregas.vpnprovisioner.router.dto.ProvisionRouterVpnRequest; +import com.litoralregas.vpnprovisioner.router.dto.RouterResponse; +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.time.Instant; +import java.util.Set; +import java.util.UUID; + +@Service +public class RouterVpnProvisioningService { + + private final RouterRepository routerRepository; + private final WireGuardVpsService wireGuardVpsService; + + public RouterVpnProvisioningService( + RouterRepository routerRepository, + WireGuardVpsService wireGuardVpsService + ) { + this.routerRepository = routerRepository; + this.wireGuardVpsService = wireGuardVpsService; + } + + @Transactional + public RouterResponse provision(UUID routerId, ProvisionRouterVpnRequest request) { + + Router router = routerRepository.findById(routerId) + .orElseThrow(() -> + new ResourceNotFoundException("Router not found: " + routerId) + ); + + if (router.hasVpnPeer()) { + throw new IllegalStateException("Router already has VPN provisioned"); + } + + Set usedIps = wireGuardVpsService.findUsedVpnIps(); + + String vpnIp = findNextAvailableIp(usedIps); + + router.markVpnApplying( + request.publicKey(), + vpnIp, + request.endpointMode() + ); + + try { + + wireGuardVpsService.applyPeer( + request.publicKey(), + vpnIp + "/32" + ); + + router.markVpnApplied(); + + } catch (RuntimeException exception) { + + router.markVpnFailed(); + + throw exception; + } + + return toResponse(router); + } + + private String findNextAvailableIp(Set usedIps) { + + for (int thirdOctet = 1; thirdOctet <= 255; thirdOctet++) { + + for (int fourthOctet = 2; fourthOctet <= 254; fourthOctet++) { + + String ip = "198.19." + thirdOctet + "." + fourthOctet; + + if (!usedIps.contains(ip)) { + return ip; + } + } + } + + throw new IllegalStateException("No VPN IPs available"); + } + + private RouterResponse toResponse(Router router) { + + return new RouterResponse( + router.getId(), + router.getName(), + router.getHardwareModel(), + router.getFirmwareVersion(), + router.getStatus(), + router.getVpnIp(), + router.getWireguardPublicKey(), + router.getEndpointMode(), + router.getVpnStatus(), + router.getVpnProvisionedAt(), + router.getCreatedAt(), + router.getUpdatedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnprovisioner/vps/VpsController.java b/src/main/java/com/litoralregas/vpnprovisioner/vps/VpsController.java index c177188..83a28f4 100644 --- a/src/main/java/com/litoralregas/vpnprovisioner/vps/VpsController.java +++ b/src/main/java/com/litoralregas/vpnprovisioner/vps/VpsController.java @@ -17,4 +17,12 @@ public class VpsController { public String health() { return wireGuardVpsService.getVpsHealthJson(); } + + @PostMapping( + value = "/wireguard/rollback-last-backup", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public String rollbackLastBackup() { + return wireGuardVpsService.restoreLastWireGuardBackup(); + } } \ 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 index 531aac2..e363836 100644 --- a/src/main/java/com/litoralregas/vpnprovisioner/vps/WireGuardVpsService.java +++ b/src/main/java/com/litoralregas/vpnprovisioner/vps/WireGuardVpsService.java @@ -79,4 +79,18 @@ public class WireGuardVpsService { return result.stdout(); } + + public String restoreLastWireGuardBackup() { + SshCommandResult result = sshService.executeOnConfiguredVps( + "sudo /usr/local/sbin/lr-wg-restore-last-backup" + ); + + if (result.exitCode() != 0) { + throw new SshCommandException( + "Failed to restore WireGuard backup: " + result.stderr() + ); + } + + return result.stdout(); + } } \ No newline at end of file diff --git a/src/main/resources/db/migration/V5__add_vpn_fields_to_routers.sql b/src/main/resources/db/migration/V5__add_vpn_fields_to_routers.sql new file mode 100644 index 0000000..bbe2bc3 --- /dev/null +++ b/src/main/resources/db/migration/V5__add_vpn_fields_to_routers.sql @@ -0,0 +1,9 @@ +ALTER TABLE routers +ADD COLUMN wireguard_public_key VARCHAR(255), +ADD COLUMN endpoint_mode VARCHAR(50), +ADD COLUMN vpn_status VARCHAR(50), +ADD COLUMN vpn_provisioned_at TIMESTAMP; + +CREATE UNIQUE INDEX idx_routers_wireguard_public_key +ON routers(wireguard_public_key) +WHERE wireguard_public_key IS NOT NULL; \ No newline at end of file