Files
nx9-wg/crates/nx9-wireguard/src/config_builder.rs
T
2026-08-18 17:32:56 +05:30

303 lines
11 KiB
Rust

//! 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()));
}
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 host_trimmed.contains(':') && !host_trimmed.starts_with('[') {
// Check if already contains port
host_trimmed.to_string()
} else {
format!("{}:{}", host_trimmed, interface.listen_port)
};
lines.push(format!("Endpoint = {endpoint}"));
// AllowedIPs based on Peer Profile
let allowed_ips = match peer.profile {
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());
if let Some(ref v6) = interface.address_v6 {
subnets.push(v6.to_string());
}
subnets.join(", ")
}
PeerProfile::Custom => {
if peer.allowed_ips.trim().is_empty() {
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()
}
}
};
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 (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"));
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"));
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();
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"));
}
}