nvpn 4.1.13

CLI and daemon for Nostr VPN private mesh networks
#[path = "wireguard_exit/linux_runtime.rs"]
mod linux_runtime;

use std::net::{IpAddr, Ipv4Addr, SocketAddrV4, ToSocketAddrs};

use anyhow::{Context, Result, anyhow};
use nostr_vpn_core::config::WireGuardExitConfig;

pub(crate) use linux_runtime::{
    LinuxWireGuardExitCleanupObligation, LinuxWireGuardExitRuntime,
    apply_linux_wireguard_exit_upstream, cleanup_linux_wireguard_exit_obligation,
    cleanup_linux_wireguard_exit_upstream,
};

pub(crate) fn validate_linux_wireguard_exit_config(config: &WireGuardExitConfig) -> Result<String> {
    if !config.enabled {
        return Err(anyhow!("WireGuard exit upstream is disabled"));
    }
    let iface = config.interface.trim();
    if !linux_iface_name_is_safe(iface) {
        return Err(anyhow!("invalid WireGuard exit interface '{iface}'"));
    }
    if config.address.trim().is_empty() {
        return Err(anyhow!(
            "WireGuard exit upstream is missing a tunnel address"
        ));
    }
    if config.private_key.trim().is_empty() {
        return Err(anyhow!("WireGuard exit upstream is missing a private key"));
    }
    if config.peer_public_key.trim().is_empty() {
        return Err(anyhow!(
            "WireGuard exit upstream is missing a peer public key"
        ));
    }
    if config.endpoint.trim().is_empty() {
        return Err(anyhow!(
            "WireGuard exit upstream is missing a peer endpoint"
        ));
    }
    if !config.allowed_ips.iter().any(|route| route == "0.0.0.0/0") {
        return Err(anyhow!(
            "WireGuard exit upstream allowed IPs must include 0.0.0.0/0"
        ));
    }
    Ok(iface.to_string())
}

pub(crate) fn linux_wireguard_exit_ipv6_default(config: &WireGuardExitConfig) -> bool {
    config.allowed_ips.iter().any(|route| route == "::/0")
        && config
            .address
            .split('/')
            .next()
            .is_some_and(|ip| ip.contains(':'))
}

fn validated_linux_wireguard_underlay_default_route(
    route: &str,
    wireguard_iface: &str,
) -> Option<String> {
    let mut lines = route.lines().map(str::trim).filter(|line| !line.is_empty());
    let line = lines.next()?;
    if lines.next().is_some() || line.split_whitespace().next() != Some("default") {
        return None;
    }
    let spec = crate::linux_route_get_spec_from_output(line)?;
    if spec.dev == wireguard_iface {
        return None;
    }
    Some(line.to_string())
}

pub(crate) fn select_linux_wireguard_underlay_default_route(
    fresh_hint: Option<&str>,
    previous_runtime_route: Option<&str>,
    current_route: Option<&str>,
    wireguard_iface: &str,
) -> Option<String> {
    [fresh_hint, previous_runtime_route, current_route]
        .into_iter()
        .flatten()
        .find_map(|route| validated_linux_wireguard_underlay_default_route(route, wireguard_iface))
}

pub(super) fn resolve_linux_wireguard_exit_endpoint(endpoint: &str) -> Result<SocketAddrV4> {
    let (host, port) = crate::split_host_port(endpoint.trim(), 51820)
        .ok_or_else(|| anyhow!("invalid WireGuard exit endpoint '{endpoint}'"))?;
    if let Ok(ip) = host.parse::<Ipv4Addr>() {
        return Ok(SocketAddrV4::new(ip, port));
    }
    if host.parse::<IpAddr>().is_ok() {
        return Err(anyhow!(
            "Linux WireGuard exit endpoint '{endpoint}' must resolve to IPv4"
        ));
    }
    (host.as_str(), port)
        .to_socket_addrs()
        .with_context(|| format!("resolve WireGuard exit endpoint '{endpoint}'"))?
        .find_map(|addr| match addr.ip() {
            IpAddr::V4(ip) => Some(SocketAddrV4::new(ip, addr.port())),
            IpAddr::V6(_) => None,
        })
        .ok_or_else(|| {
            anyhow!("WireGuard exit endpoint '{endpoint}' resolved to no supported IPv4 address")
        })
}

fn linux_iface_name_is_safe(iface: &str) -> bool {
    !iface.is_empty()
        && iface.len() <= 15
        && iface
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
}

#[cfg(test)]
mod tests {
    use std::net::{Ipv4Addr, SocketAddrV4};

    use super::{
        resolve_linux_wireguard_exit_endpoint, select_linux_wireguard_underlay_default_route,
    };

    #[test]
    fn endpoint_resolver_pins_ipv4_endpoint_and_default_port() {
        assert_eq!(
            resolve_linux_wireguard_exit_endpoint("198.51.100.20:51830").expect("explicit port"),
            SocketAddrV4::new("198.51.100.20".parse::<Ipv4Addr>().unwrap(), 51830)
        );
        assert_eq!(
            resolve_linux_wireguard_exit_endpoint("198.51.100.20").expect("default port"),
            SocketAddrV4::new("198.51.100.20".parse::<Ipv4Addr>().unwrap(), 51820)
        );
        let ipv6_error = resolve_linux_wireguard_exit_endpoint("[2001:db8::1]:51820")
            .expect_err("IPv6-only endpoint must fail closed");
        assert!(format!("{ipv6_error:#}").contains("must resolve to IPv4"));
    }

    #[test]
    fn fresh_underlay_hint_replaces_stale_wireguard_runtime_route() {
        let old = "default via 192.0.2.1 dev enp1s0 src 192.0.2.10 metric 100";
        let fresh = "default via 198.51.100.1 dev enp7s0 src 198.51.100.10 metric 600";
        let selected = select_linux_wireguard_underlay_default_route(
            Some(fresh),
            Some(old),
            Some("default dev nvwg0 src 10.200.0.2"),
            "nvwg0",
        )
        .expect("fresh physical default");
        assert_eq!(selected, fresh);

        let bypass = crate::linux_endpoint_bypass_route_from_output(
            "203.0.113.9".parse().expect("host"),
            "203.0.113.9 via 192.0.2.1 dev enp1s0 src 192.0.2.10",
            "nvwg0",
            Some(&selected),
        )
        .expect("fresh WireGuard endpoint bypass");
        assert_eq!(bypass.dev, "enp7s0");
        assert_eq!(bypass.gateway.as_deref(), Some("198.51.100.1"));
        assert_eq!(bypass.src.as_deref(), Some("198.51.100.10"));
    }

    #[test]
    fn rejects_tunnel_and_malformed_default_route_hints() {
        assert_eq!(
            select_linux_wireguard_underlay_default_route(
                Some("default dev nvwg0"),
                Some("not-default via 192.0.2.1 dev enp1s0"),
                Some("default via 192.0.2.1 dev enp1s0"),
                "nvwg0",
            )
            .as_deref(),
            Some("default via 192.0.2.1 dev enp1s0")
        );
    }
}