//! 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 { 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 { 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")); } }