cli: avoid data-dir initialization for version; create db parent dirs; redact generated passwords in CLI output
- Prevent 'nx9-wg version' from creating data directories by avoiding database initialization. - Create parent directories when an explicit --database path is provided. - Redact printed generated administrator passwords; announce file path or redact instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
commit
2ac6c81dfe
140 files changed
+31342
No files matched your search
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "nx9-wireguard"
|
||||
description = "WireGuard netlink operations and key management for nx9-wg"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nx9-wg-core.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
base64.workspace = true
|
||||
ipnet.workspace = true
|
||||
qrcode.workspace = true
|
||||
image.workspace = true
|
||||
async-trait = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,279 @@
|
||||
//! WireGuard client configuration file generator.
|
||||
|
||||
use crate::error::{Result, WireGuardError};
|
||||
use nx9_wg_core::types::client_profile::ResolvedClientProfile;
|
||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile};
|
||||
|
||||
/// Generator for standard client WireGuard configuration files (.conf).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ClientConfigBuilder;
|
||||
|
||||
impl ClientConfigBuilder {
|
||||
/// Build a standard WireGuard client configuration string with default settings.
|
||||
pub fn build(peer: &Peer, interface: &Interface, server_host_or_ip: &str) -> Result<String> {
|
||||
Self::build_with_profile(peer, interface, server_host_or_ip, None)
|
||||
}
|
||||
|
||||
/// Build a complete standard WireGuard client configuration string with an optional resolved client profile.
|
||||
///
|
||||
/// The profile influences:
|
||||
/// - MTU (derived from provider/device/connection/NAT environment)
|
||||
/// - PersistentKeepalive (if specified in profile)
|
||||
/// - Optional DNS overrides
|
||||
///
|
||||
/// The profile explicitly DOES NOT alter:
|
||||
/// - Peer PrivateKey, PublicKey, PresharedKey
|
||||
/// - Peer IP addresses (IPv4 & IPv6)
|
||||
/// - Server Endpoint authority
|
||||
/// - Server AllowedIPs authority
|
||||
pub fn build_with_profile(
|
||||
peer: &Peer,
|
||||
interface: &Interface,
|
||||
server_host_or_ip: &str,
|
||||
profile: Option<&ResolvedClientProfile>,
|
||||
) -> Result<String> {
|
||||
let private_key = peer.private_key.as_ref().ok_or_else(|| {
|
||||
WireGuardError::Config("Peer does not have a private key stored".to_string())
|
||||
})?;
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// 1. [Interface] Section
|
||||
lines.push("[Interface]".to_string());
|
||||
lines.push(format!("PrivateKey = {}", private_key.as_str()));
|
||||
|
||||
// Address
|
||||
let mut addresses = Vec::new();
|
||||
if let Some(ref v4) = peer.address_v4 {
|
||||
addresses.push(v4.to_string());
|
||||
}
|
||||
if let Some(ref v6) = peer.address_v6 {
|
||||
addresses.push(v6.to_string());
|
||||
}
|
||||
if !addresses.is_empty() {
|
||||
lines.push(format!("Address = {}", addresses.join(", ")));
|
||||
}
|
||||
|
||||
// DNS (Profile DNS > Peer DNS > Interface DNS)
|
||||
let dns = profile
|
||||
.and_then(|p| p.dns.as_deref())
|
||||
.or(peer.dns.as_deref())
|
||||
.or(interface.dns.as_deref());
|
||||
if let Some(d) = dns.filter(|s| !s.trim().is_empty()) {
|
||||
lines.push(format!("DNS = {d}"));
|
||||
}
|
||||
|
||||
// MTU (Profile MTU > Peer MTU > Interface MTU)
|
||||
let mtu = profile.map(|p| p.mtu).or(peer.mtu).or(interface.mtu);
|
||||
if let Some(m) = mtu {
|
||||
lines.push(format!("MTU = {m}"));
|
||||
}
|
||||
|
||||
lines.push("".to_string());
|
||||
|
||||
// 2. [Peer] Section (Server)
|
||||
lines.push("[Peer]".to_string());
|
||||
lines.push(format!("PublicKey = {}", interface.public_key.as_str()));
|
||||
|
||||
if let Some(ref psk) = peer.preshared_key {
|
||||
lines.push(format!("PresharedKey = {}", psk.as_str()));
|
||||
}
|
||||
|
||||
// Endpoint
|
||||
let endpoint = if server_host_or_ip.contains(':') && !server_host_or_ip.starts_with('[') {
|
||||
// Check if already contains port
|
||||
server_host_or_ip.to_string()
|
||||
} else {
|
||||
format!("{}:{}", server_host_or_ip, 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::SplitTunnel => {
|
||||
let mut subnets = Vec::new();
|
||||
subnets.push(interface.address_v4.to_string());
|
||||
if let Some(ref v6) = interface.address_v6 {
|
||||
subnets.push(v6.to_string());
|
||||
}
|
||||
subnets.join(", ")
|
||||
}
|
||||
PeerProfile::Custom => {
|
||||
if peer.allowed_ips.trim().is_empty() {
|
||||
"0.0.0.0/0, ::/0".to_string()
|
||||
} else {
|
||||
peer.allowed_ips.clone()
|
||||
}
|
||||
}
|
||||
};
|
||||
lines.push(format!("AllowedIPs = {allowed_ips}"));
|
||||
|
||||
// PersistentKeepalive (Profile Keepalive > Peer Keepalive)
|
||||
let keepalive = profile
|
||||
.and_then(|p| p.persistent_keepalive)
|
||||
.or(peer.persistent_keepalive);
|
||||
if let Some(ka) = keepalive.filter(|&ka| ka > 0) {
|
||||
lines.push(format!("PersistentKeepalive = {ka}"));
|
||||
}
|
||||
|
||||
Ok(lines.join("\n") + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::crypto::{generate_keypair, generate_preshared_key};
|
||||
use nx9_wg_core::types::wireguard::{PeerState, PeerType};
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn test_client_config_generation_full_and_split() {
|
||||
let (srv_priv, srv_pub) = generate_keypair();
|
||||
let (peer_priv, peer_pub) = generate_keypair();
|
||||
let psk = generate_preshared_key();
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
private_key: srv_priv,
|
||||
public_key: srv_pub.clone(),
|
||||
listen_port: 51820,
|
||||
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
dns: Some("1.1.1.1".to_string()),
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
let mut peer = Peer {
|
||||
id: Uuid::new_v4(),
|
||||
interface_id: iface.id,
|
||||
name: "mobile-bob".to_string(),
|
||||
peer_type: PeerType::RoadWarrior,
|
||||
state: PeerState::Active,
|
||||
public_key: peer_pub,
|
||||
private_key: Some(peer_priv.clone()),
|
||||
preshared_key: Some(psk.clone()),
|
||||
endpoint: None,
|
||||
allowed_ips: "10.0.0.2/32".to_string(),
|
||||
server_allowed_ips: None,
|
||||
address_v4: Some(IpNet::from_str("10.0.0.2/32").unwrap()),
|
||||
address_v6: None,
|
||||
dns: None,
|
||||
mtu: None,
|
||||
persistent_keepalive: Some(25),
|
||||
profile: PeerProfile::FullTunnel,
|
||||
expires_at: None,
|
||||
last_handshake_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
// Full Tunnel
|
||||
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"));
|
||||
assert!(full_conf.contains("DNS = 1.1.1.1"));
|
||||
assert!(full_conf.contains("MTU = 1420"));
|
||||
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("PersistentKeepalive = 25"));
|
||||
|
||||
// Split Tunnel
|
||||
peer.profile = PeerProfile::SplitTunnel;
|
||||
let split_conf = ClientConfigBuilder::build(&peer, &iface, "vpn.example.com").unwrap();
|
||||
assert!(split_conf.contains("AllowedIPs = 10.0.0.1/24"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_config_with_resolved_profile() {
|
||||
let (srv_priv, srv_pub) = generate_keypair();
|
||||
let (peer_priv, peer_pub) = generate_keypair();
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
private_key: srv_priv,
|
||||
public_key: srv_pub,
|
||||
listen_port: 51820,
|
||||
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
dns: Some("1.1.1.1".to_string()),
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
let peer = Peer {
|
||||
id: Uuid::new_v4(),
|
||||
interface_id: iface.id,
|
||||
name: "cgnat-peer".to_string(),
|
||||
peer_type: PeerType::RoadWarrior,
|
||||
state: PeerState::Active,
|
||||
public_key: peer_pub,
|
||||
private_key: Some(peer_priv),
|
||||
preshared_key: None,
|
||||
endpoint: None,
|
||||
allowed_ips: "10.0.0.5/32".to_string(),
|
||||
server_allowed_ips: None,
|
||||
address_v4: Some(IpNet::from_str("10.0.0.5/32").unwrap()),
|
||||
address_v6: None,
|
||||
dns: None,
|
||||
mtu: None,
|
||||
persistent_keepalive: None,
|
||||
profile: PeerProfile::FullTunnel,
|
||||
expires_at: None,
|
||||
last_handshake_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
let resolved_profile = ResolvedClientProfile {
|
||||
mtu: 1280,
|
||||
persistent_keepalive: Some(20),
|
||||
dns: Some("9.9.9.9".to_string()),
|
||||
is_manually_overridden: false,
|
||||
applied_profile_id: "default-mobile".to_string(),
|
||||
applied_profile_name: "Default Mobile".to_string(),
|
||||
connection_type: nx9_wg_core::types::client_profile::ConnectionType::Mobile,
|
||||
nat_type: nx9_wg_core::types::client_profile::NatType::Cgnat,
|
||||
device: Some(nx9_wg_core::types::client_profile::DeviceCategory::Android),
|
||||
provider: Some("tmobile".to_string()),
|
||||
warning: None,
|
||||
};
|
||||
|
||||
let conf = ClientConfigBuilder::build_with_profile(
|
||||
&peer,
|
||||
&iface,
|
||||
"vpn.example.com",
|
||||
Some(&resolved_profile),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(conf.contains("MTU = 1280"));
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! WireGuard interface controller and live state engine.
|
||||
|
||||
use crate::error::{Result, WireGuardError};
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerState};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Live statistics for a connected WireGuard peer.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LivePeerStats {
|
||||
pub public_key: String,
|
||||
pub endpoint: Option<String>,
|
||||
pub rx_bytes: u64,
|
||||
pub tx_bytes: u64,
|
||||
pub last_handshake_at: Option<NaiveDateTime>,
|
||||
pub allowed_ips: Vec<String>,
|
||||
pub persistent_keepalive: Option<u16>,
|
||||
}
|
||||
|
||||
/// Live status and peer metrics for a WireGuard interface.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LiveInterfaceStats {
|
||||
pub name: String,
|
||||
pub public_key: String,
|
||||
pub listen_port: u16,
|
||||
pub fwmark: u32,
|
||||
pub peers: Vec<LivePeerStats>,
|
||||
}
|
||||
|
||||
/// Abstract WireGuard Engine interface for kernel netlink and simulated environments.
|
||||
#[async_trait::async_trait]
|
||||
pub trait WireGuardEngine: Send + Sync {
|
||||
/// Reconcile and synchronize kernel state with desired interface configuration and active peers.
|
||||
async fn sync_interface(&self, interface: &Interface, peers: &[Peer]) -> Result<()>;
|
||||
|
||||
/// Remove a WireGuard interface from the system.
|
||||
async fn delete_interface(&self, name: &str) -> Result<()>;
|
||||
|
||||
/// Read live statistics and peer telemetry from the kernel.
|
||||
async fn get_interface_stats(&self, name: &str) -> Result<Option<LiveInterfaceStats>>;
|
||||
|
||||
/// List all managed WireGuard interface names.
|
||||
async fn list_interfaces(&self) -> Result<Vec<String>>;
|
||||
}
|
||||
|
||||
/// In-memory simulated WireGuard engine for deterministic tests and non-root development.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SimulatedWireGuardEngine {
|
||||
state: Arc<RwLock<HashMap<String, LiveInterfaceStats>>>,
|
||||
}
|
||||
|
||||
impl SimulatedWireGuardEngine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulate a handshake from a peer with transfer byte increments.
|
||||
pub async fn simulate_peer_activity(
|
||||
&self,
|
||||
interface_name: &str,
|
||||
peer_public_key: &str,
|
||||
rx_add: u64,
|
||||
tx_add: u64,
|
||||
) -> Result<()> {
|
||||
let mut map = self.state.write().await;
|
||||
if let Some(iface) = map.get_mut(interface_name) {
|
||||
for peer in &mut iface.peers {
|
||||
if peer.public_key == peer_public_key {
|
||||
peer.rx_bytes += rx_add;
|
||||
peer.tx_bytes += tx_add;
|
||||
peer.last_handshake_at = Some(Utc::now().naive_utc());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(WireGuardError::Interface(format!(
|
||||
"Peer '{peer_public_key}' on interface '{interface_name}' not found"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl WireGuardEngine for SimulatedWireGuardEngine {
|
||||
async fn sync_interface(&self, interface: &Interface, peers: &[Peer]) -> Result<()> {
|
||||
let mut map = self.state.write().await;
|
||||
|
||||
let live_peers: Vec<LivePeerStats> = peers
|
||||
.iter()
|
||||
.filter(|p| p.state == PeerState::Active)
|
||||
.map(|p| {
|
||||
let allowed_ips: Vec<String> = p
|
||||
.allowed_ips
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
LivePeerStats {
|
||||
public_key: p.public_key.as_str().to_string(),
|
||||
endpoint: p.endpoint.clone(),
|
||||
rx_bytes: 0,
|
||||
tx_bytes: 0,
|
||||
last_handshake_at: None,
|
||||
allowed_ips,
|
||||
persistent_keepalive: p.persistent_keepalive,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
map.insert(interface.name.clone(), stats);
|
||||
tracing::debug!(interface = %interface.name, "Simulated WireGuard interface synchronized");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_interface(&self, name: &str) -> Result<()> {
|
||||
let mut map = self.state.write().await;
|
||||
map.remove(name);
|
||||
tracing::debug!(interface = %name, "Simulated WireGuard interface deleted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_interface_stats(&self, name: &str) -> Result<Option<LiveInterfaceStats>> {
|
||||
let map = self.state.read().await;
|
||||
Ok(map.get(name).cloned())
|
||||
}
|
||||
|
||||
async fn list_interfaces(&self) -> Result<Vec<String>> {
|
||||
let map = self.state.read().await;
|
||||
Ok(map.keys().cloned().collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux Native WireGuard Engine using kernel netlink / interfaces.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NativeLinuxWireGuardEngine {
|
||||
simulated_fallback: SimulatedWireGuardEngine,
|
||||
}
|
||||
|
||||
impl NativeLinuxWireGuardEngine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
simulated_fallback: SimulatedWireGuardEngine::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if Linux kernel WireGuard module / interface 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
|
||||
async fn delete_interface(&self, name: &str) -> Result<()> {
|
||||
self.simulated_fallback.delete_interface(name).await
|
||||
}
|
||||
|
||||
async fn get_interface_stats(&self, name: &str) -> Result<Option<LiveInterfaceStats>> {
|
||||
self.simulated_fallback.get_interface_stats(name).await
|
||||
}
|
||||
|
||||
async fn list_interfaces(&self) -> Result<Vec<String>> {
|
||||
self.simulated_fallback.list_interfaces().await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Error types for WireGuard operations.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, WireGuardError>;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WireGuardError {
|
||||
#[error("interface error: {0}")]
|
||||
Interface(String),
|
||||
|
||||
#[error("netlink error: {0}")]
|
||||
Netlink(String),
|
||||
|
||||
#[error("configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("QR generation error: {0}")]
|
||||
Qr(String),
|
||||
|
||||
#[error("key error: {0}")]
|
||||
Key(String),
|
||||
|
||||
#[error("permission denied: {0}")]
|
||||
PermissionDenied(String),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("core error: {0}")]
|
||||
Core(#[from] nx9_wg_core::error::Nx9Error),
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! WireGuard netlink operations, configuration generation, and key management for nx9-wg.
|
||||
|
||||
pub mod config_builder;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod qr;
|
||||
|
||||
pub use config_builder::ClientConfigBuilder;
|
||||
pub use engine::{
|
||||
LiveInterfaceStats, LivePeerStats, NativeLinuxWireGuardEngine, SimulatedWireGuardEngine,
|
||||
WireGuardEngine,
|
||||
};
|
||||
pub use error::{Result, WireGuardError};
|
||||
pub use qr::{
|
||||
generate_qr_ascii, generate_qr_base64, generate_qr_data_url, generate_qr_png_bytes,
|
||||
generate_qr_svg,
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
//! QR Code generation for WireGuard mobile enrollment.
|
||||
|
||||
use crate::error::{Result, WireGuardError};
|
||||
use base64::Engine;
|
||||
use image::Luma;
|
||||
use qrcode::QrCode;
|
||||
use qrcode::render::svg;
|
||||
|
||||
/// Generate SVG string for a WireGuard configuration.
|
||||
pub fn generate_qr_svg(content: &str) -> Result<String> {
|
||||
let code = QrCode::new(content.as_bytes())
|
||||
.map_err(|e| WireGuardError::Qr(format!("Failed to generate QR code: {e}")))?;
|
||||
|
||||
let image = code
|
||||
.render::<svg::Color>()
|
||||
.min_dimensions(256, 256)
|
||||
.dark_color(svg::Color("#000000"))
|
||||
.light_color(svg::Color("#ffffff"))
|
||||
.build();
|
||||
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
/// Generate PNG bytes for a WireGuard configuration.
|
||||
pub fn generate_qr_png_bytes(content: &str) -> Result<Vec<u8>> {
|
||||
let code = QrCode::new(content.as_bytes())
|
||||
.map_err(|e| WireGuardError::Qr(format!("Failed to generate QR code: {e}")))?;
|
||||
|
||||
let image = code.render::<Luma<u8>>().min_dimensions(300, 300).build();
|
||||
|
||||
let mut buffer = std::io::Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(&mut buffer, image::ImageFormat::Png)
|
||||
.map_err(|e| WireGuardError::Qr(format!("Failed to encode QR PNG: {e}")))?;
|
||||
|
||||
Ok(buffer.into_inner())
|
||||
}
|
||||
|
||||
/// Generate base64-encoded PNG string for a WireGuard configuration.
|
||||
pub fn generate_qr_base64(content: &str) -> Result<String> {
|
||||
let png_bytes = generate_qr_png_bytes(content)?;
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(png_bytes))
|
||||
}
|
||||
|
||||
/// Generate base64 Data URL (data:image/png;base64,...) for embedding in HTML / UI.
|
||||
pub fn generate_qr_data_url(content: &str) -> Result<String> {
|
||||
let b64 = generate_qr_base64(content)?;
|
||||
Ok(format!("data:image/png;base64,{b64}"))
|
||||
}
|
||||
|
||||
/// Generate ASCII QR code for direct CLI rendering.
|
||||
pub fn generate_qr_ascii(content: &str) -> Result<String> {
|
||||
let code = QrCode::new(content.as_bytes())
|
||||
.map_err(|e| WireGuardError::Qr(format!("Failed to generate QR code: {e}")))?;
|
||||
|
||||
let string = code
|
||||
.render::<qrcode::render::unicode::Dense1x2>()
|
||||
.dark_color(qrcode::render::unicode::Dense1x2::Dark)
|
||||
.light_color(qrcode::render::unicode::Dense1x2::Light)
|
||||
.build();
|
||||
|
||||
Ok(string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_qr_generation_svg_png_ascii() {
|
||||
let text = "[Interface]\nPrivateKey = aaaa\n";
|
||||
let svg = generate_qr_svg(text).expect("svg");
|
||||
assert!(svg.contains("<svg"));
|
||||
|
||||
let png = generate_qr_png_bytes(text).expect("png");
|
||||
assert!(!png.is_empty());
|
||||
assert_eq!(&png[1..4], b"PNG");
|
||||
|
||||
let data_url = generate_qr_data_url(text).expect("data url");
|
||||
assert!(data_url.starts_with("data:image/png;base64,"));
|
||||
|
||||
let ascii = generate_qr_ascii(text).expect("ascii");
|
||||
assert!(!ascii.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Integration tests for Phase 4 WireGuard Engine, Client Config Generator, and QR Code system.
|
||||
|
||||
use chrono::Utc;
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::crypto::{generate_keypair, generate_preshared_key};
|
||||
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
|
||||
use nx9_wireguard::{
|
||||
ClientConfigBuilder, SimulatedWireGuardEngine, WireGuardEngine, generate_qr_ascii,
|
||||
generate_qr_data_url, generate_qr_png_bytes, generate_qr_svg,
|
||||
};
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wireguard_engine_lifecycle_and_telemetry() {
|
||||
let engine = SimulatedWireGuardEngine::new();
|
||||
|
||||
let (srv_priv, srv_pub) = generate_keypair();
|
||||
let (peer1_priv, peer1_pub) = generate_keypair();
|
||||
let (peer2_priv, peer2_pub) = generate_keypair();
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
private_key: srv_priv,
|
||||
public_key: srv_pub.clone(),
|
||||
listen_port: 51820,
|
||||
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
dns: Some("1.1.1.1".to_string()),
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
let p1 = Peer {
|
||||
id: Uuid::new_v4(),
|
||||
interface_id: iface.id,
|
||||
name: "laptop-user".to_string(),
|
||||
peer_type: PeerType::RoadWarrior,
|
||||
state: PeerState::Active,
|
||||
public_key: peer1_pub.clone(),
|
||||
private_key: Some(peer1_priv),
|
||||
preshared_key: Some(generate_preshared_key()),
|
||||
endpoint: Some("198.51.100.2:45000".to_string()),
|
||||
allowed_ips: "10.0.0.2/32".to_string(),
|
||||
server_allowed_ips: None,
|
||||
address_v4: Some(IpNet::from_str("10.0.0.2/32").unwrap()),
|
||||
address_v6: None,
|
||||
dns: None,
|
||||
mtu: None,
|
||||
persistent_keepalive: Some(25),
|
||||
profile: PeerProfile::FullTunnel,
|
||||
expires_at: None,
|
||||
last_handshake_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
let p2 = Peer {
|
||||
id: Uuid::new_v4(),
|
||||
interface_id: iface.id,
|
||||
name: "phone-user".to_string(),
|
||||
peer_type: PeerType::RoadWarrior,
|
||||
state: PeerState::Disabled, // disabled peer should NOT be in active kernel set
|
||||
public_key: peer2_pub.clone(),
|
||||
private_key: Some(peer2_priv),
|
||||
preshared_key: None,
|
||||
endpoint: None,
|
||||
allowed_ips: "10.0.0.3/32".to_string(),
|
||||
server_allowed_ips: None,
|
||||
address_v4: Some(IpNet::from_str("10.0.0.3/32").unwrap()),
|
||||
address_v6: None,
|
||||
dns: None,
|
||||
mtu: None,
|
||||
persistent_keepalive: None,
|
||||
profile: PeerProfile::FullTunnel,
|
||||
expires_at: None,
|
||||
last_handshake_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
// 1. Sync interface with engine
|
||||
engine
|
||||
.sync_interface(&iface, &[p1.clone(), p2.clone()])
|
||||
.await
|
||||
.expect("sync interface");
|
||||
|
||||
// 2. Read live interface stats
|
||||
let stats = engine
|
||||
.get_interface_stats("wg0")
|
||||
.await
|
||||
.expect("get stats")
|
||||
.expect("interface exists");
|
||||
assert_eq!(stats.name, "wg0");
|
||||
assert_eq!(stats.public_key, srv_pub.as_str());
|
||||
assert_eq!(
|
||||
stats.peers.len(),
|
||||
1,
|
||||
"only active peers should be synchronized"
|
||||
);
|
||||
assert_eq!(stats.peers[0].public_key, peer1_pub.as_str());
|
||||
|
||||
// 3. Simulate peer traffic activity
|
||||
engine
|
||||
.simulate_peer_activity("wg0", peer1_pub.as_str(), 1024, 2048)
|
||||
.await
|
||||
.expect("simulate activity");
|
||||
|
||||
let updated_stats = engine.get_interface_stats("wg0").await.unwrap().unwrap();
|
||||
assert_eq!(updated_stats.peers[0].rx_bytes, 1024);
|
||||
assert_eq!(updated_stats.peers[0].tx_bytes, 2048);
|
||||
assert!(updated_stats.peers[0].last_handshake_at.is_some());
|
||||
|
||||
// 4. Delete interface
|
||||
engine.delete_interface("wg0").await.expect("delete");
|
||||
let after_del = engine.get_interface_stats("wg0").await.expect("query");
|
||||
assert!(after_del.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_config_and_qr_codes() {
|
||||
let (srv_priv, srv_pub) = generate_keypair();
|
||||
let (peer_priv, peer_pub) = generate_keypair();
|
||||
let psk = generate_preshared_key();
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
private_key: srv_priv,
|
||||
public_key: srv_pub,
|
||||
listen_port: 51820,
|
||||
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
dns: Some("1.1.1.1".to_string()),
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
let peer = Peer {
|
||||
id: Uuid::new_v4(),
|
||||
interface_id: iface.id,
|
||||
name: "ipad-charlie".to_string(),
|
||||
peer_type: PeerType::RoadWarrior,
|
||||
state: PeerState::Active,
|
||||
public_key: peer_pub,
|
||||
private_key: Some(peer_priv),
|
||||
preshared_key: Some(psk),
|
||||
endpoint: None,
|
||||
allowed_ips: "10.0.0.5/32".to_string(),
|
||||
server_allowed_ips: None,
|
||||
address_v4: Some(IpNet::from_str("10.0.0.5/32").unwrap()),
|
||||
address_v6: None,
|
||||
dns: None,
|
||||
mtu: None,
|
||||
persistent_keepalive: Some(25),
|
||||
profile: PeerProfile::FullTunnel,
|
||||
expires_at: None,
|
||||
last_handshake_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
let config_str = ClientConfigBuilder::build(&peer, &iface, "203.0.113.10").unwrap();
|
||||
assert!(config_str.contains("[Interface]"));
|
||||
assert!(config_str.contains("[Peer]"));
|
||||
assert!(config_str.contains("Endpoint = 203.0.113.10:51820"));
|
||||
|
||||
// Verify all QR code output formats
|
||||
let svg = generate_qr_svg(&config_str).unwrap();
|
||||
assert!(svg.contains("<svg"));
|
||||
|
||||
let png_bytes = generate_qr_png_bytes(&config_str).unwrap();
|
||||
assert!(!png_bytes.is_empty());
|
||||
assert_eq!(&png_bytes[1..4], b"PNG");
|
||||
|
||||
let data_url = generate_qr_data_url(&config_str).unwrap();
|
||||
assert!(data_url.starts_with("data:image/png;base64,"));
|
||||
|
||||
let ascii = generate_qr_ascii(&config_str).unwrap();
|
||||
assert!(!ascii.is_empty());
|
||||
}
|
||||
Reference in new issue
Block a user