nvpn 4.0.86

CLI and daemon for Nostr VPN private mesh networks
#[cfg(target_os = "macos")]
fn split_cidr<'a>(address: &'a str, default_prefix: &'a str) -> (&'a str, &'a str) {
    address.split_once('/').unwrap_or((address, default_prefix))
}

#[cfg(target_os = "linux")]
pub(crate) fn linux_ipv4_route_source(address: &str) -> Option<String> {
    strip_cidr(address)
        .parse::<Ipv4Addr>()
        .ok()
        .map(|ip| ip.to_string())
}

#[cfg(any(target_os = "linux", test))]
fn linux_route_replace_args(
    target: &str,
    iface: &str,
    ipv4_route_source: Option<&str>,
) -> Vec<String> {
    let mut args = Vec::new();
    if linux_route_target_is_ipv6(target) {
        args.push("-6".to_string());
    } else if linux_route_target_is_ipv4(target) {
        args.push("-4".to_string());
    }
    args.extend([
        "route".to_string(),
        "replace".to_string(),
        target.to_string(),
        "dev".to_string(),
        iface.to_string(),
    ]);
    if linux_route_target_is_ipv4(target)
        && let Some(source) = ipv4_route_source
    {
        args.push("src".to_string());
        args.push(source.to_string());
    }
    args
}

#[cfg(any(target_os = "linux", target_os = "macos", test))]
pub(crate) fn linux_tunnel_address_is_ipv4(address: &str) -> bool {
    strip_cidr(address).parse::<Ipv4Addr>().is_ok()
}

#[cfg(any(target_os = "linux", target_os = "macos", test))]
pub(crate) fn linux_tunnel_address_is_ipv6(address: &str) -> bool {
    strip_cidr(address).parse::<Ipv6Addr>().is_ok()
}

#[cfg(any(target_os = "linux", test))]
pub(crate) fn linux_route_target_is_ipv4(target: &str) -> bool {
    strip_cidr(target).parse::<Ipv4Addr>().is_ok()
}

#[cfg(any(target_os = "linux", target_os = "macos", test))]
pub(crate) fn linux_route_target_is_ipv6(target: &str) -> bool {
    strip_cidr(target).parse::<Ipv6Addr>().is_ok()
}

