Release v1.1.0
This commit is contained in:
1 parent
34227efd2b
commit
edc710cbd2
46 files changed
+5324
-190
No files matched your search
@@ -91,7 +91,11 @@ impl ClientConfigBuilder {
|
||||
// Check if already contains port
|
||||
host_trimmed.to_string()
|
||||
} else {
|
||||
format!("{}:{}", host_trimmed, interface.listen_port)
|
||||
format!(
|
||||
"{}:{}",
|
||||
host_trimmed,
|
||||
interface.listen_port.unwrap_or(51820)
|
||||
)
|
||||
};
|
||||
lines.push(format!("Endpoint = {endpoint}"));
|
||||
|
||||
@@ -144,7 +148,7 @@ mod tests {
|
||||
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 nx9_wg_core::types::wireguard::{InterfaceRole, PeerState, PeerType};
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -158,9 +162,10 @@ mod tests {
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: srv_priv,
|
||||
public_key: srv_pub.clone(),
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -231,9 +236,10 @@ mod tests {
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: srv_priv,
|
||||
public_key: srv_pub,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
|
||||
@@ -137,7 +137,7 @@ impl WireGuardEngine for SimulatedWireGuardEngine {
|
||||
.filter(|p| p.state == PeerState::Active)
|
||||
.map(|p| {
|
||||
let allowed_ips: Vec<String> = p
|
||||
.server_wireguard_allowed_ips()
|
||||
.server_wireguard_allowed_ips_for_role(interface.role)
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -163,7 +163,7 @@ impl WireGuardEngine for SimulatedWireGuardEngine {
|
||||
let stats = LiveInterfaceStats {
|
||||
name: interface.name.clone(),
|
||||
public_key: interface.public_key.as_str().to_string(),
|
||||
listen_port: interface.listen_port,
|
||||
listen_port: interface.listen_port.unwrap_or(0),
|
||||
fwmark: 0,
|
||||
peers: live_peers,
|
||||
addresses,
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod error;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod native_linux;
|
||||
pub mod qr;
|
||||
pub mod upstream_parser;
|
||||
|
||||
pub use config_builder::ClientConfigBuilder;
|
||||
pub use engine::{
|
||||
@@ -17,3 +18,4 @@ pub use qr::{
|
||||
generate_qr_ascii, generate_qr_base64, generate_qr_data_url, generate_qr_png_bytes,
|
||||
generate_qr_svg,
|
||||
};
|
||||
pub use upstream_parser::{ParsedUpstreamConfig, ParsedUpstreamPeer, UpstreamConfigParser};
|
||||
@@ -450,11 +450,16 @@ async fn configure_device(interface: &Interface, peers: &[Peer]) -> Result<()> {
|
||||
let mut device_attrs: Vec<WireguardAttribute> = vec![
|
||||
WireguardAttribute::IfName(interface.name.clone()),
|
||||
WireguardAttribute::PrivateKey(private_key_bytes),
|
||||
WireguardAttribute::ListenPort(interface.listen_port),
|
||||
WireguardAttribute::Fwmark(0),
|
||||
WireguardAttribute::Flags(WireguardDeviceFlags::ReplacePeers),
|
||||
];
|
||||
|
||||
if let Some(port) = interface.listen_port {
|
||||
if port != 0 {
|
||||
device_attrs.push(WireguardAttribute::ListenPort(port));
|
||||
}
|
||||
}
|
||||
|
||||
// Build peer configurations for active peers only
|
||||
let mut wg_peers = Vec::new();
|
||||
for peer in peers.iter().filter(|p| p.state == PeerState::Active) {
|
||||
@@ -484,7 +489,7 @@ async fn configure_device(interface: &Interface, peers: &[Peer]) -> Result<()> {
|
||||
}
|
||||
|
||||
// Server-side Allowed IPs (cryptokey routing in Linux kernel)
|
||||
let server_allowed_str = peer.server_wireguard_allowed_ips();
|
||||
let server_allowed_str = peer.server_wireguard_allowed_ips_for_role(interface.role);
|
||||
let allowed_ips = parse_allowed_ips(&server_allowed_str)?;
|
||||
if !allowed_ips.is_empty() {
|
||||
peer_attrs.push(WireguardPeerAttribute::Flags(
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
//! Third-party WireGuard configuration (.conf) parser and validator.
|
||||
//!
|
||||
//! Enforces:
|
||||
//! - Exactly one `[Interface]` section containing `PrivateKey` and `Address`.
|
||||
//! - Exactly one `[Peer]` section containing `PublicKey`, `Endpoint`, and `AllowedIPs`.
|
||||
//! - Preservation of `0.0.0.0/0`, `::/0`, and specific CIDRs.
|
||||
//! - Secret safety: Never exposes private or preshared keys in error messages.
|
||||
|
||||
use crate::error::{Result, WireGuardError};
|
||||
use chrono::Utc;
|
||||
use ipnet::IpNet;
|
||||
use nx9_wg_core::crypto::derive_public_key;
|
||||
use nx9_wg_core::types::wireguard::{
|
||||
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType, WireGuardPresharedKey,
|
||||
WireGuardPrivateKey, WireGuardPublicKey,
|
||||
};
|
||||
use nx9_wg_core::validation::{validate_interface_name, validate_listen_port, validate_mtu};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Parsed provider peer definition from standard WireGuard config.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedUpstreamPeer {
|
||||
pub name: String,
|
||||
pub public_key: WireGuardPublicKey,
|
||||
pub preshared_key: Option<WireGuardPresharedKey>,
|
||||
pub endpoint: String,
|
||||
pub allowed_ips: String,
|
||||
pub persistent_keepalive: Option<u16>,
|
||||
}
|
||||
|
||||
/// Fully validated Upstream interface configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedUpstreamConfig {
|
||||
pub interface_name: String,
|
||||
pub private_key: WireGuardPrivateKey,
|
||||
pub public_key: WireGuardPublicKey,
|
||||
pub listen_port: Option<u16>,
|
||||
pub address_v4: IpNet,
|
||||
pub address_v6: Option<IpNet>,
|
||||
pub dns: Option<String>,
|
||||
pub mtu: Option<u16>,
|
||||
pub peer: ParsedUpstreamPeer,
|
||||
}
|
||||
|
||||
impl ParsedUpstreamConfig {
|
||||
/// Convert parsed configuration into desired-state domain structs (`Interface`, `Peer`).
|
||||
pub fn into_desired_state(self, interface_id: Uuid, peer_id: Uuid) -> (Interface, Peer) {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let iface = Interface {
|
||||
id: interface_id,
|
||||
name: self.interface_name,
|
||||
role: InterfaceRole::Upstream,
|
||||
private_key: self.private_key,
|
||||
public_key: self.public_key,
|
||||
listen_port: self.listen_port,
|
||||
address_v4: self.address_v4,
|
||||
address_v6: self.address_v6,
|
||||
mtu: self.mtu,
|
||||
dns: self.dns.clone(),
|
||||
enabled: true,
|
||||
pre_up: None,
|
||||
post_up: None,
|
||||
pre_down: None,
|
||||
post_down: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
let peer = Peer {
|
||||
id: peer_id,
|
||||
interface_id,
|
||||
name: self.peer.name,
|
||||
peer_type: PeerType::Server,
|
||||
state: PeerState::Active,
|
||||
public_key: self.peer.public_key,
|
||||
private_key: None,
|
||||
preshared_key: self.peer.preshared_key,
|
||||
endpoint: Some(self.peer.endpoint),
|
||||
allowed_ips: self.peer.allowed_ips.clone(),
|
||||
server_allowed_ips: Some(self.peer.allowed_ips),
|
||||
address_v4: None,
|
||||
address_v6: None,
|
||||
dns: self.dns,
|
||||
mtu: self.mtu,
|
||||
persistent_keepalive: self.peer.persistent_keepalive,
|
||||
profile: PeerProfile::Custom,
|
||||
expires_at: None,
|
||||
last_handshake_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
(iface, peer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Upstream WireGuard .conf parser.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct UpstreamConfigParser;
|
||||
|
||||
impl UpstreamConfigParser {
|
||||
/// Parse and validate a third-party WireGuard configuration string.
|
||||
pub fn parse(raw_conf: &str, interface_name: &str) -> Result<ParsedUpstreamConfig> {
|
||||
// 1. Validate interface name
|
||||
validate_interface_name(interface_name)
|
||||
.map_err(|e| WireGuardError::Config(format!("invalid interface name: {e}")))?;
|
||||
|
||||
if interface_name == "wg0" {
|
||||
return Err(WireGuardError::Config(
|
||||
"An Upstream interface cannot use the reserved name 'wg0'".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 2. Parse sections and key-values
|
||||
let mut current_section: Option<String> = None;
|
||||
let mut interface_section_count = 0;
|
||||
let mut peer_section_count = 0;
|
||||
|
||||
let mut iface_private_key: Option<String> = None;
|
||||
let mut iface_addresses: Vec<String> = Vec::new();
|
||||
let mut iface_dns: Vec<String> = Vec::new();
|
||||
let mut iface_mtu: Option<u16> = None;
|
||||
let mut iface_listen_port: Option<u16> = None;
|
||||
|
||||
let mut peer_public_key: Option<String> = None;
|
||||
let mut peer_preshared_key: Option<String> = None;
|
||||
let mut peer_endpoint: Option<String> = None;
|
||||
let mut peer_allowed_ips: Vec<String> = Vec::new();
|
||||
let mut peer_keepalive: Option<u16> = None;
|
||||
|
||||
for (line_num, raw_line) in raw_conf.lines().enumerate() {
|
||||
let line_idx = line_num + 1;
|
||||
let line = raw_line.trim();
|
||||
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Section header
|
||||
if line.starts_with('[') && line.ends_with(']') {
|
||||
let sec_name = line[1..line.len() - 1].trim();
|
||||
if sec_name.eq_ignore_ascii_case("interface") {
|
||||
interface_section_count += 1;
|
||||
current_section = Some("Interface".to_string());
|
||||
} else if sec_name.eq_ignore_ascii_case("peer") {
|
||||
peer_section_count += 1;
|
||||
current_section = Some("Peer".to_string());
|
||||
} else {
|
||||
return Err(WireGuardError::Config(format!(
|
||||
"Unsupported section '[{sec_name}]' on line {line_idx}"
|
||||
)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Key-Value pair
|
||||
let (key, val) = match line.split_once('=') {
|
||||
Some((k, v)) => (k.trim(), v.trim()),
|
||||
None => {
|
||||
return Err(WireGuardError::Config(format!(
|
||||
"Invalid key-value syntax on line {line_idx}: '{line}'"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// Remove trailing comments from value if any
|
||||
let clean_val = match val.split_once('#').or_else(|| val.split_once(';')) {
|
||||
Some((clean, _)) => clean.trim(),
|
||||
None => val,
|
||||
};
|
||||
|
||||
match current_section.as_deref() {
|
||||
Some("Interface") => {
|
||||
if key.eq_ignore_ascii_case("privatekey") {
|
||||
if clean_val.is_empty() {
|
||||
return Err(WireGuardError::Config(
|
||||
"PrivateKey value cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
iface_private_key = Some(clean_val.to_string());
|
||||
} else if key.eq_ignore_ascii_case("address") {
|
||||
for addr in clean_val.split(',') {
|
||||
let trimmed = addr.trim();
|
||||
if !trimmed.is_empty() {
|
||||
iface_addresses.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
} else if key.eq_ignore_ascii_case("dns") {
|
||||
for d in clean_val.split(',') {
|
||||
let trimmed = d.trim();
|
||||
if !trimmed.is_empty() {
|
||||
iface_dns.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
} else if key.eq_ignore_ascii_case("mtu") {
|
||||
let parsed_mtu = clean_val.parse::<u16>().map_err(|_| {
|
||||
WireGuardError::Config(format!(
|
||||
"Invalid MTU '{clean_val}' on line {line_idx}"
|
||||
))
|
||||
})?;
|
||||
validate_mtu(parsed_mtu).map_err(|e| {
|
||||
WireGuardError::Config(format!("MTU validation failed: {e}"))
|
||||
})?;
|
||||
iface_mtu = Some(parsed_mtu);
|
||||
} else if key.eq_ignore_ascii_case("listenport") {
|
||||
let parsed_port = clean_val.parse::<u16>().map_err(|_| {
|
||||
WireGuardError::Config(format!(
|
||||
"Invalid ListenPort '{clean_val}' on line {line_idx}"
|
||||
))
|
||||
})?;
|
||||
validate_listen_port(parsed_port).map_err(|e| {
|
||||
WireGuardError::Config(format!("ListenPort validation failed: {e}"))
|
||||
})?;
|
||||
iface_listen_port = Some(parsed_port);
|
||||
} else {
|
||||
tracing::debug!(key = %key, "Ignoring unrecognized Interface setting in upstream config");
|
||||
}
|
||||
}
|
||||
Some("Peer") => {
|
||||
if key.eq_ignore_ascii_case("publickey") {
|
||||
if clean_val.is_empty() {
|
||||
return Err(WireGuardError::Config(
|
||||
"PublicKey value cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
peer_public_key = Some(clean_val.to_string());
|
||||
} else if key.eq_ignore_ascii_case("presharedkey") {
|
||||
if !clean_val.is_empty() {
|
||||
peer_preshared_key = Some(clean_val.to_string());
|
||||
}
|
||||
} else if key.eq_ignore_ascii_case("endpoint") {
|
||||
if clean_val.is_empty() {
|
||||
return Err(WireGuardError::Config(
|
||||
"Endpoint value cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
peer_endpoint = Some(clean_val.to_string());
|
||||
} else if key.eq_ignore_ascii_case("allowedips") {
|
||||
for item in clean_val.split(',') {
|
||||
let trimmed = item.trim();
|
||||
if !trimmed.is_empty() {
|
||||
peer_allowed_ips.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
} else if key.eq_ignore_ascii_case("persistentkeepalive") {
|
||||
let ka = clean_val.parse::<u16>().map_err(|_| {
|
||||
WireGuardError::Config(format!(
|
||||
"Invalid PersistentKeepalive '{clean_val}' on line {line_idx}"
|
||||
))
|
||||
})?;
|
||||
peer_keepalive = Some(ka);
|
||||
} else {
|
||||
tracing::debug!(key = %key, "Ignoring unrecognized Peer setting in upstream config");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
return Err(WireGuardError::Config(format!(
|
||||
"Configuration entry '{line}' found outside any section on line {line_idx}"
|
||||
)));
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Section cardinality checks
|
||||
if interface_section_count == 0 {
|
||||
return Err(WireGuardError::Config(
|
||||
"Missing [Interface] section in WireGuard configuration".to_string(),
|
||||
));
|
||||
}
|
||||
if interface_section_count > 1 {
|
||||
return Err(WireGuardError::Config(format!(
|
||||
"Configuration contains {interface_section_count} [Interface] sections (expected exactly 1)"
|
||||
)));
|
||||
}
|
||||
if peer_section_count == 0 {
|
||||
return Err(WireGuardError::Config(
|
||||
"An Upstream configuration must contain exactly one [Peer] section (found 0)"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if peer_section_count > 1 {
|
||||
return Err(WireGuardError::Config(format!(
|
||||
"An Upstream configuration must contain exactly one [Peer] section (found {peer_section_count})"
|
||||
)));
|
||||
}
|
||||
|
||||
// 4. Validate Interface fields
|
||||
let raw_priv_k = iface_private_key.ok_or_else(|| {
|
||||
WireGuardError::Config(
|
||||
"Missing required 'PrivateKey' in [Interface] section".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let pub_k = derive_public_key(&raw_priv_k).map_err(|_| {
|
||||
WireGuardError::Config(
|
||||
"Invalid PrivateKey: failed to decode 32-byte WireGuard key".to_string(),
|
||||
)
|
||||
})?;
|
||||
let priv_k = WireGuardPrivateKey::new(raw_priv_k);
|
||||
|
||||
if iface_addresses.is_empty() {
|
||||
return Err(WireGuardError::Config(
|
||||
"Missing required 'Address' in [Interface] section".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut v4_addr: Option<IpNet> = None;
|
||||
let mut v6_addr: Option<IpNet> = None;
|
||||
|
||||
for addr_str in &iface_addresses {
|
||||
let net = IpNet::from_str(addr_str).map_err(|e| {
|
||||
WireGuardError::Config(format!("Invalid Address '{addr_str}': {e}"))
|
||||
})?;
|
||||
match net {
|
||||
IpNet::V4(_) => {
|
||||
if v4_addr.is_none() {
|
||||
v4_addr = Some(net);
|
||||
}
|
||||
}
|
||||
IpNet::V6(_) => {
|
||||
if v6_addr.is_none() {
|
||||
v6_addr = Some(net);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let address_v4 = v4_addr.ok_or_else(|| {
|
||||
WireGuardError::Config(
|
||||
"Upstream configuration requires at least one IPv4 address in Address".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let dns = if iface_dns.is_empty() {
|
||||
None
|
||||
} else {
|
||||
for d in &iface_dns {
|
||||
if d.parse::<IpAddr>().is_err() {
|
||||
return Err(WireGuardError::Config(format!("Invalid DNS address '{d}'")));
|
||||
}
|
||||
}
|
||||
Some(iface_dns.join(", "))
|
||||
};
|
||||
|
||||
let listen_port = iface_listen_port;
|
||||
|
||||
// 5. Validate Peer fields
|
||||
let raw_peer_pub = peer_public_key.ok_or_else(|| {
|
||||
WireGuardError::Config("Missing required 'PublicKey' in [Peer] section".to_string())
|
||||
})?;
|
||||
|
||||
// Validate public key format (32 bytes base64)
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
let pub_bytes = STANDARD.decode(raw_peer_pub.trim()).map_err(|_| {
|
||||
WireGuardError::Config("Invalid Peer PublicKey: malformed base64".to_string())
|
||||
})?;
|
||||
if pub_bytes.len() != 32 {
|
||||
return Err(WireGuardError::Config(
|
||||
"Invalid Peer PublicKey: must be 32 bytes (256 bits)".to_string(),
|
||||
));
|
||||
}
|
||||
let peer_pub = WireGuardPublicKey::new(raw_peer_pub);
|
||||
|
||||
let preshared_k = if let Some(psk_str) = peer_preshared_key {
|
||||
let psk_bytes = STANDARD.decode(psk_str.trim()).map_err(|_| {
|
||||
WireGuardError::Config("Invalid Peer PresharedKey: malformed base64".to_string())
|
||||
})?;
|
||||
if psk_bytes.len() != 32 {
|
||||
return Err(WireGuardError::Config(
|
||||
"Invalid Peer PresharedKey: must be 32 bytes (256 bits)".to_string(),
|
||||
));
|
||||
}
|
||||
Some(WireGuardPresharedKey::new(psk_str))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let raw_endpoint = peer_endpoint.ok_or_else(|| {
|
||||
WireGuardError::Config("Missing required 'Endpoint' in [Peer] section".to_string())
|
||||
})?;
|
||||
|
||||
// Validate endpoint
|
||||
validate_endpoint_syntax(&raw_endpoint)?;
|
||||
|
||||
if peer_allowed_ips.is_empty() {
|
||||
return Err(WireGuardError::Config(
|
||||
"Missing required 'AllowedIPs' in [Peer] section".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut validated_allowed_ips = Vec::new();
|
||||
for item in &peer_allowed_ips {
|
||||
let net = IpNet::from_str(item).map_err(|e| {
|
||||
WireGuardError::Config(format!("Invalid AllowedIPs CIDR '{item}': {e}"))
|
||||
})?;
|
||||
validated_allowed_ips.push(net.to_string());
|
||||
}
|
||||
|
||||
Ok(ParsedUpstreamConfig {
|
||||
interface_name: interface_name.to_string(),
|
||||
private_key: priv_k,
|
||||
public_key: pub_k,
|
||||
listen_port,
|
||||
address_v4,
|
||||
address_v6: v6_addr,
|
||||
dns,
|
||||
mtu: iface_mtu,
|
||||
peer: ParsedUpstreamPeer {
|
||||
name: format!("{interface_name}-provider"),
|
||||
public_key: peer_pub,
|
||||
preshared_key: preshared_k,
|
||||
endpoint: raw_endpoint,
|
||||
allowed_ips: validated_allowed_ips.join(", "),
|
||||
persistent_keepalive: peer_keepalive,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate endpoint format: IP:port or hostname:port
|
||||
fn validate_endpoint_syntax(endpoint_str: &str) -> Result<()> {
|
||||
let trimmed = endpoint_str.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(WireGuardError::Config(
|
||||
"Endpoint cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if trimmed.parse::<SocketAddr>().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(idx) = trimmed.rfind(':') {
|
||||
let host = &trimmed[..idx];
|
||||
let port_str = &trimmed[idx + 1..];
|
||||
|
||||
if host.is_empty() {
|
||||
return Err(WireGuardError::Config(format!(
|
||||
"Invalid endpoint '{trimmed}': missing host"
|
||||
)));
|
||||
}
|
||||
|
||||
let port = port_str.parse::<u16>().map_err(|_| {
|
||||
WireGuardError::Config(format!("Invalid endpoint port '{port_str}' in '{trimmed}'"))
|
||||
})?;
|
||||
|
||||
if port == 0 {
|
||||
return Err(WireGuardError::Config(format!(
|
||||
"Invalid endpoint port 0 in '{trimmed}'"
|
||||
)));
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(WireGuardError::Config(format!(
|
||||
"Invalid endpoint '{trimmed}': missing port (expected host:port)"
|
||||
)))
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Comprehensive test suite for third-party WireGuard Upstream .conf parsing and validation.
|
||||
|
||||
use nx9_wg_core::crypto::generate_keypair;
|
||||
use nx9_wg_core::types::wireguard::InterfaceRole;
|
||||
use nx9_wireguard::UpstreamConfigParser;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn test_valid_proton_style_configuration() {
|
||||
let (priv_k, pub_k) = generate_keypair();
|
||||
let (_, peer_pub_k) = generate_keypair();
|
||||
|
||||
let conf = format!(
|
||||
r#"
|
||||
# ProtonVPN WireGuard Configuration
|
||||
[Interface]
|
||||
PrivateKey = {}
|
||||
Address = 10.2.0.2/32
|
||||
DNS = 10.2.0.1
|
||||
MTU = 1420
|
||||
|
||||
[Peer]
|
||||
# Server Node
|
||||
PublicKey = {}
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
Endpoint = 37.19.199.155:51820
|
||||
PersistentKeepalive = 25
|
||||
"#,
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
|
||||
let parsed = UpstreamConfigParser::parse(&conf, "proton0").expect("parse proton config");
|
||||
assert_eq!(parsed.interface_name, "proton0");
|
||||
assert_eq!(parsed.public_key.as_str(), pub_k.as_str());
|
||||
assert_eq!(parsed.address_v4.to_string(), "10.2.0.2/32");
|
||||
assert_eq!(parsed.address_v6, None);
|
||||
assert_eq!(parsed.dns, Some("10.2.0.1".to_string()));
|
||||
assert_eq!(parsed.mtu, Some(1420));
|
||||
assert_eq!(parsed.listen_port, None);
|
||||
|
||||
assert_eq!(parsed.peer.name, "proton0-provider");
|
||||
assert_eq!(parsed.peer.public_key.as_str(), peer_pub_k.as_str());
|
||||
assert_eq!(parsed.peer.endpoint, "37.19.199.155:51820");
|
||||
assert_eq!(parsed.peer.allowed_ips, "0.0.0.0/0, ::/0");
|
||||
assert_eq!(parsed.peer.persistent_keepalive, Some(25));
|
||||
assert_eq!(parsed.peer.preshared_key, None);
|
||||
|
||||
let iface_id = Uuid::new_v4();
|
||||
let peer_id = Uuid::new_v4();
|
||||
let (iface, peer) = parsed.into_desired_state(iface_id, peer_id);
|
||||
|
||||
assert_eq!(iface.id, iface_id);
|
||||
assert_eq!(iface.name, "proton0");
|
||||
assert_eq!(iface.role, InterfaceRole::Upstream);
|
||||
assert_eq!(iface.listen_port, None);
|
||||
assert!(iface.enabled);
|
||||
|
||||
assert_eq!(peer.id, peer_id);
|
||||
assert_eq!(peer.interface_id, iface_id);
|
||||
assert_eq!(peer.name, "proton0-provider");
|
||||
assert_eq!(peer.allowed_ips, "0.0.0.0/0, ::/0");
|
||||
assert_eq!(
|
||||
peer.server_wireguard_allowed_ips_for_role(InterfaceRole::Upstream),
|
||||
"0.0.0.0/0, ::/0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_omitted_listen_port_remains_unspecified() {
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, peer_pub_k) = generate_keypair();
|
||||
|
||||
let conf = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = 37.19.199.155:51820\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
|
||||
let parsed = UpstreamConfigParser::parse(&conf, "proton0").expect("parse config");
|
||||
assert_eq!(parsed.listen_port, None);
|
||||
assert_eq!(parsed.peer.endpoint, "37.19.199.155:51820");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_explicit_listen_port_is_preserved() {
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, peer_pub_k) = generate_keypair();
|
||||
|
||||
let conf = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\nListenPort = 45000\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = 37.19.199.155:51820\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
|
||||
let parsed = UpstreamConfigParser::parse(&conf, "proton0").expect("parse config");
|
||||
assert_eq!(parsed.listen_port, Some(45000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_endpoint_port_is_not_local_listen_port() {
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, peer_pub_k) = generate_keypair();
|
||||
|
||||
let conf = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = 37.19.199.155:51820\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
|
||||
let parsed = UpstreamConfigParser::parse(&conf, "proton0").expect("parse config");
|
||||
assert_eq!(parsed.listen_port, None);
|
||||
assert!(parsed.peer.endpoint.ends_with(":51820"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dual_stack_address_and_preshared_key() {
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, peer_pub_k) = generate_keypair();
|
||||
let (psk, _) = generate_keypair();
|
||||
|
||||
let conf = format!(
|
||||
r#"
|
||||
[Interface]
|
||||
PrivateKey = {}
|
||||
Address = 10.2.0.2/32, fd00::2/64
|
||||
DNS = 10.2.0.1, 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = {}
|
||||
PresharedKey = {}
|
||||
AllowedIPs = 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
||||
Endpoint = vpn.example.com:51820
|
||||
"#,
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str(),
|
||||
psk.as_str()
|
||||
);
|
||||
|
||||
let parsed = UpstreamConfigParser::parse(&conf, "vpn0").expect("parse dual stack");
|
||||
assert_eq!(parsed.address_v4.to_string(), "10.2.0.2/32");
|
||||
assert_eq!(
|
||||
parsed.address_v6.map(|ip| ip.to_string()),
|
||||
Some("fd00::2/64".to_string())
|
||||
);
|
||||
assert_eq!(parsed.dns, Some("10.2.0.1, 1.1.1.1".to_string()));
|
||||
assert!(parsed.peer.preshared_key.is_some());
|
||||
assert_eq!(parsed.peer.preshared_key.unwrap().as_str(), psk.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_wg0_interface_name() {
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, peer_pub_k) = generate_keypair();
|
||||
let conf = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
|
||||
let err = UpstreamConfigParser::parse(&conf, "wg0").unwrap_err();
|
||||
assert!(err.to_string().contains("reserved name 'wg0'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cardinality_rejections() {
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let (_, peer_pub_k) = generate_keypair();
|
||||
|
||||
// 0 peers
|
||||
let no_peers = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n",
|
||||
priv_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&no_peers, "proton0").is_err());
|
||||
|
||||
// 2 peers
|
||||
let multi_peers = format!(
|
||||
r#"
|
||||
[Interface]
|
||||
PrivateKey = {}
|
||||
Address = 10.2.0.2/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = {}
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 1.2.3.4:51820
|
||||
|
||||
[Peer]
|
||||
PublicKey = {}
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 5.6.7.8:51820
|
||||
"#,
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
let err = UpstreamConfigParser::parse(&multi_peers, "proton0").unwrap_err();
|
||||
assert!(err.to_string().contains("exactly one [Peer] section"));
|
||||
|
||||
// Missing [Interface]
|
||||
let no_iface = format!(
|
||||
"[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&no_iface, "proton0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_and_malformed_fields_rejections() {
|
||||
let (_, peer_pub_k) = generate_keypair();
|
||||
|
||||
// Missing PrivateKey
|
||||
let no_priv = format!(
|
||||
"[Interface]\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&no_priv, "proton0").is_err());
|
||||
|
||||
// Malformed PrivateKey
|
||||
let bad_priv = format!(
|
||||
"[Interface]\nPrivateKey = not-a-key\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&bad_priv, "proton0").is_err());
|
||||
|
||||
// Missing Address
|
||||
let (priv_k, _) = generate_keypair();
|
||||
let no_addr = format!(
|
||||
"[Interface]\nPrivateKey = {}\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&no_addr, "proton0").is_err());
|
||||
|
||||
// Malformed Address
|
||||
let bad_addr = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 999.999.999.999/99\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&bad_addr, "proton0").is_err());
|
||||
|
||||
// Missing PublicKey
|
||||
let no_pub = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
|
||||
priv_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&no_pub, "proton0").is_err());
|
||||
|
||||
// Missing Endpoint
|
||||
let no_ep = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&no_ep, "proton0").is_err());
|
||||
|
||||
// Missing AllowedIPs
|
||||
let no_aips = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Peer]\nPublicKey = {}\nEndpoint = 1.2.3.4:51820\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&no_aips, "proton0").is_err());
|
||||
|
||||
// Unknown section
|
||||
let unknown_sec = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.2.0.2/32\n[Unknown]\nKey = Val\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = 1.2.3.4:51820\n",
|
||||
priv_k.as_str(),
|
||||
peer_pub_k.as_str()
|
||||
);
|
||||
assert!(UpstreamConfigParser::parse(&unknown_sec, "proton0").is_err());
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
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_wg_core::types::wireguard::{
|
||||
Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType,
|
||||
};
|
||||
use nx9_wireguard::{
|
||||
ClientConfigBuilder, SimulatedWireGuardEngine, WireGuardEngine, generate_qr_ascii,
|
||||
generate_qr_data_url, generate_qr_png_bytes, generate_qr_svg,
|
||||
@@ -23,9 +25,10 @@ async fn test_wireguard_engine_lifecycle_and_telemetry() {
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: srv_priv,
|
||||
public_key: srv_pub.clone(),
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
@@ -138,9 +141,10 @@ fn test_client_config_and_qr_codes() {
|
||||
let iface = Interface {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wg0".to_string(),
|
||||
role: InterfaceRole::Overlay,
|
||||
private_key: srv_priv,
|
||||
public_key: srv_pub,
|
||||
listen_port: 51820,
|
||||
listen_port: Some(51820),
|
||||
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
|
||||
address_v6: None,
|
||||
mtu: Some(1420),
|
||||
|
||||
Reference in new issue
Block a user