xbp 10.15.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! NordVPN meshnet setup and passthrough (feature-gated under `nordvpn`).
//!
//! Provides setup flow: install from snap, login with token, configure meshnet
//! routing, and invite management. Also passthrough for arbitrary nordvpn commands.

use crate::logging::{log_info, log_success, log_warn};
use crate::utils::command_exists;
use colored::Colorize;
use dialoguer::Input;
use regex::Regex;
use std::process::Stdio;
use tokio::process::Command;

const NORDVPN_BIN: &str = "nordvpn";

/// Run nordvpn command: setup flow or passthrough to nordvpn CLI.
pub async fn run_nordvpn(args: Vec<String>, debug: bool) -> Result<(), String> {
    if args.is_empty() || (args.len() == 1 && (args[0] == "help" || args[0] == "--help" || args[0] == "-h")) {
        return show_help().await;
    }

    if args[0] == "setup" {
        return run_setup(debug).await;
    }

    // Passthrough: run nordvpn with the given args
    run_passthrough(&args, debug).await
}

async fn show_help() -> Result<(), String> {
    println!("{}", "NordVPN (xbp nordvpn)".bright_cyan().bold());
    println!("{}", "".repeat(50).dimmed());
    println!();
    println!("  {}  Run full setup (nordvpn, meshnet, SSH, optional Azure CLI)", "setup".bright_cyan());
    println!("  {}  Passthrough to nordvpn CLI", "meshnet peer list".dimmed());
    println!();
    println!("{}", "Examples:".bright_blue());
    println!("  xbp nordvpn setup");
    println!("  xbp nordvpn meshnet peer list");
    println!("  xbp nordvpn meshnet peer list -filter external");
    println!("  xbp nordvpn meshnet invite send user@example.com");
    println!("  xbp nordvpn meshnet invite list");
    println!("  xbp nordvpn meshnet invite accept user@example.com");
    println!();
    Ok(())
}

async fn run_setup(debug: bool) -> Result<(), String> {
    let _ = log_info("nordvpn", "Starting NordVPN setup", None).await;

    ensure_nordvpn_installed(debug).await?;
    login_with_token(debug).await?;
    set_meshnet_on(debug).await?;
    let nickname = ensure_meshnet_routing(debug).await?;
    ensure_ssh_access(debug).await?;
    maybe_install_azure_cli(debug).await?;

    let _ = log_success(
        "nordvpn",
        &format!("Setup complete. This device: {}", nickname),
        None,
    )
    .await;

    // Optional: probe for email and send invite
    let send_invite: String = Input::new()
        .with_prompt("Send meshnet invite to email? (leave empty to skip)")
        .allow_empty(true)
        .interact_text()
        .unwrap_or_default();

    let send_invite = send_invite.trim();
    if !send_invite.is_empty() {
        let _ = log_info("nordvpn", &format!("Sending invite to {}", send_invite), None).await;
        let out = Command::new(NORDVPN_BIN)
            .args(["meshnet", "invite", "send", send_invite])
            .output()
            .await;
        match out {
            Ok(o) if o.status.success() => {
                let _ = log_success("nordvpn", "Invite sent", None).await;
            }
            Ok(o) => {
                let stderr = String::from_utf8_lossy(&o.stderr);
                let _ = log_warn("nordvpn", &format!("Invite send failed: {}", stderr), None).await;
            }
            Err(e) => {
                let _ = log_warn("nordvpn", &format!("Invite send failed: {}", e), None).await;
            }
        }
    }

    println!("\n{}", "Optional next steps:".bright_blue());
    println!("  xbp nordvpn meshnet peer list -filter external");
    println!("  xbp nordvpn meshnet invite list");
    println!("  xbp nordvpn meshnet invite accept <email>");

    Ok(())
}

