529 lines
15 KiB
Rust
529 lines
15 KiB
Rust
use crate::error::{ApiError, ApiResult};
|
|
use crate::routes::auth::GenericSuccess;
|
|
use crate::state::{AppState, SystemEvent};
|
|
use axum::Json;
|
|
use axum::extract::{Path, State};
|
|
use chrono::Utc;
|
|
use nx9_wg_core::crypto::generate_keypair;
|
|
use nx9_wg_core::types::wireguard::{
|
|
Interface, InterfaceRole, WireGuardPrivateKey, WireGuardPublicKey,
|
|
};
|
|
use nx9_wg_core::validation::{
|
|
validate_cidr, validate_interface_name, validate_listen_port, validate_mtu,
|
|
};
|
|
use nx9_wireguard::UpstreamConfigParser;
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateInterfaceRequest {
|
|
pub name: String,
|
|
pub role: Option<InterfaceRole>,
|
|
pub listen_port: Option<u16>,
|
|
pub address_v4: String,
|
|
pub address_v6: Option<String>,
|
|
pub mtu: Option<u16>,
|
|
pub dns: Option<String>,
|
|
pub private_key: Option<String>,
|
|
pub public_key: Option<String>,
|
|
pub pre_up: Option<String>,
|
|
pub post_up: Option<String>,
|
|
pub pre_down: Option<String>,
|
|
pub post_down: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpstreamPreviewRequest {
|
|
pub name: String,
|
|
pub config: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct UpstreamPreviewResponse {
|
|
pub name: String,
|
|
pub role: String,
|
|
pub address_v4: String,
|
|
pub address_v6: Option<String>,
|
|
pub dns: Option<String>,
|
|
pub mtu: Option<u16>,
|
|
pub listen_port: Option<u16>,
|
|
pub peer_count: usize,
|
|
pub provider_public_key: String,
|
|
pub provider_endpoint: String,
|
|
pub provider_allowed_ips: String,
|
|
pub persistent_keepalive: Option<u16>,
|
|
pub preshared_key_configured: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpstreamImportRequest {
|
|
pub name: String,
|
|
pub config: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct UpstreamImportResponse {
|
|
pub interface_id: Uuid,
|
|
pub peer_id: Uuid,
|
|
pub name: String,
|
|
pub role: String,
|
|
pub address_v4: String,
|
|
pub address_v6: Option<String>,
|
|
pub dns: Option<String>,
|
|
pub mtu: Option<u16>,
|
|
pub listen_port: Option<u16>,
|
|
pub provider_public_key: String,
|
|
pub provider_endpoint: String,
|
|
pub provider_allowed_ips: String,
|
|
pub persistent_keepalive: Option<u16>,
|
|
pub preshared_key_configured: bool,
|
|
pub enabled: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdateInterfaceRequest {
|
|
pub name: Option<String>,
|
|
pub listen_port: Option<u16>,
|
|
pub address_v4: Option<String>,
|
|
pub address_v6: Option<String>,
|
|
pub mtu: Option<u16>,
|
|
pub dns: Option<String>,
|
|
pub enabled: Option<bool>,
|
|
pub pre_up: Option<String>,
|
|
pub post_up: Option<String>,
|
|
pub pre_down: Option<String>,
|
|
pub post_down: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct InterfaceStatusResponse {
|
|
pub interface: Interface,
|
|
pub peer_count: usize,
|
|
pub active_peer_count: usize,
|
|
}
|
|
|
|
/// GET /api/v1/interfaces
|
|
pub async fn list_interfaces_handler(
|
|
State(state): State<AppState>,
|
|
) -> ApiResult<Json<Vec<Interface>>> {
|
|
let list = state.store.list_interfaces().await?;
|
|
Ok(Json(list))
|
|
}
|
|
|
|
/// POST /api/v1/interfaces
|
|
pub async fn create_interface_handler(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<CreateInterfaceRequest>,
|
|
) -> ApiResult<Json<Interface>> {
|
|
validate_interface_name(&payload.name)?;
|
|
let role = payload.role.unwrap_or(if payload.name == "wg0" {
|
|
InterfaceRole::Overlay
|
|
} else {
|
|
InterfaceRole::Upstream
|
|
});
|
|
|
|
if role == InterfaceRole::Overlay {
|
|
let existing = state.store.list_interfaces().await?;
|
|
if existing.iter().any(|i| i.role == InterfaceRole::Overlay) {
|
|
return Err(ApiError::Conflict(
|
|
"Only one Overlay interface ('wg0') is permitted".to_string(),
|
|
));
|
|
}
|
|
if payload.name != "wg0" {
|
|
return Err(ApiError::Validation(
|
|
"The primary overlay interface must be named 'wg0'".to_string(),
|
|
));
|
|
}
|
|
} else if payload.name == "wg0" {
|
|
return Err(ApiError::Validation(
|
|
"An Upstream interface cannot use the reserved name 'wg0'".to_string(),
|
|
));
|
|
}
|
|
|
|
let address_v4 = validate_cidr(&payload.address_v4)?;
|
|
let address_v6 = match payload.address_v6.as_deref() {
|
|
Some(s) if !s.trim().is_empty() => Some(validate_cidr(s)?),
|
|
_ => None,
|
|
};
|
|
|
|
let listen_port = match payload.listen_port {
|
|
Some(p) => Some(validate_listen_port(p)?),
|
|
None => {
|
|
if role == InterfaceRole::Overlay {
|
|
Some(51820)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
};
|
|
|
|
if let Some(m) = payload.mtu {
|
|
validate_mtu(m)?;
|
|
}
|
|
|
|
let (priv_k, pub_k) = match (payload.private_key, payload.public_key) {
|
|
(Some(priv_s), Some(pub_s)) => (
|
|
WireGuardPrivateKey::new(priv_s),
|
|
WireGuardPublicKey::new(pub_s),
|
|
),
|
|
_ => generate_keypair(),
|
|
};
|
|
|
|
let now = Utc::now().naive_utc();
|
|
let iface = Interface {
|
|
id: Uuid::new_v4(),
|
|
name: payload.name,
|
|
role,
|
|
private_key: priv_k,
|
|
public_key: pub_k,
|
|
listen_port,
|
|
address_v4,
|
|
address_v6,
|
|
mtu: payload.mtu,
|
|
dns: payload.dns,
|
|
enabled: true,
|
|
pre_up: payload.pre_up,
|
|
post_up: payload.post_up,
|
|
pre_down: payload.pre_down,
|
|
post_down: payload.post_down,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
|
|
state.store.create_interface(&iface).await?;
|
|
|
|
state.broadcast(SystemEvent::InterfaceChanged {
|
|
id: iface.id.to_string(),
|
|
action: "created".to_string(),
|
|
});
|
|
|
|
Ok(Json(iface))
|
|
}
|
|
|
|
/// POST /api/v1/interfaces/upstreams/preview
|
|
pub async fn preview_upstream_handler(
|
|
Json(payload): Json<UpstreamPreviewRequest>,
|
|
) -> ApiResult<Json<UpstreamPreviewResponse>> {
|
|
let parsed = UpstreamConfigParser::parse(&payload.config, &payload.name)
|
|
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
|
|
|
Ok(Json(UpstreamPreviewResponse {
|
|
name: parsed.interface_name,
|
|
role: "upstream".to_string(),
|
|
address_v4: parsed.address_v4.to_string(),
|
|
address_v6: parsed.address_v6.map(|ip| ip.to_string()),
|
|
dns: parsed.dns,
|
|
mtu: parsed.mtu,
|
|
listen_port: parsed.listen_port,
|
|
peer_count: 1,
|
|
provider_public_key: parsed.peer.public_key.as_str().to_string(),
|
|
provider_endpoint: parsed.peer.endpoint,
|
|
provider_allowed_ips: parsed.peer.allowed_ips,
|
|
persistent_keepalive: parsed.peer.persistent_keepalive,
|
|
preshared_key_configured: parsed.peer.preshared_key.is_some(),
|
|
}))
|
|
}
|
|
|
|
/// POST /api/v1/interfaces/upstreams/import
|
|
pub async fn import_upstream_handler(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<UpstreamImportRequest>,
|
|
) -> ApiResult<Json<UpstreamImportResponse>> {
|
|
let parsed = UpstreamConfigParser::parse(&payload.config, &payload.name)
|
|
.map_err(|e| ApiError::Validation(e.to_string()))?;
|
|
|
|
// Check for interface name collision
|
|
if state
|
|
.store
|
|
.get_interface_by_name(&parsed.interface_name)
|
|
.await?
|
|
.is_some()
|
|
{
|
|
return Err(ApiError::Conflict(format!(
|
|
"An interface named '{}' already exists",
|
|
parsed.interface_name
|
|
)));
|
|
}
|
|
|
|
let interface_id = Uuid::new_v4();
|
|
let peer_id = Uuid::new_v4();
|
|
let psk_configured = parsed.peer.preshared_key.is_some();
|
|
let (iface, peer) = parsed.into_desired_state(interface_id, peer_id);
|
|
|
|
// Persist desired state transactionally
|
|
state.store.create_interface(&iface).await?;
|
|
if let Err(e) = state.store.create_peer(&peer).await {
|
|
let _ = state.store.delete_interface(iface.id).await;
|
|
return Err(ApiError::from(e));
|
|
}
|
|
|
|
// Synchronize to kernel / runtime state
|
|
if let Err(e) = state
|
|
.wg_engine
|
|
.sync_interface(&iface, &[peer.clone()])
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
interface = %iface.name,
|
|
error = %e,
|
|
"Kernel sync failed after upstream import"
|
|
);
|
|
}
|
|
|
|
state.broadcast(SystemEvent::InterfaceChanged {
|
|
id: iface.id.to_string(),
|
|
action: "imported".to_string(),
|
|
});
|
|
|
|
Ok(Json(UpstreamImportResponse {
|
|
interface_id: iface.id,
|
|
peer_id: peer.id,
|
|
name: iface.name,
|
|
role: iface.role.to_string(),
|
|
address_v4: iface.address_v4.to_string(),
|
|
address_v6: iface.address_v6.map(|ip| ip.to_string()),
|
|
dns: iface.dns,
|
|
mtu: iface.mtu,
|
|
listen_port: iface.listen_port,
|
|
provider_public_key: peer.public_key.as_str().to_string(),
|
|
provider_endpoint: peer.endpoint.unwrap_or_default(),
|
|
provider_allowed_ips: peer.allowed_ips,
|
|
persistent_keepalive: peer.persistent_keepalive,
|
|
preshared_key_configured: psk_configured,
|
|
enabled: iface.enabled,
|
|
}))
|
|
}
|
|
|
|
/// GET /api/v1/interfaces/{id}
|
|
pub async fn get_interface_handler(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
) -> ApiResult<Json<Interface>> {
|
|
let iface = state
|
|
.store
|
|
.get_interface(id)
|
|
.await?
|
|
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
|
|
Ok(Json(iface))
|
|
}
|
|
|
|
/// PUT /api/v1/interfaces/{id}
|
|
pub async fn update_interface_handler(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
Json(payload): Json<UpdateInterfaceRequest>,
|
|
) -> ApiResult<Json<Interface>> {
|
|
let mut iface = state
|
|
.store
|
|
.get_interface(id)
|
|
.await?
|
|
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
|
|
|
|
if let Some(ref name) = payload.name {
|
|
let trimmed = name.trim();
|
|
validate_interface_name(trimmed)?;
|
|
if iface.name != trimmed {
|
|
if let Ok(Some(existing)) = state.store.get_interface_by_name(trimmed).await
|
|
&& existing.id != iface.id
|
|
{
|
|
return Err(ApiError::Validation(format!(
|
|
"Interface with name '{trimmed}' already exists"
|
|
)));
|
|
}
|
|
iface.name = trimmed.to_string();
|
|
}
|
|
}
|
|
if let Some(port) = payload.listen_port {
|
|
validate_listen_port(port)?;
|
|
iface.listen_port = Some(port);
|
|
}
|
|
if let Some(ref v4) = payload.address_v4 {
|
|
iface.address_v4 = validate_cidr(v4)?;
|
|
}
|
|
if let Some(ref v6) = payload.address_v6 {
|
|
if v6.trim().is_empty() {
|
|
iface.address_v6 = None;
|
|
} else {
|
|
iface.address_v6 = Some(validate_cidr(v6.trim())?);
|
|
}
|
|
}
|
|
if let Some(m) = payload.mtu {
|
|
validate_mtu(m)?;
|
|
iface.mtu = Some(m);
|
|
}
|
|
if let Some(ref dns) = payload.dns {
|
|
if dns.trim().is_empty() {
|
|
iface.dns = None;
|
|
} else {
|
|
iface.dns = Some(dns.trim().to_string());
|
|
}
|
|
}
|
|
if let Some(en) = payload.enabled {
|
|
iface.enabled = en;
|
|
}
|
|
if payload.pre_up.is_some() {
|
|
iface.pre_up = payload.pre_up;
|
|
}
|
|
if payload.post_up.is_some() {
|
|
iface.post_up = payload.post_up;
|
|
}
|
|
if payload.pre_down.is_some() {
|
|
iface.pre_down = payload.pre_down;
|
|
}
|
|
if payload.post_down.is_some() {
|
|
iface.post_down = payload.post_down;
|
|
}
|
|
|
|
iface.updated_at = Utc::now().naive_utc();
|
|
|
|
state.store.update_interface(&iface).await?;
|
|
|
|
state.broadcast(SystemEvent::InterfaceChanged {
|
|
id: iface.id.to_string(),
|
|
action: "updated".to_string(),
|
|
});
|
|
|
|
Ok(Json(iface))
|
|
}
|
|
|
|
/// DELETE /api/v1/interfaces/{id}
|
|
pub async fn delete_interface_handler(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
) -> ApiResult<Json<GenericSuccess>> {
|
|
let iface = state
|
|
.store
|
|
.get_interface(id)
|
|
.await?
|
|
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
|
|
|
|
if iface.name == "wg0" {
|
|
return Err(ApiError::Forbidden(
|
|
"The primary overlay interface 'wg0' cannot be deleted".to_string(),
|
|
));
|
|
}
|
|
|
|
// 1. Attempt kernel deletion
|
|
let _ = state.wg_engine.delete_interface(&iface.name).await;
|
|
|
|
// 2. Delete from DB
|
|
state.store.delete_interface(id).await?;
|
|
|
|
state.broadcast(SystemEvent::InterfaceChanged {
|
|
id: id.to_string(),
|
|
action: "deleted".to_string(),
|
|
});
|
|
|
|
Ok(Json(GenericSuccess {
|
|
success: true,
|
|
message: format!("Interface '{id}' and all associated peers deleted"),
|
|
}))
|
|
}
|
|
|
|
/// POST /api/v1/interfaces/{id}/enable
|
|
pub async fn enable_interface_handler(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
) -> ApiResult<Json<GenericSuccess>> {
|
|
state.store.set_interface_enabled(id, true).await?;
|
|
|
|
state.broadcast(SystemEvent::InterfaceChanged {
|
|
id: id.to_string(),
|
|
action: "enabled".to_string(),
|
|
});
|
|
|
|
Ok(Json(GenericSuccess {
|
|
success: true,
|
|
message: format!("Interface '{id}' enabled"),
|
|
}))
|
|
}
|
|
|
|
/// POST /api/v1/interfaces/{id}/disable
|
|
pub async fn disable_interface_handler(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
) -> ApiResult<Json<GenericSuccess>> {
|
|
let iface = state
|
|
.store
|
|
.get_interface(id)
|
|
.await?
|
|
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
|
|
|
|
if iface.name == "wg0" {
|
|
return Err(ApiError::Forbidden(
|
|
"The primary overlay interface 'wg0' cannot be disabled".to_string(),
|
|
));
|
|
}
|
|
|
|
state.store.set_interface_enabled(id, false).await?;
|
|
|
|
state.broadcast(SystemEvent::InterfaceChanged {
|
|
id: id.to_string(),
|
|
action: "disabled".to_string(),
|
|
});
|
|
|
|
Ok(Json(GenericSuccess {
|
|
success: true,
|
|
message: format!("Interface '{id}' disabled"),
|
|
}))
|
|
}
|
|
|
|
/// GET /api/v1/interfaces/{id}/status
|
|
pub async fn interface_status_handler(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
) -> ApiResult<Json<InterfaceStatusResponse>> {
|
|
let iface = state
|
|
.store
|
|
.get_interface(id)
|
|
.await?
|
|
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
|
|
|
|
let peers = state.store.list_peers_for_interface(id).await?;
|
|
let active_count = peers
|
|
.iter()
|
|
.filter(|p| p.state == nx9_wg_core::types::wireguard::PeerState::Active)
|
|
.count();
|
|
|
|
Ok(Json(InterfaceStatusResponse {
|
|
interface: iface,
|
|
peer_count: peers.len(),
|
|
active_peer_count: active_count,
|
|
}))
|
|
}
|
|
|
|
/// POST /api/v1/interfaces/{id}/restart
|
|
pub async fn restart_interface_handler(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
) -> ApiResult<Json<GenericSuccess>> {
|
|
let iface = state
|
|
.store
|
|
.get_interface(id)
|
|
.await?
|
|
.ok_or_else(|| ApiError::NotFound(format!("Interface '{id}' not found")))?;
|
|
|
|
// 1. Tear down the kernel WireGuard interface
|
|
let _ = state.wg_engine.delete_interface(&iface.name).await;
|
|
|
|
// 2. Re-sync from desired state (recreate link, addresses, peers, routes)
|
|
let peers = state.store.list_peers_for_interface(iface.id).await?;
|
|
state
|
|
.wg_engine
|
|
.sync_interface(&iface, &peers)
|
|
.await
|
|
.map_err(|e| {
|
|
ApiError::Internal(format!("Failed to restart interface '{}': {e}", iface.name))
|
|
})?;
|
|
|
|
state.broadcast(SystemEvent::InterfaceChanged {
|
|
id: iface.id.to_string(),
|
|
action: "restarted".to_string(),
|
|
});
|
|
|
|
Ok(Json(GenericSuccess {
|
|
success: true,
|
|
message: format!("Interface '{}' restarted successfully", iface.name),
|
|
}))
|
|
}
|