Stable core service

This commit is contained in:
litoral05
2026-05-08 14:14:23 +01:00
parent c9439dfb7e
commit 2545f285eb
20 changed files with 754 additions and 0 deletions
@@ -0,0 +1,69 @@
package com.litoralregas.vpnorchestrator.auth;
import com.litoralregas.vpnorchestrator.config.AppSecurityProperties;
import jakarta.servlet.FilterChain;
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;
import java.io.IOException;
import java.util.List;
public class ApiKeyAuthFilter extends OncePerRequestFilter {
private static final String API_KEY_HEADER = "X-API-Key";
private final AppSecurityProperties securityProperties;
public ApiKeyAuthFilter(AppSecurityProperties securityProperties) {
this.securityProperties = securityProperties;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return "/actuator/health".equals(request.getRequestURI());
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String providedApiKey = request.getHeader(API_KEY_HEADER);
String expectedApiKey = securityProperties.getApiKey();
if (!StringUtils.hasText(expectedApiKey)) {
response.sendError(
HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"API key is not configured"
);
return;
}
if (!expectedApiKey.equals(providedApiKey)) {
response.sendError(
HttpServletResponse.SC_UNAUTHORIZED,
"Invalid API key"
);
return;
}
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
"api-key-client",
null,
List.of(new SimpleGrantedAuthority("ROLE_API"))
);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,12 @@
package com.litoralregas.vpnorchestrator.common.exception;
import java.time.Instant;
public record ApiErrorResponse(
Instant timestamp,
int status,
String error,
String message,
String path
) {
}
@@ -0,0 +1,65 @@
package com.litoralregas.vpnorchestrator.common.exception;
import com.litoralregas.vpnorchestrator.vps.SshCommandException;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiErrorResponse handleValidation(
MethodArgumentNotValidException exception,
HttpServletRequest request
) {
String message = exception.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
return new ApiErrorResponse(
Instant.now(),
HttpStatus.BAD_REQUEST.value(),
"Bad Request",
message,
request.getRequestURI()
);
}
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiErrorResponse handleIllegalArgument(
IllegalArgumentException exception,
HttpServletRequest request
) {
return new ApiErrorResponse(
Instant.now(),
HttpStatus.BAD_REQUEST.value(),
"Bad Request",
exception.getMessage(),
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()
);
}
}
@@ -0,0 +1,17 @@
package com.litoralregas.vpnorchestrator.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.security")
public class AppSecurityProperties {
private String apiKey;
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
}
@@ -0,0 +1,45 @@
package com.litoralregas.vpnorchestrator.config;
import com.litoralregas.vpnorchestrator.auth.ApiKeyAuthFilter;
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;
@Configuration
@EnableConfigurationProperties({
AppSecurityProperties.class,
VpsSshProperties.class
})
public class SecurityConfig {
private final AppSecurityProperties securityProperties;
public SecurityConfig(AppSecurityProperties securityProperties) {
this.securityProperties = securityProperties;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
ApiKeyAuthFilter apiKeyAuthFilter = new ApiKeyAuthFilter(securityProperties);
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()
)
.addFilterBefore(apiKeyAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
@@ -0,0 +1,71 @@
package com.litoralregas.vpnorchestrator.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;
}
}
@@ -0,0 +1,59 @@
package com.litoralregas.vpnorchestrator.vpn;
import com.litoralregas.vpnorchestrator.vpn.dto.AvailableVpnIpResponse;
import com.litoralregas.vpnorchestrator.vpn.dto.PeerApplyResponse;
import com.litoralregas.vpnorchestrator.vpn.dto.UpsertPeerRequest;
import com.litoralregas.vpnorchestrator.vpn.dto.UsedVpnIpsResponse;
import com.litoralregas.vpnorchestrator.vps.WireGuardService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.Set;
@RestController
@RequestMapping("/api/vpn")
public class VpnController {
private final WireGuardService wireGuardService;
private final VpnIpAllocatorService vpnIpAllocatorService;
public VpnController(
WireGuardService wireGuardService,
VpnIpAllocatorService vpnIpAllocatorService
) {
this.wireGuardService = wireGuardService;
this.vpnIpAllocatorService = vpnIpAllocatorService;
}
@GetMapping("/available-ip")
public AvailableVpnIpResponse availableIp() {
Set<String> usedIps = wireGuardService.findUsedVpnIps();
String vpnIp = vpnIpAllocatorService.nextAvailableIp(usedIps);
return new AvailableVpnIpResponse(vpnIp);
}
@PostMapping("/peers")
public PeerApplyResponse upsertPeer(@Valid @RequestBody UpsertPeerRequest request) {
wireGuardService.applyPeer(
request.publicKey(),
request.vpnIp() + "/32"
);
return new PeerApplyResponse(
request.vpnIp(),
request.publicKey(),
true
);
}
@GetMapping("/used-ips")
public UsedVpnIpsResponse usedIps() {
Set<String> usedIps = wireGuardService.findUsedVpnIps();
return new UsedVpnIpsResponse(
usedIps,
usedIps.size()
);
}
}
@@ -0,0 +1,23 @@
package com.litoralregas.vpnorchestrator.vpn;
import org.springframework.stereotype.Service;
import java.util.Set;
@Service
public class VpnIpAllocatorService {
public String nextAvailableIp(Set<String> usedIps) {
for (int third = 1; third <= 254; third++) {
for (int fourth = 1; fourth <= 254; fourth++) {
String candidate = "198.19." + third + "." + fourth;
if (!usedIps.contains(candidate)) {
return candidate;
}
}
}
throw new IllegalStateException("No available VPN IPs in 198.19.0.0/16 pool");
}
}
@@ -0,0 +1,6 @@
package com.litoralregas.vpnorchestrator.vpn.dto;
public record AvailableVpnIpResponse(
String vpnIp
) {
}
@@ -0,0 +1,8 @@
package com.litoralregas.vpnorchestrator.vpn.dto;
public record PeerApplyResponse(
String vpnIp,
String publicKey,
boolean applied
) {
}
@@ -0,0 +1,22 @@
package com.litoralregas.vpnorchestrator.vpn.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
public record UpsertPeerRequest(
@NotBlank
@Pattern(
regexp = "^198\\.19\\.(?:[1-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-4])\\.(?:[1-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-4])$",
message = "VPN IP must be inside allocatable range 198.19.1.1 - 198.19.254.254"
)
String vpnIp,
@NotBlank
@Pattern(
regexp = "^[A-Za-z0-9+/]{43}=$",
message = "Invalid WireGuard public key format"
)
String publicKey
) {
}
@@ -0,0 +1,9 @@
package com.litoralregas.vpnorchestrator.vpn.dto;
import java.util.Set;
public record UsedVpnIpsResponse(
Set<String> usedIps,
int count
) {
}
@@ -0,0 +1,12 @@
package com.litoralregas.vpnorchestrator.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.vpnorchestrator.vps;
public record SshCommandResult(
int exitCode,
String stdout,
String stderr
) {
}
@@ -0,0 +1,126 @@
package com.litoralregas.vpnorchestrator.vps;
import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
import com.litoralregas.vpnorchestrator.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,29 @@
package com.litoralregas.vpnorchestrator.vps;
import com.litoralregas.vpnorchestrator.vps.dto.VpsHealthResponse;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/vps")
public class VpsController {
private final WireGuardService wireGuardService;
public VpsController(WireGuardService wireGuardService) {
this.wireGuardService = wireGuardService;
}
@GetMapping(value = "/health", produces = MediaType.APPLICATION_JSON_VALUE)
public VpsHealthResponse health() {
return wireGuardService.getVpsHealth();
}
@PostMapping(
value = "/wireguard/rollback-last-backup",
produces = MediaType.APPLICATION_JSON_VALUE
)
public String rollbackLastBackup() {
return wireGuardService.restoreLastWireGuardBackup();
}
}
@@ -0,0 +1,8 @@
package com.litoralregas.vpnorchestrator.vps;
public record WireGuardPeerApplyResult(
String publicKey,
String allowedIps,
boolean applied
) {
}
@@ -0,0 +1,126 @@
package com.litoralregas.vpnorchestrator.vps;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.litoralregas.vpnorchestrator.vps.dto.VpsHealthResponse;
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 WireGuardService {
private static final Pattern VPN_IP_PATTERN =
Pattern.compile("\\b198\\.19\\.\\d{1,3}\\.\\d{1,3}\\b");
private final SshService sshService;
private final ObjectMapper objectMapper;
public WireGuardService(SshService sshService, ObjectMapper objectMapper) {
this.sshService = sshService;
this.objectMapper = objectMapper;
}
public Set<String> findUsedVpnIps() {
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 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;
}
public WireGuardPeerApplyResult applyPeer(String publicKey, String allowedIps) {
String command = "sudo /usr/local/sbin/lr-wg-add-peer "
+ shellQuote(publicKey) + " "
+ shellQuote(allowedIps);
SshCommandResult result = sshService.executeOnConfiguredVps(command);
if (result.exitCode() != 0) {
throw new SshCommandException(
"Failed to apply WireGuard peer: " + result.stderr() + result.stdout()
);
}
return new WireGuardPeerApplyResult(
publicKey,
allowedIps,
true
);
}
private String shellQuote(String value) {
return "'" + value.replace("'", "'\"'\"'") + "'";
}
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.vpnorchestrator.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
) {
}
+22
View File
@@ -1,3 +1,25 @@
spring: spring:
application: application:
name: vpn-orchestrator name: vpn-orchestrator
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health
app:
security:
api-key: dev-api-key
vps:
ssh:
host: 146.59.230.190
port: 22
username: lr-vpn
password: hidrotek2026
connect-timeout-ms: 10000
command-timeout-ms: 15000