feat: complete nx9-wg v0.8.0 platform
This commit is contained in:
1 parent
c75e5c4e71
commit
c8a9b7cde6
52 files changed
+7751
-725
No files matched your search
@@ -19,5 +19,15 @@ qrcode.workspace = true
|
||||
image.workspace = true
|
||||
async-trait = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
rtnetlink = { workspace = true }
|
||||
genetlink = { workspace = true }
|
||||
netlink-packet-wireguard = { workspace = true }
|
||||
netlink-packet-core = { workspace = true }
|
||||
netlink-packet-generic = { workspace = true }
|
||||
netlink-proto = { workspace = true }
|
||||
netlink-sys = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -143,12 +143,25 @@ impl WireGuardEngine for SimulatedWireGuardEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux Native WireGuard Engine using kernel netlink / interfaces.
|
||||
// ── Native Linux WireGuard Engine ─────────────────────────────────────────────
|
||||
//
|
||||
// On Linux: the real implementation lives in native_linux.rs and uses
|
||||
// RTNETLINK + WireGuard Generic Netlink to communicate with the kernel.
|
||||
//
|
||||
// On non-Linux platforms: a thin simulation wrapper is provided so that
|
||||
// the workspace remains portable and tests remain functional.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use crate::native_linux::NativeLinuxWireGuardEngine;
|
||||
|
||||
/// Non-Linux fallback: NativeLinuxWireGuardEngine delegates to simulation.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NativeLinuxWireGuardEngine {
|
||||
simulated_fallback: SimulatedWireGuardEngine,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl NativeLinuxWireGuardEngine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -156,24 +169,16 @@ impl NativeLinuxWireGuardEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if Linux kernel WireGuard module / interface support is available.
|
||||
/// Check if Linux kernel WireGuard support is available.
|
||||
pub fn is_supported() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::path::Path::new("/sys/module/wireguard").exists()
|
||||
|| std::path::Path::new("/proc/net/dev").exists()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[async_trait::async_trait]
|
||||
impl WireGuardEngine for NativeLinuxWireGuardEngine {
|
||||
async fn sync_interface(&self, interface: &Interface, peers: &[Peer]) -> Result<()> {
|
||||
// Fallback to simulated engine for test sandboxes and non-root execution
|
||||
self.simulated_fallback
|
||||
.sync_interface(interface, peers)
|
||||
.await
|
||||
|
||||
@@ -24,6 +24,21 @@ pub enum WireGuardError {
|
||||
#[error("permission denied: {0}")]
|
||||
PermissionDenied(String),
|
||||
|
||||
#[error("interface not found: {0}")]
|
||||
InterfaceNotFound(String),
|
||||
|
||||
#[error("wrong interface type: expected wireguard, found {0}")]
|
||||
WrongInterfaceType(String),
|
||||
|
||||
#[error("unsupported: {0}")]
|
||||
Unsupported(String),
|
||||
|
||||
#[error("invalid endpoint: {0}")]
|
||||
InvalidEndpoint(String),
|
||||
|
||||
#[error("invalid allowed IP: {0}")]
|
||||
InvalidAllowedIp(String),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
pub mod config_builder;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod native_linux;
|
||||
pub mod qr;
|
||||
|
||||
pub use config_builder::ClientConfigBuilder;
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
//! Native Linux WireGuard engine using RTNETLINK and WireGuard Generic Netlink.
|
||||
//!
|
||||
//! This module communicates directly with the Linux kernel to manage WireGuard
|
||||
//! interfaces. It uses:
|
||||
//!
|
||||
//! - **RTNETLINK** for network link lifecycle (create, delete, list interfaces)
|
||||
//! - **WireGuard Generic Netlink** for device configuration and telemetry
|
||||
//!
|
||||
//! No external commands (wg, ip, wg-quick, nft, sysctl) are ever executed.
|
||||
|
||||
use crate::engine::{LiveInterfaceStats, LivePeerStats, WireGuardEngine};
|
||||
use crate::error::{Result, WireGuardError};
|
||||
use base64::Engine as _;
|
||||
use chrono::NaiveDateTime;
|
||||
use futures::stream::{StreamExt, TryStreamExt};
|
||||
use genetlink::GenetlinkHandle;
|
||||
use ipnet::IpNet;
|
||||
use netlink_packet_core::{NLM_F_ACK, NLM_F_DUMP, NLM_F_REQUEST, NetlinkMessage, NetlinkPayload};
|
||||
use netlink_packet_generic::GenlMessage;
|
||||
use netlink_packet_wireguard::{
|
||||
WireguardAddressFamily, WireguardAllowedIp, WireguardAllowedIpAttr, WireguardAttribute,
|
||||
WireguardCmd, WireguardDeviceFlags, WireguardMessage, WireguardPeer, WireguardPeerAttribute,
|
||||
WireguardPeerFlags,
|
||||
};
|
||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerState};
|
||||
use rtnetlink::LinkWireguard;
|
||||
use rtnetlink::packet_route::link::{InfoKind, LinkAttribute, LinkInfo};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
/// Linux Native WireGuard Engine using kernel RTNETLINK and Generic Netlink.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NativeLinuxWireGuardEngine;
|
||||
|
||||
impl NativeLinuxWireGuardEngine {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Check if Linux kernel WireGuard module and Generic Netlink support is available.
|
||||
pub fn is_supported() -> bool {
|
||||
// Check for the WireGuard kernel module or network dev procfs
|
||||
std::path::Path::new("/sys/module/wireguard").exists()
|
||||
|| std::path::Path::new("/proc/net/dev").exists()
|
||||
}
|
||||
}
|
||||
|
||||
// ── RTNETLINK Interface Lifecycle ─────────────────────────────────────────────
|
||||
|
||||
/// Create a new RTNETLINK connection and return the handle.
|
||||
async fn rtnetlink_handle() -> Result<(rtnetlink::Handle, tokio::task::JoinHandle<()>)> {
|
||||
let (connection, handle, _) = rtnetlink::new_connection().map_err(|e| {
|
||||
WireGuardError::Netlink(format!("failed to create rtnetlink connection: {e}"))
|
||||
})?;
|
||||
let join = tokio::spawn(connection);
|
||||
Ok((handle, join))
|
||||
}
|
||||
|
||||
/// Ensure a WireGuard interface exists with the given name.
|
||||
///
|
||||
/// - If the interface already exists and is a WireGuard link, this is a no-op.
|
||||
/// - If the interface already exists but is NOT a WireGuard link, returns an error.
|
||||
/// - If the interface does not exist, it is created as a WireGuard link and brought up.
|
||||
async fn ensure_link(name: &str) -> Result<()> {
|
||||
let (handle, _conn_task) = rtnetlink_handle().await?;
|
||||
|
||||
// Try to find existing interface by name
|
||||
let mut links = handle.link().get().match_name(name.to_string()).execute();
|
||||
|
||||
match links.try_next().await {
|
||||
Ok(Some(link)) => {
|
||||
let mut is_wireguard = false;
|
||||
for nla in &link.attributes {
|
||||
if let LinkAttribute::LinkInfo(infos) = nla {
|
||||
for info in infos {
|
||||
if let LinkInfo::Kind(InfoKind::Wireguard) = info {
|
||||
is_wireguard = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_wireguard {
|
||||
tracing::debug!(interface = %name, "WireGuard interface already exists");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WireGuardError::WrongInterfaceType(format!(
|
||||
"interface '{name}' exists but is not a WireGuard interface"
|
||||
)))
|
||||
}
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
// Interface does not exist — create it and bring it up
|
||||
tracing::info!(interface = %name, "Creating WireGuard interface via RTNETLINK");
|
||||
let add_msg = LinkWireguard::new(name).up().build();
|
||||
|
||||
handle.link().add(add_msg).execute().await.map_err(|e| {
|
||||
let msg = format!("{e}");
|
||||
if msg.contains("permission")
|
||||
|| msg.contains("EPERM")
|
||||
|| msg.contains("Operation not permitted")
|
||||
{
|
||||
WireGuardError::PermissionDenied(format!(
|
||||
"insufficient privileges to create WireGuard interface '{name}': {e}"
|
||||
))
|
||||
} else {
|
||||
WireGuardError::Netlink(format!(
|
||||
"failed to create WireGuard interface '{name}': {e}"
|
||||
))
|
||||
}
|
||||
})?;
|
||||
|
||||
tracing::info!(interface = %name, "WireGuard interface created and brought up");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a WireGuard interface by name.
|
||||
async fn delete_link(name: &str) -> Result<()> {
|
||||
let (handle, _conn_task) = rtnetlink_handle().await?;
|
||||
|
||||
let mut links = handle.link().get().match_name(name.to_string()).execute();
|
||||
match links.try_next().await {
|
||||
Ok(Some(link)) => {
|
||||
let index = link.header.index;
|
||||
handle.link().del(index).execute().await.map_err(|e| {
|
||||
WireGuardError::Netlink(format!(
|
||||
"failed to delete interface '{name}' (index {index}): {e}"
|
||||
))
|
||||
})?;
|
||||
tracing::info!(interface = %name, "WireGuard interface deleted via RTNETLINK");
|
||||
Ok(())
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!(interface = %name, "Interface not found for deletion");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(WireGuardError::Netlink(format!(
|
||||
"failed to look up interface '{name}': {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all WireGuard interface names using RTNETLINK link dump.
|
||||
async fn list_wireguard_links() -> Result<Vec<String>> {
|
||||
let (handle, _conn_task) = rtnetlink_handle().await?;
|
||||
|
||||
let mut links = handle.link().get().execute();
|
||||
let mut wg_names = Vec::new();
|
||||
|
||||
while let Some(link) = links
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| WireGuardError::Netlink(format!("failed to dump links: {e}")))?
|
||||
{
|
||||
let mut name = None;
|
||||
let mut is_wireguard = false;
|
||||
|
||||
for nla in &link.attributes {
|
||||
match nla {
|
||||
LinkAttribute::IfName(n) => name = Some(n.clone()),
|
||||
LinkAttribute::LinkInfo(infos) => {
|
||||
for info in infos {
|
||||
if let LinkInfo::Kind(InfoKind::Wireguard) = info {
|
||||
is_wireguard = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let (true, Some(n)) = (is_wireguard, name) {
|
||||
wg_names.push(n);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(wg_names)
|
||||
}
|
||||
|
||||
// ── WireGuard Generic Netlink Operations ─────────────────────────────────────
|
||||
|
||||
/// Create a WireGuard Generic Netlink connection.
|
||||
async fn wireguard_genl_handle() -> Result<(GenetlinkHandle, tokio::task::JoinHandle<()>)> {
|
||||
let (connection, handle, _) = genetlink::new_connection().map_err(|e| {
|
||||
WireGuardError::Netlink(format!("failed to create genetlink connection: {e}"))
|
||||
})?;
|
||||
let join = tokio::spawn(connection);
|
||||
Ok((handle, join))
|
||||
}
|
||||
|
||||
/// Configure a WireGuard device via Generic Netlink SET_DEVICE.
|
||||
///
|
||||
/// Sets the private key, listen port, and synchronizes the active peer set.
|
||||
/// Uses `WireguardDeviceFlags::ReplacePeers` to atomically replace all peers.
|
||||
async fn configure_device(interface: &Interface, peers: &[Peer]) -> Result<()> {
|
||||
let (mut handle, _conn_task) = wireguard_genl_handle().await?;
|
||||
|
||||
// Decode the private key from base64 to 32 bytes
|
||||
let private_key_bytes = decode_base64_key(interface.private_key.as_str())
|
||||
.map_err(|e| WireGuardError::Key(format!("invalid interface private key: {e}")))?;
|
||||
|
||||
// Build the device attributes
|
||||
let mut device_attrs: Vec<WireguardAttribute> = vec![
|
||||
WireguardAttribute::IfName(interface.name.clone()),
|
||||
WireguardAttribute::PrivateKey(private_key_bytes),
|
||||
WireguardAttribute::ListenPort(interface.listen_port),
|
||||
WireguardAttribute::Fwmark(0),
|
||||
WireguardAttribute::Flags(WireguardDeviceFlags::ReplacePeers),
|
||||
];
|
||||
|
||||
// Build peer configurations for active peers only
|
||||
let mut wg_peers = Vec::new();
|
||||
for peer in peers.iter().filter(|p| p.state == PeerState::Active) {
|
||||
let mut peer_attrs: Vec<WireguardPeerAttribute> = Vec::new();
|
||||
|
||||
// Public key (required)
|
||||
let pub_key_bytes = decode_base64_key(peer.public_key.as_str())
|
||||
.map_err(|e| WireGuardError::Key(format!("invalid peer public key: {e}")))?;
|
||||
peer_attrs.push(WireguardPeerAttribute::PublicKey(pub_key_bytes));
|
||||
|
||||
// Preshared key (optional)
|
||||
if let Some(ref psk) = peer.preshared_key {
|
||||
let psk_bytes = decode_base64_key(psk.as_str())
|
||||
.map_err(|e| WireGuardError::Key(format!("invalid peer preshared key: {e}")))?;
|
||||
peer_attrs.push(WireguardPeerAttribute::PresharedKey(psk_bytes));
|
||||
}
|
||||
|
||||
// Endpoint (optional)
|
||||
if let Some(ref endpoint_str) = peer.endpoint {
|
||||
let endpoint = parse_endpoint(endpoint_str)?;
|
||||
peer_attrs.push(WireguardPeerAttribute::Endpoint(endpoint));
|
||||
}
|
||||
|
||||
// Persistent keepalive (optional)
|
||||
if let Some(keepalive) = peer.persistent_keepalive {
|
||||
peer_attrs.push(WireguardPeerAttribute::PersistentKeepalive(keepalive));
|
||||
}
|
||||
|
||||
// Allowed IPs
|
||||
let allowed_ips = parse_allowed_ips(&peer.allowed_ips)?;
|
||||
if !allowed_ips.is_empty() {
|
||||
peer_attrs.push(WireguardPeerAttribute::Flags(
|
||||
WireguardPeerFlags::ReplaceAllowedIps,
|
||||
));
|
||||
peer_attrs.push(WireguardPeerAttribute::AllowedIps(allowed_ips));
|
||||
}
|
||||
|
||||
wg_peers.push(WireguardPeer(peer_attrs));
|
||||
}
|
||||
|
||||
device_attrs.push(WireguardAttribute::Peers(wg_peers));
|
||||
|
||||
// Build and send the SET_DEVICE message
|
||||
let genlmsg = GenlMessage::from_payload(WireguardMessage {
|
||||
cmd: WireguardCmd::SetDevice,
|
||||
attributes: device_attrs,
|
||||
});
|
||||
|
||||
let mut nlmsg = NetlinkMessage::from(genlmsg);
|
||||
nlmsg.header.flags = NLM_F_REQUEST | NLM_F_ACK;
|
||||
nlmsg.finalize();
|
||||
|
||||
let mut response = handle.request(nlmsg).await.map_err(|e| {
|
||||
let msg = format!("{e}");
|
||||
if msg.contains("not found") || msg.contains("No such") {
|
||||
WireGuardError::Unsupported(
|
||||
"WireGuard Generic Netlink family not available — is the wireguard kernel module loaded?".to_string(),
|
||||
)
|
||||
} else {
|
||||
WireGuardError::Netlink(format!("failed to send WireGuard SET_DEVICE: {e}"))
|
||||
}
|
||||
})?;
|
||||
|
||||
// Check for errors in the response stream
|
||||
while let Some(res) = response.next().await {
|
||||
let msg = res.map_err(|e| WireGuardError::Netlink(format!("decode error: {e}")))?;
|
||||
if let NetlinkPayload::Error(err) = msg.payload
|
||||
&& let Some(code) = err.code
|
||||
{
|
||||
let code_val = code.get();
|
||||
if code_val == -1 {
|
||||
return Err(WireGuardError::PermissionDenied(
|
||||
"insufficient privileges to configure WireGuard device".to_string(),
|
||||
));
|
||||
}
|
||||
return Err(WireGuardError::Netlink(format!(
|
||||
"kernel rejected WireGuard device configuration (errno={code_val})"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
interface = %interface.name,
|
||||
active_peers = peers.iter().filter(|p| p.state == PeerState::Active).count(),
|
||||
"WireGuard device configured via Generic Netlink"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Query a WireGuard device via Generic Netlink GET_DEVICE and return live stats.
|
||||
async fn query_device(name: &str) -> Result<Option<LiveInterfaceStats>> {
|
||||
let (mut handle, _conn_task) = wireguard_genl_handle().await?;
|
||||
|
||||
let genlmsg = GenlMessage::from_payload(WireguardMessage {
|
||||
cmd: WireguardCmd::GetDevice,
|
||||
attributes: vec![WireguardAttribute::IfName(name.to_string())],
|
||||
});
|
||||
|
||||
let mut nlmsg = NetlinkMessage::from(genlmsg);
|
||||
nlmsg.header.flags = NLM_F_REQUEST | NLM_F_DUMP;
|
||||
nlmsg.finalize();
|
||||
|
||||
let mut response = handle.request(nlmsg).await.map_err(|e| {
|
||||
let msg = format!("{e}");
|
||||
if msg.contains("No such device") || msg.contains("ENODEV") {
|
||||
WireGuardError::InterfaceNotFound(format!("interface '{name}' not found"))
|
||||
} else if msg.contains("not found") || msg.contains("No such") {
|
||||
WireGuardError::Unsupported(
|
||||
"WireGuard Generic Netlink family not available — is the wireguard kernel module loaded?".to_string(),
|
||||
)
|
||||
} else {
|
||||
WireGuardError::Netlink(format!("failed to query WireGuard device '{name}': {e}"))
|
||||
}
|
||||
})?;
|
||||
|
||||
let mut public_key = String::new();
|
||||
let mut listen_port: u16 = 0;
|
||||
let mut fwmark: u32 = 0;
|
||||
let mut live_peers: Vec<LivePeerStats> = Vec::new();
|
||||
let mut found = false;
|
||||
|
||||
while let Some(res) = response.next().await {
|
||||
let msg = res.map_err(|e| WireGuardError::Netlink(format!("decode error: {e}")))?;
|
||||
match msg.payload {
|
||||
NetlinkPayload::Error(err) => {
|
||||
if let Some(code) = err.code {
|
||||
let code_val = code.get();
|
||||
// ENODEV = -19 means device not found
|
||||
if code_val == -19 {
|
||||
return Ok(None);
|
||||
}
|
||||
if code_val == -1 {
|
||||
return Err(WireGuardError::PermissionDenied(
|
||||
"insufficient privileges to query WireGuard device".to_string(),
|
||||
));
|
||||
}
|
||||
return Err(WireGuardError::Netlink(format!(
|
||||
"kernel error querying WireGuard device (errno={code_val})"
|
||||
)));
|
||||
}
|
||||
}
|
||||
NetlinkPayload::InnerMessage(genl) => {
|
||||
found = true;
|
||||
for attr in genl.payload.attributes {
|
||||
match attr {
|
||||
WireguardAttribute::PublicKey(key) => {
|
||||
public_key = base64::engine::general_purpose::STANDARD.encode(key);
|
||||
}
|
||||
WireguardAttribute::ListenPort(port) => listen_port = port,
|
||||
WireguardAttribute::Fwmark(fw) => fwmark = fw,
|
||||
WireguardAttribute::Peers(peers) => {
|
||||
for peer in peers {
|
||||
let mut peer_pubkey = String::new();
|
||||
let mut peer_endpoint: Option<String> = None;
|
||||
let mut rx_bytes: u64 = 0;
|
||||
let mut tx_bytes: u64 = 0;
|
||||
let mut last_handshake: Option<NaiveDateTime> = None;
|
||||
let mut allowed_ips_strs: Vec<String> = Vec::new();
|
||||
let mut persistent_keepalive: Option<u16> = None;
|
||||
|
||||
for attr in peer.0 {
|
||||
match attr {
|
||||
WireguardPeerAttribute::PublicKey(key) => {
|
||||
peer_pubkey = base64::engine::general_purpose::STANDARD
|
||||
.encode(key);
|
||||
}
|
||||
WireguardPeerAttribute::Endpoint(ep) => {
|
||||
peer_endpoint = Some(format!("{ep}"));
|
||||
}
|
||||
WireguardPeerAttribute::RxBytes(rx) => rx_bytes = rx,
|
||||
WireguardPeerAttribute::TxBytes(tx) => tx_bytes = tx,
|
||||
WireguardPeerAttribute::LastHandshake(ts) => {
|
||||
let secs = ts.seconds;
|
||||
let nsecs = ts.nano_seconds;
|
||||
if secs > 0 {
|
||||
last_handshake = chrono::DateTime::from_timestamp(
|
||||
secs,
|
||||
nsecs.clamp(0, 999_999_999) as u32,
|
||||
)
|
||||
.map(|dt| dt.naive_utc());
|
||||
}
|
||||
}
|
||||
WireguardPeerAttribute::AllowedIps(ips) => {
|
||||
for ip_entry in ips {
|
||||
let mut addr: Option<IpAddr> = None;
|
||||
let mut prefix: u8 = 0;
|
||||
for ip_attr in ip_entry.0 {
|
||||
match ip_attr {
|
||||
WireguardAllowedIpAttr::IpAddr(a) => {
|
||||
addr = Some(a);
|
||||
}
|
||||
WireguardAllowedIpAttr::Cidr(c) => {
|
||||
prefix = c;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(a) = addr {
|
||||
allowed_ips_strs.push(format!("{a}/{prefix}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
WireguardPeerAttribute::PersistentKeepalive(ka)
|
||||
if ka > 0 =>
|
||||
{
|
||||
persistent_keepalive = Some(ka);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
live_peers.push(LivePeerStats {
|
||||
public_key: peer_pubkey,
|
||||
endpoint: peer_endpoint,
|
||||
rx_bytes,
|
||||
tx_bytes,
|
||||
last_handshake_at: last_handshake,
|
||||
allowed_ips: allowed_ips_strs,
|
||||
persistent_keepalive,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(LiveInterfaceStats {
|
||||
name: name.to_string(),
|
||||
public_key,
|
||||
listen_port,
|
||||
fwmark,
|
||||
peers: live_peers,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Conversion Utilities ─────────────────────────────────────────────────────
|
||||
|
||||
/// Decode a base64-encoded WireGuard key into exactly 32 bytes.
|
||||
fn decode_base64_key(b64: &str) -> std::result::Result<[u8; 32], String> {
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64)
|
||||
.map_err(|e| format!("invalid base64: {e}"))?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(format!("key must be exactly 32 bytes, got {}", bytes.len()));
|
||||
}
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&bytes);
|
||||
Ok(arr)
|
||||
}
|
||||
|
||||
/// Parse a comma-separated list of CIDR addresses into WireGuard allowed-IP NLAs.
|
||||
fn parse_allowed_ips(csv: &str) -> Result<Vec<WireguardAllowedIp>> {
|
||||
let mut result = Vec::new();
|
||||
for cidr_str in csv.split(',') {
|
||||
let trimmed = cidr_str.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let net: IpNet = trimmed.parse().map_err(|e| {
|
||||
WireGuardError::InvalidAllowedIp(format!("invalid CIDR '{trimmed}': {e}"))
|
||||
})?;
|
||||
let family = match net {
|
||||
IpNet::V4(_) => WireguardAddressFamily::Ipv4,
|
||||
IpNet::V6(_) => WireguardAddressFamily::Ipv6,
|
||||
};
|
||||
result.push(WireguardAllowedIp(vec![
|
||||
WireguardAllowedIpAttr::Family(family),
|
||||
WireguardAllowedIpAttr::IpAddr(net.addr()),
|
||||
WireguardAllowedIpAttr::Cidr(net.prefix_len()),
|
||||
]));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Parse an endpoint string ("ip:port" or "[ipv6]:port") into a SocketAddr.
|
||||
fn parse_endpoint(s: &str) -> Result<SocketAddr> {
|
||||
if let Ok(addr) = s.parse::<SocketAddr>() {
|
||||
return Ok(addr);
|
||||
}
|
||||
if let Some(idx) = s.rfind(':') {
|
||||
let host = &s[..idx];
|
||||
let port_str = &s[idx + 1..];
|
||||
if let (Ok(ip), Ok(port)) = (host.parse::<IpAddr>(), port_str.parse::<u16>()) {
|
||||
return Ok(SocketAddr::new(ip, port));
|
||||
}
|
||||
}
|
||||
Err(WireGuardError::InvalidEndpoint(format!(
|
||||
"cannot parse endpoint '{s}'"
|
||||
)))
|
||||
}
|
||||
|
||||
// ── WireGuardEngine Trait Implementation ─────────────────────────────────────
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl WireGuardEngine for NativeLinuxWireGuardEngine {
|
||||
async fn sync_interface(&self, interface: &Interface, peers: &[Peer]) -> Result<()> {
|
||||
// 1. Ensure the WireGuard link exists
|
||||
ensure_link(&interface.name).await?;
|
||||
|
||||
// 2. Configure the WireGuard device (private key, listen port, peers)
|
||||
configure_device(interface, peers).await?;
|
||||
|
||||
tracing::info!(
|
||||
interface = %interface.name,
|
||||
active_peers = peers.iter().filter(|p| p.state == PeerState::Active).count(),
|
||||
"WireGuard interface synchronized via native netlink"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_interface(&self, name: &str) -> Result<()> {
|
||||
delete_link(name).await
|
||||
}
|
||||
|
||||
async fn get_interface_stats(&self, name: &str) -> Result<Option<LiveInterfaceStats>> {
|
||||
query_device(name).await
|
||||
}
|
||||
|
||||
async fn list_interfaces(&self) -> Result<Vec<String>> {
|
||||
list_wireguard_links().await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Unit tests for native Linux WireGuard conversion utilities and error invariants.
|
||||
//!
|
||||
//! These tests verify CIDR parsing, endpoint parsing, key decoding, and peer
|
||||
//! filtering without requiring CAP_NET_ADMIN or kernel mutation.
|
||||
|
||||
use base64::Engine as _;
|
||||
use nx9_wg_core::types::wireguard::PeerState;
|
||||
use nx9_wireguard::WireGuardError;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
|
||||
#[test]
|
||||
fn test_ipv4_cidr_parsing() {
|
||||
let net: ipnet::IpNet = "10.0.0.2/32".parse().unwrap();
|
||||
assert_eq!(net.addr(), IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)));
|
||||
assert_eq!(net.prefix_len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ipv6_cidr_parsing() {
|
||||
let net: ipnet::IpNet = "fd00::2/128".parse().unwrap();
|
||||
assert!(net.addr().is_ipv6());
|
||||
assert_eq!(net.prefix_len(), 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_allowed_ips_parsing() {
|
||||
let csv = "10.0.0.2/32, fd00::2/128";
|
||||
let nets: Vec<ipnet::IpNet> = csv
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.parse::<ipnet::IpNet>().unwrap())
|
||||
.collect();
|
||||
assert_eq!(nets.len(), 2);
|
||||
assert!(nets[0].addr().is_ipv4());
|
||||
assert!(nets[1].addr().is_ipv6());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_allowed_ips() {
|
||||
let csv = "";
|
||||
let nets: Vec<ipnet::IpNet> = csv
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| s.parse::<ipnet::IpNet>().ok())
|
||||
.collect();
|
||||
assert!(nets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_cidr_rejected() {
|
||||
let result = "invalid/cidr".parse::<ipnet::IpNet>();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ipv4_endpoint_parsing() {
|
||||
let addr: SocketAddr = "198.51.100.2:45000".parse().unwrap();
|
||||
assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(198, 51, 100, 2)));
|
||||
assert_eq!(addr.port(), 45000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ipv6_endpoint_parsing() {
|
||||
let addr: SocketAddr = "[2001:db8::1]:51820".parse().unwrap();
|
||||
assert!(addr.ip().is_ipv6());
|
||||
assert_eq!(addr.port(), 51820);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_endpoint_rejected() {
|
||||
let result = "not-an-endpoint".parse::<SocketAddr>();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base64_key_decode_valid() {
|
||||
let key_bytes = [0xAAu8; 32];
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(key_bytes);
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(&b64)
|
||||
.unwrap();
|
||||
assert_eq!(decoded.len(), 32);
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&decoded);
|
||||
assert_eq!(arr, key_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base64_key_decode_wrong_length() {
|
||||
let short_key = [0xBBu8; 16];
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(short_key);
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(&b64)
|
||||
.unwrap();
|
||||
assert_ne!(decoded.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base64_key_decode_invalid_base64() {
|
||||
let result = base64::engine::general_purpose::STANDARD.decode("not!valid!base64!!!");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peer_state_filtering() {
|
||||
let states = [
|
||||
PeerState::Active,
|
||||
PeerState::Disabled,
|
||||
PeerState::Revoked,
|
||||
PeerState::Expired,
|
||||
];
|
||||
|
||||
let active_count = states.iter().filter(|s| **s == PeerState::Active).count();
|
||||
assert_eq!(active_count, 1, "only Active peers should be synchronized");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_display_no_key_leakage() {
|
||||
let err = WireGuardError::Key("invalid base64".to_string());
|
||||
let display = format!("{err}");
|
||||
assert!(!display.contains("secret"));
|
||||
assert!(!display.contains("private"));
|
||||
assert!(display.contains("invalid base64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_variants_exist() {
|
||||
let _ = format!("{}", WireGuardError::InterfaceNotFound("wg0".into()));
|
||||
let _ = format!("{}", WireGuardError::WrongInterfaceType("eth0".into()));
|
||||
let _ = format!("{}", WireGuardError::Unsupported("no kernel module".into()));
|
||||
let _ = format!("{}", WireGuardError::InvalidEndpoint("bad:ep".into()));
|
||||
let _ = format!("{}", WireGuardError::InvalidAllowedIp("bad/cidr".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix_length_preservation() {
|
||||
let cases = [
|
||||
("10.0.0.0/8", 8),
|
||||
("10.0.0.0/16", 16),
|
||||
("10.0.0.0/24", 24),
|
||||
("10.0.0.1/32", 32),
|
||||
("fd00::/64", 64),
|
||||
("fd00::1/128", 128),
|
||||
("0.0.0.0/0", 0),
|
||||
("::/0", 0),
|
||||
];
|
||||
for (cidr, expected_prefix) in cases {
|
||||
let net: ipnet::IpNet = cidr.parse().unwrap();
|
||||
assert_eq!(
|
||||
net.prefix_len(),
|
||||
expected_prefix,
|
||||
"prefix mismatch for {cidr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user