release: NX9-WG v1.0.0

This commit is contained in:
thakares committed 2026-08-18 17:32:56 +05:30
1 parent c8a9b7cde6
commit 4dfe42fe68
42 files changed
+4689 -336

No files matched your search

+171
View File
@@ -215,9 +215,82 @@ pub struct Peer {
pub updated_at: NaiveDateTime,
}
impl Peer {
/// Returns the effective server-side WireGuard AllowedIPs string for this peer.
///
/// # Semantics:
/// - If `server_allowed_ips` is explicitly configured and non-empty, it is used after
/// validating CIDRs. For `RoadWarrior` peers, default full-tunnel routes (`0.0.0.0/0`, `::/0`)
/// are strictly forbidden as server-side AllowedIPs.
/// - For `RoadWarrior` peers: derives exclusively from the peer's assigned tunnel addresses
/// (`address_v4/32` and `address_v6/128`).
/// - For non-`RoadWarrior` peers (e.g. `SiteGateway`, `Server`, `Relay`): uses assigned tunnel
/// addresses or explicit subnets from `allowed_ips` (excluding `0.0.0.0/0` and `::/0`).
///
/// # Invariants:
/// - NEVER returns `0.0.0.0/0` or `::/0` as server-side AllowedIPs for a RoadWarrior peer.
/// - NEVER falls back to client full-tunnel routing policy.
pub fn server_wireguard_allowed_ips(&self) -> String {
// 1. Explicit server_allowed_ips override
if let Some(ref s_allowed) = self.server_allowed_ips {
let trimmed = s_allowed.trim();
if !trimmed.is_empty() {
let mut valid_cidrs = Vec::new();
for item in trimmed.split(',') {
let item_trim = item.trim();
if let Ok(net) = item_trim.parse::<IpNet>() {
// For RoadWarrior, reject 0.0.0.0/0 or ::/0
if self.peer_type == PeerType::RoadWarrior && net.prefix_len() == 0 {
continue;
}
valid_cidrs.push(net.to_string());
}
}
if !valid_cidrs.is_empty() {
return valid_cidrs.join(", ");
}
}
}
// 2. Default for RoadWarrior (and default for any peer with assigned addresses):
// Derive exclusively from assigned tunnel addresses (/32 and /128)
let mut addrs = Vec::new();
if let Some(ref v4) = self.address_v4 {
addrs.push(format!("{}/32", v4.addr()));
}
if let Some(ref v6) = self.address_v6 {
addrs.push(format!("{}/128", v6.addr()));
}
if !addrs.is_empty() {
return addrs.join(", ");
}
// 3. For non-RoadWarrior peers without assigned tunnel addresses (e.g. site gateway routing subnets),
// inspect allowed_ips, strictly excluding default 0.0.0.0/0 and ::/0
if self.peer_type != PeerType::RoadWarrior {
let mut valid_cidrs = Vec::new();
for item in self.allowed_ips.split(',') {
let item_trim = item.trim();
if let Ok(net) = item_trim.parse::<IpNet>()
&& net.prefix_len() > 0
{
valid_cidrs.push(net.to_string());
}
}
if !valid_cidrs.is_empty() {
return valid_cidrs.join(", ");
}
}
String::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
#[test]
fn test_peer_type_roundtrip() {
@@ -233,4 +306,102 @@ mod tests {
let pk = WireGuardPrivateKey::new("secret".to_string());
assert_eq!(format!("{:?}", pk), "[REDACTED]");
}
#[test]
fn test_road_warrior_server_allowed_ips_derives_from_assigned_address() {
let now = Utc::now().naive_utc();
let peer = Peer {
id: Uuid::new_v4(),
interface_id: Uuid::new_v4(),
name: "Mobile".to_string(),
peer_type: PeerType::RoadWarrior,
state: PeerState::Active,
public_key: WireGuardPublicKey::new("pubkey123".to_string()),
private_key: None,
preshared_key: None,
endpoint: None,
allowed_ips: "0.0.0.0/0, ::/0".to_string(), // Client full tunnel routing policy
server_allowed_ips: None,
address_v4: Some("10.100.0.9/24".parse().unwrap()),
address_v6: Some("fd00::9/64".parse().unwrap()),
dns: None,
mtu: None,
persistent_keepalive: Some(25),
profile: PeerProfile::FullTunnel,
expires_at: None,
last_handshake_at: None,
created_at: now,
updated_at: now,
};
// Server-side AllowedIPs MUST be 10.100.0.9/32, fd00::9/128 (never 0.0.0.0/0)
assert_eq!(
peer.server_wireguard_allowed_ips(),
"10.100.0.9/32, fd00::9/128"
);
}
#[test]
fn test_road_warrior_explicit_server_allowed_ips_override() {
let now = Utc::now().naive_utc();
let peer = Peer {
id: Uuid::new_v4(),
interface_id: Uuid::new_v4(),
name: "Mobile".to_string(),
peer_type: PeerType::RoadWarrior,
state: PeerState::Active,
public_key: WireGuardPublicKey::new("pubkey123".to_string()),
private_key: None,
preshared_key: None,
endpoint: None,
allowed_ips: "0.0.0.0/0, ::/0".to_string(),
server_allowed_ips: Some("10.100.0.9/32, 192.168.50.0/24".to_string()),
address_v4: Some("10.100.0.9/32".parse().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,
};
assert_eq!(
peer.server_wireguard_allowed_ips(),
"10.100.0.9/32, 192.168.50.0/24"
);
}
#[test]
fn test_road_warrior_forbids_default_route_as_server_allowed_ips() {
let now = Utc::now().naive_utc();
let peer = Peer {
id: Uuid::new_v4(),
interface_id: Uuid::new_v4(),
name: "Mobile".to_string(),
peer_type: PeerType::RoadWarrior,
state: PeerState::Active,
public_key: WireGuardPublicKey::new("pubkey123".to_string()),
private_key: None,
preshared_key: None,
endpoint: None,
allowed_ips: "0.0.0.0/0, ::/0".to_string(),
server_allowed_ips: Some("0.0.0.0/0".to_string()), // Attempt to set full tunnel as server allowed ips
address_v4: Some("10.100.0.9/32".parse().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,
};
// Rejects 0.0.0.0/0 and falls back to assigned address 10.100.0.9/32
assert_eq!(peer.server_wireguard_allowed_ips(), "10.100.0.9/32");
}
}
+21
View File
@@ -146,6 +146,27 @@ pub fn validate_ip_in_network(ip: IpAddr, net: IpNet) -> Result<()> {
Ok(())
}
/// Validate a comma-separated list of CIDR subnets (e.g. "10.100.0.9/32, 192.168.1.0/24").
pub fn validate_allowed_ips_cidr_list(list: &str) -> Result<Vec<IpNet>> {
let mut nets = Vec::new();
for item in list.split(',') {
let trimmed = item.trim();
if trimmed.is_empty() {
continue;
}
let net = validate_cidr(trimmed)?;
if !nets.contains(&net) {
nets.push(net);
}
}
if nets.is_empty() {
return Err(Nx9Error::Validation(
"AllowedIPs list cannot be empty".into(),
));
}
Ok(nets)
}
/// Validate port.
pub fn validate_port(port: u16) -> Result<u16> {
if port == 0 {