#[cfg(target_os = "macos")]
fn apply_macos_route(iface: &str, target: &str) -> Result<()> {
    if linux_route_target_is_ipv6(target) {
        let (target_ip, prefix) = split_cidr(target, "128");
        let _ = ProcessCommand::new("route")
            .arg("delete")
            .arg("-inet6")
            .arg("-prefixlen")
            .arg(prefix)
            .arg(target_ip)
            .arg("-interface")
            .arg(iface)
            .status();
        return run_checked(
            ProcessCommand::new("route")
                .arg("add")
                .arg("-inet6")
                .arg("-prefixlen")
                .arg(prefix)
                .arg(target_ip)
                .arg("-interface")
                .arg(iface),
        );
    }
    if target == "0.0.0.0/0" {
        eprintln!("tunnel: applying macOS default route via interface {iface}");
        return apply_macos_default_route(None, Some(iface));
    }
    apply_macos_route_spec(target, None, Some(iface))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn linux_route_family_helpers_detect_ipv4_and_ipv6_cidrs() {
        assert!(linux_tunnel_address_is_ipv4("10.44.0.1/32"));
        assert!(!linux_tunnel_address_is_ipv6("10.44.0.1/32"));
        assert!(linux_tunnel_address_is_ipv6("fd00::1/128"));
        assert!(!linux_tunnel_address_is_ipv4("fd00::1/128"));
        assert!(linux_route_target_is_ipv4("0.0.0.0/0"));
        assert!(!linux_route_target_is_ipv4("::/0"));
        assert!(linux_route_target_is_ipv6("::/0"));
        assert!(!linux_route_target_is_ipv6("10.44.0.0/16"));
    }

    #[test]
    fn linux_route_replace_args_selects_address_family() {
        assert_eq!(
            linux_route_replace_args("fd00::/8", "utun100", Some("10.44.0.1")),
            vec!["-6", "route", "replace", "fd00::/8", "dev", "utun100"]
        );
        assert_eq!(
            linux_route_replace_args("10.44.0.2/32", "utun100", Some("10.44.0.1")),
            vec![
                "-4",
                "route",
                "replace",
                "10.44.0.2/32",
                "dev",
                "utun100",
                "src",
                "10.44.0.1"
            ]
        );
    }

    #[test]
    fn wireguard_upstream_inbound_drop_rule_blocks_new_mesh_forwards() {
        assert_eq!(
            linux_wireguard_exit_inbound_drop_rule("nvpn-wg-exit", "nvpn0", "10.44.0.0/16"),
            vec![
                "FORWARD",
                "-i",
                "nvpn-wg-exit",
                "-o",
                "nvpn0",
                "-d",
                "10.44.0.0/16",
                "-m",
                "conntrack",
                "--ctstate",
                "NEW,INVALID",
                "-m",
                "comment",
                "--comment",
                "nvpn-wg-upstream-inbound-drop",
                "-j",
                "DROP",
            ]
        );
    }

    #[test]
    fn exit_node_forward_rules_are_scoped_to_mesh_source_and_outbound_iface() {
        assert_eq!(
            linux_exit_node_forward_in_rule(
                "utun100",
                "enp41s0",
                "10.44.0.0/16",
                LinuxExitNodeIpFamily::V4
            ),
            vec![
                "FORWARD",
                "-i",
                "utun100",
                "-o",
                "enp41s0",
                "-s",
                "10.44.0.0/16",
                "-m",
                "comment",
                "--comment",
                "nvpn-exit-forward-in",
                "-j",
                "ACCEPT",
            ]
        );
        assert_eq!(
            linux_exit_node_forward_out_rule("utun100", "enp41s0", LinuxExitNodeIpFamily::V4),
            vec![
                "FORWARD",
                "-i",
                "enp41s0",
                "-o",
                "utun100",
                "-m",
                "conntrack",
                "--ctstate",
                "RELATED,ESTABLISHED",
                "-m",
                "comment",
                "--comment",
                "nvpn-exit-forward-out",
                "-j",
                "ACCEPT",
            ]
        );
        assert_eq!(
            linux_exit_node_ipv4_mss_clamp_rule("utun100", "enp41s0", "10.44.0.0/16", 1110),
            vec![
                "FORWARD",
                "-i",
                "utun100",
                "-o",
                "enp41s0",
                "-s",
                "10.44.0.0/16",
                "-p",
                "tcp",
                "--tcp-flags",
                "SYN,RST",
                "SYN",
                "-m",
                "comment",
                "--comment",
                "nvpn-exit-mss",
                "-j",
                "TCPMSS",
                "--set-mss",
                "1110",
            ]
        );
    }

    #[test]
    fn legacy_exit_node_forward_rules_match_old_unscoped_rules_for_cleanup() {
        assert_eq!(
            linux_exit_node_legacy_forward_in_rule("utun100", LinuxExitNodeIpFamily::V6),
            vec![
                "FORWARD",
                "-i",
                "utun100",
                "-m",
                "comment",
                "--comment",
                "nvpn-exit6-forward-in",
                "-j",
                "ACCEPT",
            ]
        );
        assert_eq!(
            linux_exit_node_legacy_forward_out_rule("utun100", LinuxExitNodeIpFamily::V6),
            vec![
                "FORWARD",
                "-o",
                "utun100",
                "-m",
                "conntrack",
                "--ctstate",
                "RELATED,ESTABLISHED",
                "-m",
                "comment",
                "--comment",
                "nvpn-exit6-forward-out",
                "-j",
                "ACCEPT",
            ]
        );
    }
}