release: NX9-WG v1.0.0

This commit is contained in:
thakares committed 2026-08-18 17:32:56 +05:30
1 parent c8a9b7cde6
commit 4dfe42fe68
42 files changed
+4689 -336

No files matched your search

+31 -8
View File
@@ -79,18 +79,31 @@ impl ClientConfigBuilder {
lines.push(format!("PresharedKey = {}", psk.as_str()));
}
let host_trimmed = server_host_or_ip.trim();
if host_trimmed.is_empty() {
return Err(WireGuardError::Config(
"No reachable WireGuard server endpoint is configured. Configure 'server_endpoint' in settings or provide --endpoint.".to_string(),
));
}
// Endpoint
let endpoint = if server_host_or_ip.contains(':') && !server_host_or_ip.starts_with('[') {
let endpoint = if host_trimmed.contains(':') && !host_trimmed.starts_with('[') {
// Check if already contains port
server_host_or_ip.to_string()
host_trimmed.to_string()
} else {
format!("{}:{}", server_host_or_ip, interface.listen_port)
format!("{}:{}", host_trimmed, interface.listen_port)
};
lines.push(format!("Endpoint = {endpoint}"));
// AllowedIPs based on Peer Profile
let allowed_ips = match peer.profile {
PeerProfile::FullTunnel => "0.0.0.0/0, ::/0".to_string(),
PeerProfile::FullTunnel => {
if interface.address_v6.is_some() || peer.address_v6.is_some() {
"0.0.0.0/0, ::/0".to_string()
} else {
"0.0.0.0/0".to_string()
}
}
PeerProfile::SplitTunnel => {
let mut subnets = Vec::new();
subnets.push(interface.address_v4.to_string());
@@ -101,7 +114,11 @@ impl ClientConfigBuilder {
}
PeerProfile::Custom => {
if peer.allowed_ips.trim().is_empty() {
"0.0.0.0/0, ::/0".to_string()
if interface.address_v6.is_some() || peer.address_v6.is_some() {
"0.0.0.0/0, ::/0".to_string()
} else {
"0.0.0.0/0".to_string()
}
} else {
peer.allowed_ips.clone()
}
@@ -181,7 +198,7 @@ mod tests {
updated_at: now,
};
// Full Tunnel
// Full Tunnel (IPv4-only interface -> 0.0.0.0/0 to prevent silent IPv6 blackhole)
let full_conf = ClientConfigBuilder::build(&peer, &iface, "vpn.example.com").unwrap();
assert!(full_conf.contains(&format!("PrivateKey = {}", peer_priv.as_str())));
assert!(full_conf.contains("Address = 10.0.0.2/32"));
@@ -190,9 +207,15 @@ mod tests {
assert!(full_conf.contains(&format!("PublicKey = {}", srv_pub.as_str())));
assert!(full_conf.contains(&format!("PresharedKey = {}", psk.as_str())));
assert!(full_conf.contains("Endpoint = vpn.example.com:51820"));
assert!(full_conf.contains("AllowedIPs = 0.0.0.0/0, ::/0"));
assert!(full_conf.contains("AllowedIPs = 0.0.0.0/0"));
assert!(full_conf.contains("PersistentKeepalive = 25"));
// Full Tunnel (Dual-stack interface -> 0.0.0.0/0, ::/0)
let mut dual_iface = iface.clone();
dual_iface.address_v6 = Some(IpNet::from_str("fd00::1/64").unwrap());
let dual_conf = ClientConfigBuilder::build(&peer, &dual_iface, "vpn.example.com").unwrap();
assert!(dual_conf.contains("AllowedIPs = 0.0.0.0/0, ::/0"));
// Split Tunnel
peer.profile = PeerProfile::SplitTunnel;
let split_conf = ClientConfigBuilder::build(&peer, &iface, "vpn.example.com").unwrap();
@@ -274,6 +297,6 @@ mod tests {
assert!(conf.contains("PersistentKeepalive = 20"));
assert!(conf.contains("DNS = 9.9.9.9"));
assert!(conf.contains("Address = 10.0.0.5/32"));
assert!(conf.contains("AllowedIPs = 0.0.0.0/0, ::/0"));
assert!(conf.contains("AllowedIPs = 0.0.0.0/0"));
}
}
+21 -1
View File
@@ -28,6 +28,12 @@ pub struct LiveInterfaceStats {
pub listen_port: u16,
pub fwmark: u32,
pub peers: Vec<LivePeerStats>,
#[serde(default)]
pub addresses: Vec<String>,
#[serde(default)]
pub mtu: Option<u32>,
#[serde(default)]
pub is_up: bool,
}
/// Abstract WireGuard Engine interface for kernel netlink and simulated environments.
@@ -82,6 +88,12 @@ impl SimulatedWireGuardEngine {
"Peer '{peer_public_key}' on interface '{interface_name}' not found"
)))
}
/// Directly inject live interface stats (for testing drift and telemetry scenarios).
pub async fn inject_interface_stats(&self, stats: LiveInterfaceStats) {
let mut map = self.state.write().await;
map.insert(stats.name.clone(), stats);
}
}
#[async_trait::async_trait]
@@ -94,7 +106,7 @@ impl WireGuardEngine for SimulatedWireGuardEngine {
.filter(|p| p.state == PeerState::Active)
.map(|p| {
let allowed_ips: Vec<String> = p
.allowed_ips
.server_wireguard_allowed_ips()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
@@ -112,12 +124,20 @@ impl WireGuardEngine for SimulatedWireGuardEngine {
})
.collect();
let mut addresses = vec![interface.address_v4.to_string()];
if let Some(ref v6) = interface.address_v6 {
addresses.push(v6.to_string());
}
let stats = LiveInterfaceStats {
name: interface.name.clone(),
public_key: interface.public_key.as_str().to_string(),
listen_port: interface.listen_port,
fwmark: 0,
peers: live_peers,
addresses,
mtu: interface.mtu.map(|m| m as u32),
is_up: true,
};
map.insert(interface.name.clone(), stats);
+393 -48
View File
@@ -24,7 +24,8 @@ use netlink_packet_wireguard::{
};
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerState};
use rtnetlink::LinkWireguard;
use rtnetlink::packet_route::link::{InfoKind, LinkAttribute, LinkInfo};
use rtnetlink::packet_route::address::{AddressAttribute, AddressMessage};
use rtnetlink::packet_route::link::{InfoKind, LinkAttribute, LinkFlags, LinkInfo};
use std::net::{IpAddr, SocketAddr};
/// Linux Native WireGuard Engine using kernel RTNETLINK and Generic Netlink.
@@ -55,42 +56,53 @@ async fn rtnetlink_handle() -> Result<(rtnetlink::Handle, tokio::task::JoinHandl
Ok((handle, join))
}
/// Ensure a WireGuard interface exists with the given name.
/// Ensure a WireGuard interface exists with the given name and addresses.
///
/// - 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<()> {
/// - Sets MTU if configured.
/// - Assigns IPv4 and IPv6 addresses via RTNETLINK if not already assigned.
/// - Removes stale IP addresses on the managed interface that do not match desired state.
async fn ensure_link_and_addresses(interface: &Interface) -> 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();
let mut links = handle
.link()
.get()
.match_name(interface.name.to_string())
.execute();
match links.try_next().await {
let link_index = match links.try_next().await {
Ok(Some(link)) => {
let mut is_wireguard = false;
let mut is_other_type = 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;
match info {
LinkInfo::Kind(InfoKind::Wireguard) => {}
LinkInfo::Kind(InfoKind::Other(k))
if k.eq_ignore_ascii_case("wireguard") => {}
LinkInfo::Kind(_) => {
is_other_type = 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"
)))
if is_other_type {
return Err(WireGuardError::WrongInterfaceType(format!(
"interface '{}' exists but is not a WireGuard interface",
interface.name
)));
}
tracing::debug!(interface = %interface.name, index = link.header.index, "WireGuard interface found");
link.header.index
}
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();
tracing::info!(interface = %interface.name, "Creating WireGuard interface via RTNETLINK");
let add_msg = LinkWireguard::new(&interface.name).up().build();
handle.link().add(add_msg).execute().await.map_err(|e| {
let msg = format!("{e}");
@@ -99,19 +111,208 @@ async fn ensure_link(name: &str) -> Result<()> {
|| msg.contains("Operation not permitted")
{
WireGuardError::PermissionDenied(format!(
"insufficient privileges to create WireGuard interface '{name}': {e}"
"insufficient privileges to create WireGuard interface '{}': {e}",
interface.name
))
} else {
WireGuardError::Netlink(format!(
"failed to create WireGuard interface '{name}': {e}"
"failed to create WireGuard interface '{}': {e}",
interface.name
))
}
})?;
tracing::info!(interface = %name, "WireGuard interface created and brought up");
Ok(())
// Retrieve newly created link to get its index
let mut new_links = handle
.link()
.get()
.match_name(interface.name.to_string())
.execute();
match new_links.try_next().await {
Ok(Some(nl)) => {
tracing::info!(interface = %interface.name, "WireGuard interface created and brought up");
nl.header.index
}
_ => {
return Err(WireGuardError::Netlink(format!(
"failed to retrieve newly created interface '{}'",
interface.name
)));
}
}
}
};
// Set MTU and ensure UP
let mut link_builder = rtnetlink::LinkUnspec::new_with_index(link_index).up();
if let Some(mtu) = interface.mtu {
link_builder = link_builder.mtu(mtu as u32);
}
let msg = link_builder.build();
if let Err(e) = handle.link().change(msg).execute().await {
let msg_str = format!("{e}");
if msg_str.contains("permission")
|| msg_str.contains("EPERM")
|| msg_str.contains("Operation not permitted")
{
return Err(WireGuardError::PermissionDenied(format!(
"insufficient privileges to set link UP/MTU for '{}': {e}",
interface.name
)));
}
tracing::warn!(interface = %interface.name, "Failed to set link UP/MTU: {e}");
}
// Read existing addresses on link
let mut existing_addrs: Vec<(IpAddr, u8)> = Vec::new();
let mut stale_addr_msgs: Vec<AddressMessage> = Vec::new();
let mut addr_stream = handle
.address()
.get()
.set_link_index_filter(link_index)
.execute();
let desired_v4_ip = interface.address_v4.addr();
let desired_v4_prefix = interface.address_v4.prefix_len();
let desired_v6 = interface.address_v6.as_ref();
while let Ok(Some(addr_msg)) = addr_stream.try_next().await {
let prefix = addr_msg.header.prefix_len;
let mut msg_ip: Option<IpAddr> = None;
for attr in &addr_msg.attributes {
match attr {
AddressAttribute::Address(ip) | AddressAttribute::Local(ip) => {
if !existing_addrs.contains(&(*ip, prefix)) {
existing_addrs.push((*ip, prefix));
}
msg_ip = Some(*ip);
}
_ => {}
}
}
if let Some(ip) = msg_ip {
let is_desired = match ip {
IpAddr::V4(v4) => v4 == desired_v4_ip && prefix == desired_v4_prefix,
IpAddr::V6(v6) => {
if v6.is_unicast_link_local() {
true // preserve IPv6 link-local fe80::/10
} else if let Some(v6_desired) = desired_v6 {
v6 == v6_desired.addr() && prefix == v6_desired.prefix_len()
} else {
false
}
}
};
if !is_desired {
stale_addr_msgs.push(addr_msg);
}
}
}
// Remove any stale addresses
for stale_msg in stale_addr_msgs {
if let Err(e) = handle.address().del(stale_msg).execute().await {
tracing::warn!(interface = %interface.name, "Failed to delete stale address: {e}");
}
}
// Add desired IPv4 address if not already present
if !existing_addrs
.iter()
.any(|(ip, p)| *ip == desired_v4_ip && *p == desired_v4_prefix)
{
handle
.address()
.add(link_index, desired_v4_ip, desired_v4_prefix)
.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 assign IPv4 address '{}/{}' to interface '{}': {e}",
desired_v4_ip, desired_v4_prefix, interface.name
))
} else if msg.contains("File exists") || msg.contains("EEXIST") {
WireGuardError::Netlink(e.to_string())
} else {
WireGuardError::Netlink(format!(
"failed to assign IPv4 address '{}/{}' to interface '{}': {e}",
desired_v4_ip, desired_v4_prefix, interface.name
))
}
})
.or_else(|e| {
if e.to_string().contains("File exists") || e.to_string().contains("EEXIST") {
Ok(())
} else {
Err(e)
}
})?;
tracing::info!(
interface = %interface.name,
ip = %desired_v4_ip,
prefix = desired_v4_prefix,
"Assigned IPv4 address to WireGuard interface via RTNETLINK"
);
}
// Add desired IPv6 address if present and not already configured
if let Some(v6) = desired_v6 {
let v6_ip = v6.addr();
let v6_prefix = v6.prefix_len();
if !existing_addrs
.iter()
.any(|(ip, p)| *ip == v6_ip && *p == v6_prefix)
{
handle
.address()
.add(link_index, v6_ip, v6_prefix)
.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 assign IPv6 address '{}/{}' to interface '{}': {e}",
v6_ip, v6_prefix, interface.name
))
} else if msg.contains("File exists") || msg.contains("EEXIST") {
WireGuardError::Netlink(e.to_string())
} else {
WireGuardError::Netlink(format!(
"failed to assign IPv6 address '{}/{}' to interface '{}': {e}",
v6_ip, v6_prefix, interface.name
))
}
})
.or_else(|e| {
if e.to_string().contains("File exists") || e.to_string().contains("EEXIST") {
Ok(())
} else {
Err(e)
}
})?;
tracing::info!(
interface = %interface.name,
ip = %v6_ip,
prefix = v6_prefix,
"Assigned IPv6 address to WireGuard interface via RTNETLINK"
);
}
}
Ok(())
}
/// Delete a WireGuard interface by name.
@@ -140,17 +341,18 @@ async fn delete_link(name: &str) -> Result<()> {
}
}
/// List all WireGuard interface names using RTNETLINK link dump.
/// List all WireGuard interface names using RTNETLINK link dump and Generic Netlink enumeration.
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();
// 1. Primary discovery: RTNETLINK link dump
let (handle, _conn_task) = rtnetlink_handle().await?;
let mut links = handle.link().get().execute();
while let Some(link) = links
.try_next()
.await
.map_err(|e| WireGuardError::Netlink(format!("failed to dump links: {e}")))?
.map_err(|e| WireGuardError::Netlink(format!("failed to dump links via rtnetlink: {e}")))?
{
let mut name = None;
let mut is_wireguard = false;
@@ -160,8 +362,14 @@ async fn list_wireguard_links() -> Result<Vec<String>> {
LinkAttribute::IfName(n) => name = Some(n.clone()),
LinkAttribute::LinkInfo(infos) => {
for info in infos {
if let LinkInfo::Kind(InfoKind::Wireguard) = info {
is_wireguard = true;
match info {
LinkInfo::Kind(InfoKind::Wireguard) => is_wireguard = true,
LinkInfo::Kind(InfoKind::Other(k))
if k.eq_ignore_ascii_case("wireguard") =>
{
is_wireguard = true;
}
_ => {}
}
}
}
@@ -169,11 +377,44 @@ async fn list_wireguard_links() -> Result<Vec<String>> {
}
}
if let (true, Some(n)) = (is_wireguard, name) {
if let (true, Some(n)) = (is_wireguard, name)
&& !wg_names.contains(&n)
{
wg_names.push(n);
}
}
// 2. Secondary discovery: WireGuard Generic Netlink dump
if let Ok((mut genl_handle, _)) = wireguard_genl_handle().await {
let genlmsg = GenlMessage::from_payload(WireguardMessage {
cmd: WireguardCmd::GetDevice,
attributes: Vec::new(),
});
let mut nlmsg = NetlinkMessage::from(genlmsg);
nlmsg.header.flags = NLM_F_REQUEST | NLM_F_DUMP;
nlmsg.finalize();
if let Ok(mut response) = genl_handle.request(nlmsg).await {
while let Some(Ok(msg)) = response.next().await {
if let NetlinkPayload::InnerMessage(genl) = msg.payload {
for attr in genl.payload.attributes {
if let WireguardAttribute::IfName(ifname) = attr
&& !wg_names.contains(&ifname)
{
wg_names.push(ifname);
}
}
}
}
}
}
tracing::debug!(
discovered_count = wg_names.len(),
interfaces = ?wg_names,
"Discovered WireGuard interfaces in kernel"
);
Ok(wg_names)
}
@@ -236,8 +477,9 @@ async fn configure_device(interface: &Interface, peers: &[Peer]) -> Result<()> {
peer_attrs.push(WireguardPeerAttribute::PersistentKeepalive(keepalive));
}
// Allowed IPs
let allowed_ips = parse_allowed_ips(&peer.allowed_ips)?;
// Server-side Allowed IPs (cryptokey routing in Linux kernel)
let server_allowed_str = peer.server_wireguard_allowed_ips();
let allowed_ips = parse_allowed_ips(&server_allowed_str)?;
if !allowed_ips.is_empty() {
peer_attrs.push(WireguardPeerAttribute::Flags(
WireguardPeerFlags::ReplaceAllowedIps,
@@ -299,6 +541,51 @@ async fn configure_device(interface: &Interface, peers: &[Peer]) -> Result<()> {
/// Query a WireGuard device via Generic Netlink GET_DEVICE and return live stats.
async fn query_device(name: &str) -> Result<Option<LiveInterfaceStats>> {
// 1. Query RTNETLINK for link existence, MTU, is_up, and assigned addresses
let mut link_exists = false;
let mut addresses = Vec::new();
let mut mtu = None;
let mut is_up = false;
if let Ok((rt_handle, _)) = rtnetlink_handle().await {
let mut links = rt_handle
.link()
.get()
.match_name(name.to_string())
.execute();
if let Ok(Some(link)) = links.try_next().await {
link_exists = true;
let index = link.header.index;
is_up = link.header.flags.contains(LinkFlags::Up);
for attr in link.attributes {
if let LinkAttribute::Mtu(m) = attr {
mtu = Some(m);
}
}
let mut addr_stream = rt_handle
.address()
.get()
.set_link_index_filter(index)
.execute();
while let Ok(Some(addr_msg)) = addr_stream.try_next().await {
let prefix = addr_msg.header.prefix_len;
for attr in addr_msg.attributes {
match attr {
AddressAttribute::Address(ip) | AddressAttribute::Local(ip) => {
let cidr = format!("{}/{}", ip, prefix);
if !addresses.contains(&cidr) {
addresses.push(cidr);
}
}
_ => {}
}
}
}
}
}
// 2. Query Generic Netlink for WireGuard keys, port, fwmark, and peers
let (mut handle, _conn_task) = wireguard_genl_handle().await?;
let genlmsg = GenlMessage::from_payload(WireguardMessage {
@@ -310,18 +597,35 @@ async fn query_device(name: &str) -> Result<Option<LiveInterfaceStats>> {
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 response = match handle.request(nlmsg).await {
Ok(resp) => resp,
Err(e) => {
let msg = format!("{e}");
if msg.contains("No such device") || msg.contains("ENODEV") {
if link_exists {
return Ok(Some(LiveInterfaceStats {
name: name.to_string(),
public_key: String::new(),
listen_port: 0,
fwmark: 0,
peers: Vec::new(),
addresses,
mtu,
is_up,
}));
}
return Ok(None);
} else if msg.contains("not found") || msg.contains("No such") {
return Err(WireGuardError::Unsupported(
"WireGuard Generic Netlink family not available — is the wireguard kernel module loaded?".to_string(),
));
} else {
return Err(WireGuardError::Netlink(format!(
"failed to query WireGuard device '{name}': {e}"
)));
}
}
})?;
};
let mut public_key = String::new();
let mut listen_port: u16 = 0;
@@ -335,11 +639,40 @@ async fn query_device(name: &str) -> Result<Option<LiveInterfaceStats>> {
NetlinkPayload::Error(err) => {
if let Some(code) = err.code {
let code_val = code.get();
// ENODEV = -19 means device not found
if code_val == -19 {
// ENODEV
if link_exists {
return Ok(Some(LiveInterfaceStats {
name: name.to_string(),
public_key: String::new(),
listen_port: 0,
fwmark: 0,
peers: Vec::new(),
addresses,
mtu,
is_up,
}));
}
return Ok(None);
}
if code_val == -1 {
// EPERM
if link_exists {
tracing::warn!(
interface = %name,
"Permission denied reading WireGuard keys via Generic Netlink; returning RTNETLINK link info"
);
return Ok(Some(LiveInterfaceStats {
name: name.to_string(),
public_key: String::new(),
listen_port: 0,
fwmark: 0,
peers: Vec::new(),
addresses,
mtu,
is_up,
}));
}
return Err(WireGuardError::PermissionDenied(
"insufficient privileges to query WireGuard device".to_string(),
));
@@ -438,16 +771,28 @@ async fn query_device(name: &str) -> Result<Option<LiveInterfaceStats>> {
}
}
if !found {
if !found && !link_exists {
return Ok(None);
}
tracing::debug!(
interface = %name,
listen_port,
peer_count = live_peers.len(),
addresses_count = addresses.len(),
is_up,
"Retrieved live WireGuard interface telemetry"
);
Ok(Some(LiveInterfaceStats {
name: name.to_string(),
public_key,
listen_port,
fwmark,
peers: live_peers,
addresses,
mtu,
is_up,
}))
}
@@ -512,8 +857,8 @@ fn parse_endpoint(s: &str) -> Result<SocketAddr> {
#[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?;
// 1. Ensure the WireGuard link exists and addresses/MTU are configured
ensure_link_and_addresses(interface).await?;
// 2. Configure the WireGuard device (private key, listen port, peers)
configure_device(interface, peers).await?;