async fn ensure_nordvpn_installed(_debug: bool) -> Result<(), String> {
    if command_exists(NORDVPN_BIN) {
        let _ = log_info("nordvpn", "NordVPN is already installed", None).await;
        return Ok(());
    }

    #[cfg(target_os = "linux")]
    {
        let _ = log_info("nordvpn", "NordVPN not found. Installing from snap...", None).await;

        if !command_exists("snap") {
            return Err("snap is required to install NordVPN. Install snapd first.".to_string());
        }

        let output = Command::new("sudo")
            .args(["snap", "install", "nordvpn"])
            .output()
            .await
            .map_err(|e| format!("Failed to run snap install: {}", e))?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(format!("snap install nordvpn failed: {}", stderr));
        }

        let _ = log_success("nordvpn", "NordVPN installed from snap", None).await;
    }

    #[cfg(not(target_os = "linux"))]
    {
        Err(
            "NordVPN is not installed. On Linux, run: sudo snap install nordvpn\n\
             On Windows/macOS, install NordVPN from the official website."
                .to_string(),
        )
    }
}

async fn login_with_token(_debug: bool) -> Result<(), String> {
    let token: String = Input::new()
        .with_prompt("NordVPN login token")
        .interact_text()
        .map_err(|e| format!("Failed to read token: {}", e))?;

    let token = token.trim();
    if token.is_empty() {
        return Err("Token cannot be empty".to_string());
    }

    let _ = log_info("nordvpn", "Logging in with token...", None).await;

    let output = Command::new(NORDVPN_BIN)
        .args(["login", "--token", token])
        .output()
        .await
        .map_err(|e| format!("nordvpn login failed: {}", e))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let stdout = String::from_utf8_lossy(&output.stdout);
        return Err(format!(
            "nordvpn login failed: {}{}",
            stdout,
            if stderr.is_empty() { stderr.to_string() } else { format!("\n{}", stderr) }
        ));
    }

    let _ = log_success("nordvpn", "Logged in successfully", None).await;
    Ok(())
}

async fn set_meshnet_on(_debug: bool) -> Result<(), String> {
    let _ = log_info("nordvpn", "Enabling meshnet...", None).await;

    let output = Command::new(NORDVPN_BIN)
        .args(["set", "meshnet", "on"])
        .output()
        .await
        .map_err(|e| format!("nordvpn set meshnet on failed: {}", e))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(format!("nordvpn set meshnet on failed: {}", stderr));
    }

    let _ = log_success("nordvpn", "Meshnet enabled", None).await;
    Ok(())
}

#[cfg(target_os = "linux")]
async fn ensure_ssh_access(_debug: bool) -> Result<(), String> {
    let _ = log_info("nordvpn", "Enabling SSH access...", None).await;

    let apt_update = Command::new("sudo")
        .args(["apt", "update"])
        .output()
        .await
        .map_err(|e| format!("apt update failed: {}", e))?;

    if !apt_update.status.success() {
        let stderr = String::from_utf8_lossy(&apt_update.stderr);
        return Err(format!("apt update failed: {}", stderr));
    }

    let apt_install = Command::new("sudo")
        .args(["apt", "install", "-y", "openssh-server"])
        .output()
        .await
        .map_err(|e| format!("apt install openssh-server failed: {}", e))?;

    if !apt_install.status.success() {
        let stderr = String::from_utf8_lossy(&apt_install.stderr);
        return Err(format!("apt install openssh-server failed: {}", stderr));
    }

    let _ = log_info("nordvpn", "Checking SSH service status...", None).await;
    let _ = Command::new("sudo")
        .args(["systemctl", "status", "ssh"])
        .output()
        .await;

    let ufw = Command::new("sudo")
        .args(["ufw", "allow", "ssh"])
        .output()
        .await;

    if let Ok(o) = ufw {
        if o.status.success() {
            let _ = log_success("nordvpn", "SSH allowed in UFW", None).await;
        } else {
            let _ = log_warn("nordvpn", "ufw allow ssh may have failed (ufw might not be active)", None).await;
        }
    } else {
        let _ = log_warn("nordvpn", "ufw not available or failed", None).await;
    }

    let _ = log_success("nordvpn", "SSH access enabled", None).await;
    Ok(())
}

#[cfg(not(target_os = "linux"))]
async fn ensure_ssh_access(_debug: bool) -> Result<(), String> {
    let _ = log_info("nordvpn", "SSH setup skipped (Linux only)", None).await;
    Ok(())
}

