nvpn 4.1.13

CLI and daemon for Nostr VPN private mesh networks
use anyhow::{Context, Result};
use fips_core::Identity;
use fips_core::host_firewall::{HostFirewallConfig, HostFirewallGuard};
use nostr_vpn_core::config::AppConfig;
use std::net::Ipv6Addr;
#[cfg(target_os = "linux")]
use std::process::Command as ProcessCommand;

const LEGACY_FIPS_HOST_IFACE: &str = "nvpnfips0";
const HOST_FIREWALL_LINUX_TABLE_NAME: &str = "nvpn_fips_host";
const HOST_FIREWALL_MACOS_ANCHOR_NAME: &str = "com.apple/to.nostrvpn/fips-host";

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FipsHostTunnelConfig {
    pub(crate) fips_address: Ipv6Addr,
    pub(crate) inbound_tcp_ports: Vec<u16>,
}

impl FipsHostTunnelConfig {
    pub(crate) fn from_app(app: &AppConfig) -> Result<Option<Self>> {
        if !app.fips_host_tunnel_enabled {
            return Ok(None);
        }
        if !HostFirewallGuard::platform_available() {
            eprintln!("fips-host: disabled because no FIPS host firewall is available");
            return Ok(None);
        }

        let identity = Identity::from_secret_str(&app.nostr.secret_key)
            .context("failed to derive .fips identity from nostr secret key")?;
        let mut inbound_tcp_ports = app.fips_host_inbound_tcp_ports.clone();
        inbound_tcp_ports.sort_unstable();
        inbound_tcp_ports.dedup();

        Ok(Some(Self {
            fips_address: identity.address().to_ipv6(),
            inbound_tcp_ports,
        }))
    }
}

pub(crate) struct FipsHostTunnelRuntime {
    interface: String,
    config: FipsHostTunnelConfig,
    firewall: Option<HostFirewallGuard>,
}

impl FipsHostTunnelRuntime {
    pub(crate) fn start(interface: &str, config: FipsHostTunnelConfig) -> Result<Self> {
        let firewall =
            HostFirewallGuard::install(&host_firewall_config(interface, &config.inbound_tcp_ports))
                .context("failed to install .fips host firewall")?;
        Ok(Self {
            interface: interface.to_string(),
            config,
            firewall: Some(firewall),
        })
    }

    pub(crate) fn requires_restart(&self, interface: &str, config: &FipsHostTunnelConfig) -> bool {
        self.interface != interface || self.config != *config
    }

    pub(crate) fn stop(mut self) {
        drop(self.firewall.take());
    }

    pub(crate) fn cleanup_disabled_artifacts() {
        HostFirewallGuard::cleanup_disabled_artifacts(&host_firewall_config(
            LEGACY_FIPS_HOST_IFACE,
            &[],
        ));
        cleanup_legacy_resolver_artifacts();
    }
}

fn host_firewall_config(iface: &str, inbound_tcp_ports: &[u16]) -> HostFirewallConfig {
    HostFirewallConfig::new(iface)
        .with_inbound_tcp_ports(inbound_tcp_ports.iter().copied())
        .with_linux_table_name(HOST_FIREWALL_LINUX_TABLE_NAME)
        .with_macos_anchor_name(HOST_FIREWALL_MACOS_ANCHOR_NAME)
}

fn cleanup_legacy_resolver_artifacts() {
    #[cfg(target_os = "macos")]
    {
        remove_legacy_owned_file("/etc/resolver/fips");
    }
    #[cfg(target_os = "linux")]
    {
        if remove_legacy_owned_file("/etc/systemd/resolved.conf.d/nostr-vpn-fips.conf") {
            restart_service("systemd-resolved");
        }
        if remove_legacy_owned_file("/etc/dnsmasq.d/nostr-vpn-fips.conf") {
            reload_service("dnsmasq");
        }
        if remove_legacy_owned_file("/etc/NetworkManager/dnsmasq.d/nostr-vpn-fips.conf") {
            reload_service("NetworkManager");
        }
    }
}

#[cfg(any(target_os = "linux", target_os = "macos"))]
fn remove_legacy_owned_file(path: &str) -> bool {
    matches!(
        std::fs::read_to_string(path),
        Ok(contents) if contents.contains("Managed by nostr-vpn")
    ) && std::fs::remove_file(path).is_ok()
}

#[cfg(target_os = "linux")]
fn restart_service(service: &str) {
    let _ = ProcessCommand::new("systemctl")
        .arg("restart")
        .arg(service)
        .status();
}

#[cfg(target_os = "linux")]
fn reload_service(service: &str) {
    let _ = ProcessCommand::new("systemctl")
        .arg("reload")
        .arg(service)
        .status();
}

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

    #[test]
    fn app_config_builds_integrated_host_tunnel_config() {
        let mut app = AppConfig::generated();
        app.fips_host_tunnel_enabled = true;
        app.fips_host_inbound_tcp_ports = vec![443, 22, 22];

        let maybe_config = FipsHostTunnelConfig::from_app(&app).expect("valid fips host config");
        if !HostFirewallGuard::platform_available() {
            assert!(maybe_config.is_none());
            return;
        }
        let config = maybe_config.expect("enabled when host firewall is available");
        assert_eq!(config.inbound_tcp_ports, vec![22, 443]);
        assert_eq!(
            config.fips_address,
            Identity::from_secret_str(&app.nostr.secret_key)
                .expect("app identity")
                .address()
                .to_ipv6()
        );
    }

    #[test]
    fn app_config_can_disable_host_tunnel() {
        let mut app = AppConfig::generated();
        app.fips_host_tunnel_enabled = false;

        assert!(
            FipsHostTunnelConfig::from_app(&app)
                .expect("valid disabled config")
                .is_none()
        );
    }

    #[test]
    fn host_firewall_config_uses_nvpn_artifact_names() {
        let config = host_firewall_config("utun8", &[443, 22, 22]);

        assert_eq!(config.interface(), "utun8");
        assert_eq!(config.inbound_tcp_ports(), &[22, 443]);
        assert_eq!(config.linux_table_name(), HOST_FIREWALL_LINUX_TABLE_NAME);
        assert_eq!(config.macos_anchor_name(), HOST_FIREWALL_MACOS_ANCHOR_NAME);
    }
}