303 lines
11 KiB
Rust
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"));
|
|
}
|
|
}
|