#[cfg(target_os = "linux")]
async fn maybe_install_azure_cli(_debug: bool) -> Result<(), String> {
    if command_exists("az") {
        let _ = log_info("nordvpn", "Azure CLI already installed", None).await;
        return Ok(());
    }

    let install: String = Input::new()
        .with_prompt("Install Azure CLI? (y/N)")
        .default("N".into())
        .allow_empty(true)
        .interact_text()
        .unwrap_or_else(|_| "n".to_string());

    if !install.trim().eq_ignore_ascii_case("y") && !install.trim().eq_ignore_ascii_case("yes") {
        return Ok(());
    }

    let _ = log_info("nordvpn", "Installing Azure CLI...", None).await;

    let output = Command::new("sh")
        .args(["-c", "curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash"])
        .output()
        .await
        .map_err(|e| format!("Azure CLI install failed: {}", e))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(format!("Azure CLI install failed: {}", stderr));
    }

    let _ = log_success("nordvpn", "Azure CLI installed", None).await;
    Ok(())
}

#[cfg(not(target_os = "linux"))]
async fn maybe_install_azure_cli(_debug: bool) -> Result<(), String> {
    Ok(())
}

/// Run `sudo nordvpn meshnet peer list`, parse "This device:" section to get nickname.
async fn ensure_meshnet_routing(_debug: bool) -> Result<String, String> {
    let _ = log_info("nordvpn", "Fetching meshnet peer list...", None).await;

    let output = Command::new("sudo")
        .args([NORDVPN_BIN, "meshnet", "peer", "list"])
        .output()
        .await
        .map_err(|e| format!("nordvpn meshnet peer list failed: {}", e))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(format!("nordvpn meshnet peer list failed: {}", stderr));
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let nickname = parse_this_device_nickname(&stdout)
        .ok_or_else(|| format!("Could not parse 'This device' nickname from:\n{}", stdout))?;

    let _ = log_info(
        "nordvpn",
        &format!("This device nickname: {}", nickname),
        None,
    )
    .await;

    println!("\n{}", "Enabling meshnet peer routing for this device...".bright_blue().dimmed());

    let allow_output = Command::new("sudo")
        .args([NORDVPN_BIN, "meshnet", "peer", "routing", "allow", &nickname])
        .output()
        .await
        .map_err(|e| format!("nordvpn meshnet peer routing allow failed: {}", e))?;

    if !allow_output.status.success() {
        let stderr = String::from_utf8_lossy(&allow_output.stderr);
        let _ = log_warn(
            "nordvpn",
            &format!("peer routing allow may have failed: {}", stderr),
            None,
        )
        .await;
    } else {
        let _ = log_success("nordvpn", "Meshnet peer routing enabled", None).await;
    }

    println!("\n{}", "Meshnet peers:".bright_blue().dimmed());
    println!("{}", stdout);

    Ok(nickname)
}

/// Parse "Nickname: vpn-vsf1" from the "This device:" section.
fn parse_this_device_nickname(output: &str) -> Option<String> {
    let re = Regex::new(r"(?m)Nickname:\s*(\S+)").ok()?;
    let mut found_this_device = false;

    for line in output.lines() {
        if line.trim().starts_with("This device:") {
            found_this_device = true;
            continue;
        }
        if found_this_device {
            if let Some(cap) = re.captures(line) {
                return Some(cap.get(1)?.as_str().to_string());
            }
            if line.trim().is_empty() {
                break;
            }
        }
    }

    // Fallback: first match of Nickname: in the whole output
    re.captures(output).map(|cap| cap.get(1).unwrap().as_str().to_string())
}

async fn run_passthrough(args: &[String], debug: bool) -> Result<(), String> {
    if debug {
        let _ = log_info("nordvpn", &format!("Running: nordvpn {}", args.join(" ")), None).await;
    }

    let mut cmd = Command::new(NORDVPN_BIN);
    cmd.args(args);

    let status = cmd
        .stdin(Stdio::inherit())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .status()
        .await
        .map_err(|e| format!("Failed to run nordvpn: {}", e))?;

    if !status.success() {
        return Err(format!(
            "nordvpn exited with code {}",
            status.code().unwrap_or(-1)
        ));
    }

    Ok(())
}