diff --git a/src/main/java/com/litoralregas/vpnprovisioner/vps/WireGuardPeerApplyResult.java b/src/main/java/com/litoralregas/vpnprovisioner/vps/WireGuardPeerApplyResult.java new file mode 100644 index 0000000..48f2572 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnprovisioner/vps/WireGuardPeerApplyResult.java @@ -0,0 +1,8 @@ +package com.litoralregas.vpnprovisioner.vps; + +public record WireGuardPeerApplyResult( + String publicKey, + String allowedIps, + boolean applied +) { +} \ 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 5c1146f..0d04bd0 100644 --- a/src/main/java/com/litoralregas/vpnprovisioner/vps/WireGuardVpsService.java +++ b/src/main/java/com/litoralregas/vpnprovisioner/vps/WireGuardVpsService.java @@ -44,4 +44,25 @@ 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 + ); + } } \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnprovisioner/wireguard/controller/WireGuardPeerController.java b/src/main/java/com/litoralregas/vpnprovisioner/wireguard/controller/WireGuardPeerController.java new file mode 100644 index 0000000..e9f7420 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnprovisioner/wireguard/controller/WireGuardPeerController.java @@ -0,0 +1,30 @@ +package com.litoralregas.vpnprovisioner.wireguard.controller; + +import com.litoralregas.vpnprovisioner.wireguard.dto.CreateWireGuardPeerRequest; +import com.litoralregas.vpnprovisioner.wireguard.dto.WireGuardPeerResponse; +import com.litoralregas.vpnprovisioner.wireguard.service.WireGuardPeerService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/routers/{routerId}/wireguard-peer") +public class WireGuardPeerController { + + private final WireGuardPeerService wireGuardPeerService; + + public WireGuardPeerController(WireGuardPeerService wireGuardPeerService) { + this.wireGuardPeerService = wireGuardPeerService; + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public WireGuardPeerResponse create( + @PathVariable UUID routerId, + @Valid @RequestBody CreateWireGuardPeerRequest request + ) { + return wireGuardPeerService.createForRouter(routerId, request); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnprovisioner/wireguard/dto/CreateWireGuardPeerRequest.java b/src/main/java/com/litoralregas/vpnprovisioner/wireguard/dto/CreateWireGuardPeerRequest.java new file mode 100644 index 0000000..9002b44 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnprovisioner/wireguard/dto/CreateWireGuardPeerRequest.java @@ -0,0 +1,11 @@ +package com.litoralregas.vpnprovisioner.wireguard.dto; + +import com.litoralregas.vpnprovisioner.wireguard.entity.EndpointMode; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record CreateWireGuardPeerRequest( + @NotBlank String publicKey, + @NotNull EndpointMode endpointMode +) { +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnprovisioner/wireguard/dto/WireGuardPeerResponse.java b/src/main/java/com/litoralregas/vpnprovisioner/wireguard/dto/WireGuardPeerResponse.java new file mode 100644 index 0000000..d86ad72 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnprovisioner/wireguard/dto/WireGuardPeerResponse.java @@ -0,0 +1,20 @@ +package com.litoralregas.vpnprovisioner.wireguard.dto; + +import com.litoralregas.vpnprovisioner.wireguard.entity.EndpointMode; +import com.litoralregas.vpnprovisioner.wireguard.entity.WireGuardPeerStatus; + +import java.time.Instant; +import java.util.UUID; + +public record WireGuardPeerResponse( + UUID id, + UUID routerId, + String publicKey, + String vpnIp, + String allowedIps, + EndpointMode endpointMode, + WireGuardPeerStatus status, + Instant createdAt, + Instant updatedAt +) { +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnprovisioner/wireguard/entity/EndpointMode.java b/src/main/java/com/litoralregas/vpnprovisioner/wireguard/entity/EndpointMode.java new file mode 100644 index 0000000..9ba87b3 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnprovisioner/wireguard/entity/EndpointMode.java @@ -0,0 +1,6 @@ +package com.litoralregas.vpnprovisioner.wireguard.entity; + +public enum EndpointMode { + NORMAL_WIREGUARD, + UDP2RAW +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnprovisioner/wireguard/entity/WireGuardPeer.java b/src/main/java/com/litoralregas/vpnprovisioner/wireguard/entity/WireGuardPeer.java new file mode 100644 index 0000000..708604e --- /dev/null +++ b/src/main/java/com/litoralregas/vpnprovisioner/wireguard/entity/WireGuardPeer.java @@ -0,0 +1,80 @@ +package com.litoralregas.vpnprovisioner.wireguard.entity; + +import jakarta.persistence.*; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "wireguard_peers") +public class WireGuardPeer { + + @Id + private UUID id; + + @Column(nullable = false, unique = true) + private UUID routerId; + + @Column(nullable = false, unique = true) + private String publicKey; + + @Column(nullable = false) + private String vpnIp; + + @Column(nullable = false) + private String allowedIps; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private EndpointMode endpointMode; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private WireGuardPeerStatus status; + + @Column(nullable = false) + private Instant createdAt; + + @Column(nullable = false) + private Instant updatedAt; + + protected WireGuardPeer() { + } + + public WireGuardPeer(UUID routerId, String publicKey, String vpnIp, EndpointMode endpointMode) { + this.id = UUID.randomUUID(); + this.routerId = routerId; + this.publicKey = publicKey; + this.vpnIp = vpnIp; + this.allowedIps = vpnIp + "/32"; + this.endpointMode = endpointMode; + this.status = WireGuardPeerStatus.PENDING_APPLY; + this.createdAt = Instant.now(); + this.updatedAt = Instant.now(); + } + + @PreUpdate + public void preUpdate() { + this.updatedAt = Instant.now(); + } + + public UUID getId() { return id; } + public UUID getRouterId() { return routerId; } + public String getPublicKey() { return publicKey; } + public String getVpnIp() { return vpnIp; } + public String getAllowedIps() { return allowedIps; } + public EndpointMode getEndpointMode() { return endpointMode; } + public WireGuardPeerStatus getStatus() { return status; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } + + public void markApplied() { + this.status = WireGuardPeerStatus.APPLIED; + this.updatedAt = Instant.now(); + } + + public void markFailed() { + this.status = WireGuardPeerStatus.FAILED; + this.updatedAt = Instant.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnprovisioner/wireguard/entity/WireGuardPeerStatus.java b/src/main/java/com/litoralregas/vpnprovisioner/wireguard/entity/WireGuardPeerStatus.java new file mode 100644 index 0000000..6a09250 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnprovisioner/wireguard/entity/WireGuardPeerStatus.java @@ -0,0 +1,8 @@ +package com.litoralregas.vpnprovisioner.wireguard.entity; + +public enum WireGuardPeerStatus { + PENDING_APPLY, + APPLIED, + FAILED, + REVOKED +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnprovisioner/wireguard/repository/WireGuardPeerRepository.java b/src/main/java/com/litoralregas/vpnprovisioner/wireguard/repository/WireGuardPeerRepository.java new file mode 100644 index 0000000..cd52787 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnprovisioner/wireguard/repository/WireGuardPeerRepository.java @@ -0,0 +1,14 @@ +package com.litoralregas.vpnprovisioner.wireguard.repository; + +import com.litoralregas.vpnprovisioner.wireguard.entity.WireGuardPeer; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface WireGuardPeerRepository extends JpaRepository { + + Optional findByRouterId(UUID routerId); + + boolean existsByPublicKey(String publicKey); +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnprovisioner/wireguard/service/WireGuardPeerService.java b/src/main/java/com/litoralregas/vpnprovisioner/wireguard/service/WireGuardPeerService.java new file mode 100644 index 0000000..e9e1910 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnprovisioner/wireguard/service/WireGuardPeerService.java @@ -0,0 +1,93 @@ +package com.litoralregas.vpnprovisioner.wireguard.service; + +import com.litoralregas.vpnprovisioner.common.exception.ResourceNotFoundException; +import com.litoralregas.vpnprovisioner.ipam.dto.VpnIpAllocationResponse; +import com.litoralregas.vpnprovisioner.ipam.service.VpnIpAllocationService; +import com.litoralregas.vpnprovisioner.router.repository.RouterRepository; +import com.litoralregas.vpnprovisioner.vps.WireGuardVpsService; +import com.litoralregas.vpnprovisioner.wireguard.dto.CreateWireGuardPeerRequest; +import com.litoralregas.vpnprovisioner.wireguard.dto.WireGuardPeerResponse; +import com.litoralregas.vpnprovisioner.wireguard.entity.WireGuardPeer; +import com.litoralregas.vpnprovisioner.wireguard.repository.WireGuardPeerRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Service +public class WireGuardPeerService { + + private final WireGuardPeerRepository wireGuardPeerRepository; + private final RouterRepository routerRepository; + private final VpnIpAllocationService vpnIpAllocationService; + private final WireGuardVpsService wireGuardVpsService; + + public WireGuardPeerService( + WireGuardPeerRepository wireGuardPeerRepository, + RouterRepository routerRepository, + VpnIpAllocationService vpnIpAllocationService, + WireGuardVpsService wireGuardVpsService) { + this.wireGuardPeerRepository = wireGuardPeerRepository; + this.routerRepository = routerRepository; + this.vpnIpAllocationService = vpnIpAllocationService; + this.wireGuardVpsService = wireGuardVpsService; + } + + @Transactional + public WireGuardPeerResponse createForRouter(UUID routerId, CreateWireGuardPeerRequest request) { + routerRepository.findById(routerId) + .orElseThrow(() -> new ResourceNotFoundException("Router not found: " + routerId)); + + return wireGuardPeerRepository.findByRouterId(routerId) + .map(this::toResponse) + .orElseGet(() -> createNewPeer(routerId, request)); + } + + private WireGuardPeerResponse createNewPeer(UUID routerId, CreateWireGuardPeerRequest request) { + if (wireGuardPeerRepository.existsByPublicKey(request.publicKey())) { + throw new IllegalArgumentException("WireGuard public key already exists"); + } + + VpnIpAllocationResponse allocation = + vpnIpAllocationService.allocateForRouter(routerId); + + WireGuardPeer peer = new WireGuardPeer( + routerId, + request.publicKey(), + allocation.ipAddress(), + request.endpointMode() + ); + + WireGuardPeer saved = wireGuardPeerRepository.save(peer); + + + try { + wireGuardVpsService.applyPeer( + saved.getPublicKey(), + saved.getAllowedIps() + ); + + saved.markApplied(); + + } catch (RuntimeException exception) { + saved.markFailed(); + throw exception; + } + + return toResponse(saved); + } + + private WireGuardPeerResponse toResponse(WireGuardPeer peer) { + return new WireGuardPeerResponse( + peer.getId(), + peer.getRouterId(), + peer.getPublicKey(), + peer.getVpnIp(), + peer.getAllowedIps(), + peer.getEndpointMode(), + peer.getStatus(), + peer.getCreatedAt(), + peer.getUpdatedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/resources/db/migration/V4__create_wireguard_peers.sql b/src/main/resources/db/migration/V4__create_wireguard_peers.sql new file mode 100644 index 0000000..8a2e7b0 --- /dev/null +++ b/src/main/resources/db/migration/V4__create_wireguard_peers.sql @@ -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); \ No newline at end of file