Compare commits
7 Commits
c88ef29449
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| af55458ad4 | |||
| 6712a26b2a | |||
| ad9a053099 | |||
| fe2faa4784 | |||
| 66adf1b42b | |||
| 8b849e6560 | |||
| f93a4c8402 |
@@ -6,6 +6,7 @@ import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
@@ -58,7 +59,7 @@ public class ApiKeyAuthFilter extends OncePerRequestFilter {
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
"api-key-client",
|
||||
null,
|
||||
List.of()
|
||||
List.of(new SimpleGrantedAuthority("ROLE_API"))
|
||||
);
|
||||
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
@@ -27,11 +28,16 @@ public class SecurityConfig {
|
||||
|
||||
return http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.cors(cors -> cors.disable())
|
||||
.formLogin(form -> form.disable())
|
||||
.httpBasic(basic -> basic.disable())
|
||||
.logout(logout -> logout.disable())
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/actuator/health").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
.addFilterBefore(apiKeyAuthFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.build();
|
||||
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
) {
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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
@@ -1,51 +0,0 @@
|
||||
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
@@ -1,73 +0,0 @@
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-4
@@ -1,9 +1,9 @@
|
||||
package com.litoralregas.vpnprovisioner.router.controller;
|
||||
|
||||
import com.litoralregas.vpnprovisioner.router.dto.CreateRouterRequest;
|
||||
import com.litoralregas.vpnprovisioner.router.dto.RouterResponse;
|
||||
import com.litoralregas.vpnprovisioner.router.dto.UpdateRouterRequest;
|
||||
import com.litoralregas.vpnprovisioner.router.dto.*;
|
||||
import com.litoralregas.vpnprovisioner.router.service.RouterService;
|
||||
import com.litoralregas.vpnprovisioner.router.service.RouterSyncService;
|
||||
import com.litoralregas.vpnprovisioner.router.service.RouterVpnProvisioningService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -16,9 +16,13 @@ import java.util.UUID;
|
||||
public class RouterController {
|
||||
|
||||
private final RouterService routerService;
|
||||
private final RouterVpnProvisioningService routerVpnProvisioningService;
|
||||
private final RouterSyncService routerSyncService;
|
||||
|
||||
public RouterController(RouterService routerService) {
|
||||
public RouterController(RouterService routerService, RouterVpnProvisioningService routerVpnProvisioningService, RouterSyncService routerSyncService) {
|
||||
this.routerService = routerService;
|
||||
this.routerVpnProvisioningService = routerVpnProvisioningService;
|
||||
this.routerSyncService = routerSyncService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@@ -50,4 +54,25 @@ 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);
|
||||
}
|
||||
|
||||
@PostMapping("/sync-from-vps")
|
||||
public SyncRoutersFromVpsResponse syncFromVps() {
|
||||
return routerSyncService.syncFromVps();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/provisioning-result")
|
||||
public RouterResponse submitProvisioningResult(
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody RouterProvisioningResultRequest request
|
||||
) {
|
||||
return routerService.submitProvisioningResult(id, request);
|
||||
}
|
||||
}
|
||||
+15
@@ -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
|
||||
) {
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.litoralregas.vpnprovisioner.router.dto;
|
||||
|
||||
import com.litoralregas.vpnprovisioner.router.entity.RouterStatus;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record RouterProvisioningResultRequest(
|
||||
@NotNull
|
||||
Boolean success,
|
||||
|
||||
@NotNull
|
||||
RouterStatus finalStatus,
|
||||
|
||||
String message,
|
||||
String firmwareVersion,
|
||||
String validationSummary,
|
||||
String lastError
|
||||
) {
|
||||
}
|
||||
@@ -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,7 +14,16 @@ public record RouterResponse(
|
||||
String firmwareVersion,
|
||||
RouterStatus status,
|
||||
String vpnIp,
|
||||
String wireguardPublicKey,
|
||||
EndpointMode endpointMode,
|
||||
RouterVpnStatus vpnStatus,
|
||||
Instant vpnProvisionedAt,
|
||||
Instant createdAt,
|
||||
Instant updatedAt
|
||||
Instant updatedAt,
|
||||
|
||||
Instant lastProvisionedAt,
|
||||
Instant lastValidatedAt,
|
||||
String validationSummary,
|
||||
String lastProvisioningError
|
||||
) {
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.litoralregas.vpnprovisioner.router.dto;
|
||||
|
||||
public record SyncRoutersFromVpsResponse(
|
||||
int seenOnVps,
|
||||
int created,
|
||||
int updated
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.litoralregas.vpnprovisioner.router.entity;
|
||||
|
||||
public enum EndpointMode {
|
||||
NORMAL_WIREGUARD,
|
||||
UDP2RAW
|
||||
}
|
||||
@@ -25,12 +25,32 @@ 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;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
private Instant lastProvisionedAt;
|
||||
private Instant lastValidatedAt;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String validationSummary;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String lastProvisioningError;
|
||||
|
||||
protected Router() {
|
||||
}
|
||||
|
||||
@@ -40,6 +60,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,18 +70,147 @@ 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;
|
||||
this.firmwareVersion = firmwareVersion;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public static Router importedVpnRouter(String publicKey, String vpnIp) {
|
||||
Router router = new Router(
|
||||
"Imported Router " + vpnIp,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
router.wireguardPublicKey = publicKey;
|
||||
router.vpnIp = vpnIp;
|
||||
router.endpointMode = EndpointMode.UDP2RAW;
|
||||
router.vpnStatus = RouterVpnStatus.APPLIED;
|
||||
router.vpnProvisionedAt = Instant.now();
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
public void syncVpnPeer(String publicKey, String vpnIp) {
|
||||
this.wireguardPublicKey = publicKey;
|
||||
this.vpnIp = vpnIp;
|
||||
this.endpointMode = EndpointMode.UDP2RAW;
|
||||
this.vpnStatus = RouterVpnStatus.APPLIED;
|
||||
|
||||
if (this.vpnProvisionedAt == null) {
|
||||
this.vpnProvisionedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
public void registerProvisioningResult(
|
||||
boolean success,
|
||||
RouterStatus finalStatus,
|
||||
String message,
|
||||
String firmwareVersion,
|
||||
String validationSummary,
|
||||
String lastError
|
||||
) {
|
||||
Instant now = Instant.now();
|
||||
|
||||
this.status = finalStatus;
|
||||
this.lastValidatedAt = now;
|
||||
this.firmwareVersion = firmwareVersion;
|
||||
this.validationSummary = validationSummary;
|
||||
|
||||
if (success) {
|
||||
this.lastProvisionedAt = now;
|
||||
this.lastProvisioningError = null;
|
||||
} else {
|
||||
this.lastProvisioningError = lastError != null ? lastError : message;
|
||||
}
|
||||
}
|
||||
|
||||
public Instant getLastProvisionedAt() {
|
||||
return lastProvisionedAt;
|
||||
}
|
||||
|
||||
public Instant getLastValidatedAt() {
|
||||
return lastValidatedAt;
|
||||
}
|
||||
|
||||
public String getValidationSummary() {
|
||||
return validationSummary;
|
||||
}
|
||||
|
||||
public String getLastProvisioningError() {
|
||||
return lastProvisioningError;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.litoralregas.vpnprovisioner.router.entity;
|
||||
|
||||
public enum RouterVpnStatus {
|
||||
NOT_PROVISIONED,
|
||||
APPLYING,
|
||||
APPLIED,
|
||||
FAILED
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package com.litoralregas.vpnprovisioner.router.repository;
|
||||
import com.litoralregas.vpnprovisioner.router.entity.Router;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface RouterRepository extends JpaRepository<Router, UUID> {
|
||||
Optional<Router> findByWireguardPublicKey(String wireguardPublicKey);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.litoralregas.vpnprovisioner.router.service;
|
||||
|
||||
import com.litoralregas.vpnprovisioner.router.dto.CreateRouterRequest;
|
||||
import com.litoralregas.vpnprovisioner.router.dto.RouterProvisioningResultRequest;
|
||||
import com.litoralregas.vpnprovisioner.router.dto.RouterResponse;
|
||||
import com.litoralregas.vpnprovisioner.router.dto.UpdateRouterRequest;
|
||||
import com.litoralregas.vpnprovisioner.router.entity.Router;
|
||||
@@ -9,6 +10,7 @@ import com.litoralregas.vpnprovisioner.common.exception.ResourceNotFoundExceptio
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -79,8 +81,37 @@ public class RouterService {
|
||||
router.getFirmwareVersion(),
|
||||
router.getStatus(),
|
||||
router.getVpnIp(),
|
||||
router.getWireguardPublicKey(),
|
||||
router.getEndpointMode(),
|
||||
router.getVpnStatus(),
|
||||
router.getVpnProvisionedAt(),
|
||||
router.getCreatedAt(),
|
||||
router.getUpdatedAt()
|
||||
router.getUpdatedAt(),
|
||||
|
||||
router.getLastProvisionedAt(),
|
||||
router.getLastValidatedAt(),
|
||||
router.getValidationSummary(),
|
||||
router.getLastProvisioningError()
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RouterResponse submitProvisioningResult(
|
||||
UUID id,
|
||||
RouterProvisioningResultRequest request
|
||||
) {
|
||||
Router router = routerRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Router not found: " + id));
|
||||
|
||||
router.registerProvisioningResult(
|
||||
request.success(),
|
||||
request.finalStatus(),
|
||||
request.message(),
|
||||
request.firmwareVersion(),
|
||||
request.validationSummary(),
|
||||
request.lastError()
|
||||
);
|
||||
|
||||
return toResponse(router);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.litoralregas.vpnprovisioner.router.service;
|
||||
|
||||
import com.litoralregas.vpnprovisioner.router.dto.SyncRoutersFromVpsResponse;
|
||||
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.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Service
|
||||
public class RouterSyncService {
|
||||
|
||||
private static final Pattern LINE_PATTERN =
|
||||
Pattern.compile("^(.+?)\\s+([0-9.]+/32)$");
|
||||
|
||||
private final WireGuardVpsService wireGuardVpsService;
|
||||
private final RouterRepository routerRepository;
|
||||
|
||||
public RouterSyncService(
|
||||
WireGuardVpsService wireGuardVpsService,
|
||||
RouterRepository routerRepository
|
||||
) {
|
||||
this.wireGuardVpsService = wireGuardVpsService;
|
||||
this.routerRepository = routerRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SyncRoutersFromVpsResponse syncFromVps() {
|
||||
|
||||
String output = wireGuardVpsService.showAllowedIps();
|
||||
|
||||
List<String> lines = output.lines()
|
||||
.filter(line -> !line.isBlank())
|
||||
.toList();
|
||||
|
||||
int created = 0;
|
||||
int updated = 0;
|
||||
|
||||
for (String line : lines) {
|
||||
|
||||
Matcher matcher = LINE_PATTERN.matcher(line.trim());
|
||||
|
||||
if (!matcher.matches()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String publicKey = matcher.group(1).trim();
|
||||
String allowedIp = matcher.group(2).trim();
|
||||
|
||||
String vpnIp = allowedIp.replace("/32", "");
|
||||
|
||||
Router router = routerRepository
|
||||
.findByWireguardPublicKey(publicKey)
|
||||
.orElse(null);
|
||||
|
||||
if (router == null) {
|
||||
|
||||
Router imported = Router.importedVpnRouter(
|
||||
publicKey,
|
||||
vpnIp
|
||||
);
|
||||
|
||||
routerRepository.save(imported);
|
||||
|
||||
created++;
|
||||
|
||||
} else {
|
||||
|
||||
router.syncVpnPeer(publicKey, vpnIp);
|
||||
|
||||
routerRepository.save(router);
|
||||
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
return new SyncRoutersFromVpsResponse(
|
||||
lines.size(),
|
||||
created,
|
||||
updated
|
||||
);
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
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<String> 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<String> 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(),
|
||||
|
||||
router.getLastProvisionedAt(),
|
||||
router.getLastValidatedAt(),
|
||||
router.getValidationSummary(),
|
||||
router.getLastProvisioningError()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.litoralregas.vpnprovisioner.router.startup;
|
||||
|
||||
import com.litoralregas.vpnprovisioner.router.service.RouterSyncService;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class RouterStartupSync implements ApplicationRunner {
|
||||
|
||||
private final RouterSyncService routerSyncService;
|
||||
|
||||
public RouterStartupSync(RouterSyncService routerSyncService) {
|
||||
this.routerSyncService = routerSyncService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
|
||||
routerSyncService.syncFromVps();
|
||||
|
||||
System.out.println("Router VPS sync completed.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.litoralregas.vpnprovisioner.vps;
|
||||
|
||||
import com.litoralregas.vpnprovisioner.vps.dto.VpsHealthResponse;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/vps")
|
||||
public class VpsController {
|
||||
|
||||
private final WireGuardVpsService wireGuardVpsService;
|
||||
|
||||
public VpsController(WireGuardVpsService wireGuardVpsService) {
|
||||
this.wireGuardVpsService = wireGuardVpsService;
|
||||
}
|
||||
|
||||
@GetMapping(value = "/health", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public VpsHealthResponse health() {
|
||||
return wireGuardVpsService.getVpsHealth();
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
value = "/wireguard/rollback-last-backup",
|
||||
produces = MediaType.APPLICATION_JSON_VALUE
|
||||
)
|
||||
public String rollbackLastBackup() {
|
||||
return wireGuardVpsService.restoreLastWireGuardBackup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.litoralregas.vpnprovisioner.vps;
|
||||
|
||||
public record WireGuardPeerApplyResult(
|
||||
String publicKey,
|
||||
String allowedIps,
|
||||
boolean applied
|
||||
) {
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.litoralregas.vpnprovisioner.vps;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.litoralregas.vpnprovisioner.vps.dto.VpsHealthResponse;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashSet;
|
||||
@@ -14,14 +17,16 @@ public class WireGuardVpsService {
|
||||
Pattern.compile("\\b198\\.19\\.\\d{1,3}\\.\\d{1,3}\\b");
|
||||
|
||||
private final SshService sshService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public WireGuardVpsService(SshService sshService) {
|
||||
public WireGuardVpsService(SshService sshService, ObjectMapper objectMapper) {
|
||||
this.sshService = sshService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public Set<String> findUsedVpnIps() {
|
||||
SshCommandResult result = sshService.executeOnConfiguredVps(
|
||||
"sudo wg show wg0 allowed-ips"
|
||||
"sudo /usr/local/sbin/lr-wg-used-ips"
|
||||
);
|
||||
|
||||
if (result.exitCode() != 0) {
|
||||
@@ -44,4 +49,74 @@ public class WireGuardVpsService {
|
||||
|
||||
return ips;
|
||||
}
|
||||
|
||||
public WireGuardPeerApplyResult applyPeer(String publicKey, String allowedIps) {
|
||||
|
||||
String command = """
|
||||
sudo /usr/local/sbin/lr-wg-add-peer '%s' '%s'
|
||||
""".formatted(publicKey, allowedIps);
|
||||
|
||||
SshCommandResult result = sshService.executeOnConfiguredVps(command);
|
||||
|
||||
if (result.exitCode() != 0) {
|
||||
throw new SshCommandException(
|
||||
"Failed to apply WireGuard peer: " + result.stderr()
|
||||
);
|
||||
}
|
||||
|
||||
return new WireGuardPeerApplyResult(
|
||||
publicKey,
|
||||
allowedIps,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
public VpsHealthResponse getVpsHealth() {
|
||||
SshCommandResult result = sshService.executeOnConfiguredVps(
|
||||
"sudo /usr/local/sbin/lr-vps-health"
|
||||
);
|
||||
|
||||
if (result.exitCode() != 0) {
|
||||
throw new SshCommandException(
|
||||
"Failed to query VPS health: " + result.stderr()
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return objectMapper.readValue(result.stdout(), VpsHealthResponse.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException(
|
||||
"Invalid VPS health JSON returned by script",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public String showAllowedIps() {
|
||||
SshCommandResult result = sshService.executeOnConfiguredVps(
|
||||
"sudo /usr/local/sbin/lr-wg-used-ips"
|
||||
);
|
||||
|
||||
if (result.exitCode() != 0) {
|
||||
throw new SshCommandException(
|
||||
"Failed to query WireGuard allowed IPs: " + result.stderr()
|
||||
);
|
||||
}
|
||||
|
||||
return result.stdout();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.litoralregas.vpnprovisioner.vps.dto;
|
||||
|
||||
public record VpsHealthResponse(
|
||||
String wireGuardInterface,
|
||||
boolean wireGuardRunning,
|
||||
int wireGuardPeerCount,
|
||||
boolean wireGuardConfigExists,
|
||||
String udp2rawService,
|
||||
boolean udp2rawActive,
|
||||
String latestWireGuardBackup,
|
||||
String systemUptime,
|
||||
int diskUsagePercent,
|
||||
int memoryUsagePercent,
|
||||
String loadAverage,
|
||||
String publicIp
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE vpn_ip_allocations
|
||||
ADD COLUMN status VARCHAR(50);
|
||||
|
||||
UPDATE vpn_ip_allocations
|
||||
SET status = CASE
|
||||
WHEN allocated = true THEN 'ALLOCATED'
|
||||
ELSE 'AVAILABLE'
|
||||
END;
|
||||
|
||||
ALTER TABLE vpn_ip_allocations
|
||||
ALTER COLUMN status SET NOT NULL;
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE wireguard_peers (
|
||||
id UUID PRIMARY KEY,
|
||||
router_id UUID NOT NULL UNIQUE,
|
||||
public_key VARCHAR(255) NOT NULL UNIQUE,
|
||||
vpn_ip VARCHAR(45) NOT NULL,
|
||||
allowed_ips VARCHAR(100) NOT NULL,
|
||||
endpoint_mode VARCHAR(50) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
|
||||
CONSTRAINT fk_wireguard_peers_router
|
||||
FOREIGN KEY (router_id)
|
||||
REFERENCES routers(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_wireguard_peers_status
|
||||
ON wireguard_peers(status);
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS wireguard_peers;
|
||||
DROP TABLE IF EXISTS vpn_ip_allocations;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE routers
|
||||
ADD COLUMN last_provisioned_at TIMESTAMP,
|
||||
ADD COLUMN last_validated_at TIMESTAMP,
|
||||
ADD COLUMN validation_summary TEXT,
|
||||
ADD COLUMN last_provisioning_error TEXT;
|
||||
Reference in New Issue
Block a user