fluidattacks-core 0.1.5

Fluid Attacks Core Library
Documentation
use anyhow::{bail, Context, Result};
use std::time::Duration;
use tokio::process::Command;
use tokio::time::sleep;

pub async fn connect() -> Result<()> {
    run_warp_cli(&["connect"]).await?;
    sleep(Duration::from_secs(5)).await;
    Ok(())
}

pub async fn disconnect() -> Result<()> {
    run_warp_cli(&["disconnect"]).await?;
    sleep(Duration::from_secs(5)).await;
    Ok(())
}

pub async fn status() -> Result<String> {
    let output = Command::new("warp-cli")
        .args(["--accept-tos", "status"])
        .output()
        .await
        .context("running warp-cli status")?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        bail!("warp-cli status failed: {stderr}");
    }

    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

pub async fn set_virtual_network(vnet_name: &str) -> Result<()> {
    let vnet_id = get_virtual_network_id(vnet_name).await?;
    run_warp_cli(&["vnet", &vnet_id]).await?;
    sleep(Duration::from_secs(5)).await;
    Ok(())
}

pub async fn test_public_ip(expected_ip: &str) -> Result<bool> {
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(10))
        .build()?;

    let resp = client
        .get("https://api.ipify.org")
        .send()
        .await
        .context("fetching public IP")?;

    let ip = resp.text().await.context("reading IP response")?;
    Ok(ip.trim() == expected_ip)
}

pub async fn wait_public_ip(expected_ip: &str, attempts: u32, interval_secs: u64) -> Result<()> {
    for i in 0..attempts {
        if test_public_ip(expected_ip).await.unwrap_or(false) {
            return Ok(());
        }
        if i + 1 < attempts {
            sleep(Duration::from_secs(interval_secs)).await;
        }
    }
    bail!("public IP did not match {expected_ip} after {attempts} attempts");
}

pub async fn wait_dns_ready(host: &str, attempts: u32, interval_secs: u64) -> Result<()> {
    for i in 0..attempts {
        let output = Command::new("nslookup")
            .arg(host)
            .output()
            .await
            .context("running nslookup")?;

        if output.status.success() {
            return Ok(());
        }
        if i + 1 < attempts {
            sleep(Duration::from_secs(interval_secs)).await;
        }
    }
    bail!("DNS resolution for {host} failed after {attempts} attempts");
}

pub async fn is_using_split_tunnel(host: &str) -> Result<bool> {
    let output = Command::new("ip")
        .args(["route", "get", host])
        .output()
        .await
        .context("running ip route get")?;

    let stdout = String::from_utf8_lossy(&output.stdout);
    Ok(stdout.contains("CloudflareWARP"))
}

pub async fn get_virtual_network_id(vnet_name: &str) -> Result<String> {
    let output = tokio::time::timeout(
        Duration::from_secs(30),
        Command::new("warp-cli")
            .args(["--accept-tos", "vnet"])
            .output(),
    )
    .await
    .context("warp-cli vnet timed out")?
    .context("running warp-cli vnet")?;

    let stdout = String::from_utf8_lossy(&output.stdout);
    let pattern = format!("ID: (.*)\n  Name: {}\n", regex::escape(vnet_name));
    let re = regex::Regex::new(&pattern).context("building vnet regex")?;

    re.captures(&stdout)
        .and_then(|c| c.get(1))
        .map(|m| m.as_str().trim().to_string())
        .ok_or_else(|| anyhow::anyhow!("virtual network '{vnet_name}' not found"))
}

async fn run_warp_cli(args: &[&str]) -> Result<()> {
    let mut full_args = vec!["--accept-tos"];
    full_args.extend(args);

    let output = tokio::time::timeout(
        Duration::from_secs(30),
        Command::new("warp-cli").args(&full_args).output(),
    )
    .await
    .context("warp-cli timed out")?
    .context("running warp-cli")?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        bail!("warp-cli {} failed: {}", args.join(" "), stderr);
    }

    Ok(())
}