fluidattacks-core-git 0.68.0

Fluid Attacks Core Git
Documentation
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use tokio::process::Command;
use tokio::time::sleep;

const WARP_CLI_TIMEOUT_SECS: u64 = 30;
const ROUTE_TIMEOUT_SECS: u64 = 5;
const CONFIG_DELAY_SECS: u64 = 5;
const DNS_ATTEMPTS: u32 = 40;
const DNS_INTERVAL_SECS: u64 = 5;
const DOMAIN_TO_TEST_DNS: &str = "api.ipify.org";
const IP_SERVICE_URL: &str = "https://api.ipify.org?format=text";

/// Every failure warp reports, carrying the message consumers match on.
#[derive(Debug)]
pub struct WarpError {
    message: String,
}

impl WarpError {
    fn new(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
        }
    }
}

impl std::fmt::Display for WarpError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(formatter, "{}", self.message)
    }
}

impl std::error::Error for WarpError {}

pub async fn connect() -> Result<(), WarpError> {
    warp_cli(&["connect"]).await?;
    sleep(Duration::from_secs(CONFIG_DELAY_SECS)).await;
    if !wait_dns_ready(DOMAIN_TO_TEST_DNS, DNS_ATTEMPTS, DNS_INTERVAL_SECS).await {
        return Err(WarpError::new("Failed to resolve DNS"));
    }
    Ok(())
}

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

pub async fn status() -> Result<String, WarpError> {
    warp_cli(&["status"]).await
}

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

async fn fetch_public_ip() -> Option<String> {
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(10))
        .build()
        .inspect_err(|error| tracing::error!(error = %error, "error building the public IP client"))
        .ok()?;

    let response = client
        .get(IP_SERVICE_URL)
        .send()
        .await
        .inspect_err(|error| tracing::error!(error = %error, "error fetching public IP"))
        .ok()?;

    if response.status() != reqwest::StatusCode::OK {
        tracing::error!(status = %response.status(), "failed to fetch public IP");
        return None;
    }

    response
        .text()
        .await
        .inspect_err(|error| tracing::error!(error = %error, "error reading public IP"))
        .ok()
}

pub async fn test_public_ip(expected_ip: &str) -> bool {
    fetch_public_ip()
        .await
        .is_some_and(|public_ip| public_ip.trim() == expected_ip)
}

pub async fn wait_public_ip(expected_ip: &str, attempts: u32, interval_secs: u64) -> bool {
    for attempt in 0..attempts {
        if test_public_ip(expected_ip).await {
            return true;
        }
        if attempt.saturating_add(1) < attempts {
            sleep(Duration::from_secs(interval_secs)).await;
        }
    }
    false
}

pub async fn wait_dns_ready(host: &str, attempts: u32, interval_secs: u64) -> bool {
    for attempt in 0..attempts {
        if resolve_ipv4(host).await.is_some() {
            return true;
        }
        if attempt.saturating_add(1) < attempts {
            sleep(Duration::from_secs(interval_secs)).await;
        }
    }
    false
}

pub async fn is_using_split_tunnel(host: &str) -> bool {
    let target = resolve_host(host).await;
    let route = tokio::time::timeout(
        Duration::from_secs(ROUTE_TIMEOUT_SECS),
        Command::new("ip")
            .args(["route", "get", &target])
            .kill_on_drop(true)
            .output(),
    )
    .await;

    let output = match route {
        Ok(Ok(output)) => output,
        Ok(Err(error)) => {
            tracing::error!(host, error = %error, "error getting IP route in split tunnel");
            return false;
        }
        Err(_) => {
            tracing::error!(host, "timeout getting IP route in split tunnel");
            return false;
        }
    };

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        tracing::error!(host, %stderr, "error getting IP route in split tunnel");
        return false;
    }

    String::from_utf8_lossy(&output.stdout).contains("CloudflareWARP")
}

pub async fn get_virtual_network_id(vnet_name: &str) -> Result<String, WarpError> {
    parse_virtual_network_id(&warp_cli(&["vnet"]).await?, vnet_name)
}

fn parse_virtual_network_id(vnets: &str, vnet_name: &str) -> Result<String, WarpError> {
    let pattern = format!("ID: (.*)\n  Name: {}\n", regex::escape(vnet_name));
    let expression =
        regex::Regex::new(&pattern).map_err(|error| WarpError::new(error.to_string()))?;

    expression
        .captures(vnets)
        .and_then(|captures| captures.get(1))
        .map(|id| id.as_str().trim().to_owned())
        .ok_or_else(|| WarpError::new(format!("Failed to find virtual network {vnet_name}")))
}

async fn resolve_host(host: &str) -> String {
    resolve_ipv4(host)
        .await
        .map_or_else(|| host.to_owned(), |address| address.to_string())
}

async fn resolve_ipv4(host: &str) -> Option<IpAddr> {
    first_ipv4(tokio::net::lookup_host((host, 0)).await.ok()?)
}

fn first_ipv4(addresses: impl Iterator<Item = SocketAddr>) -> Option<IpAddr> {
    addresses.map(|address| address.ip()).find(IpAddr::is_ipv4)
}

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

    let output = tokio::time::timeout(
        Duration::from_secs(WARP_CLI_TIMEOUT_SECS),
        Command::new("warp-cli")
            .args(&full_args)
            .kill_on_drop(true)
            .output(),
    )
    .await
    .map_err(|_| WarpError::new("Failed to run command"))?
    .map_err(|_| WarpError::new("Failed to run command"))?;

    if !output.status.success() {
        return Err(WarpError::new(
            String::from_utf8_lossy(&output.stderr).trim(),
        ));
    }

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

#[cfg(test)]
mod tests {
    use super::{first_ipv4, parse_virtual_network_id};
    use std::net::SocketAddr;

    const VNETS: &str = "\
Currently selected: 92d3926f-f86c-498c-b27c-85a2037f0d70

Virtual Networks:
  ID: 11fba1a0-4abe-4e66-8a9e-182506fdc482
  Name: amsterdam
  Comment: ZTNA Default Virtual Network
  Default: false

  ID: 49936bfc-52c8-4247-8a8a-da1ff1cb2eed
  Name: egress-ips
  Comment: ZTNA Default Virtual Network
  Default: false
";

    #[test]
    fn a_virtual_network_is_read_by_the_name_that_follows_its_id() {
        assert_eq!(
            parse_virtual_network_id(VNETS, "egress-ips").unwrap(),
            "49936bfc-52c8-4247-8a8a-da1ff1cb2eed"
        );
    }

    #[test]
    fn a_missing_virtual_network_is_reported_by_the_name_asked_for() {
        let error = parse_virtual_network_id(VNETS, "nope").unwrap_err();
        assert_eq!(error.to_string(), "Failed to find virtual network nope");
    }

    #[test]
    fn a_host_that_answers_on_both_families_routes_through_its_ipv4() {
        let addresses = [
            "[2606:4700:90:0:f22e:fbec:5bed:a9b9]:0"
                .parse::<SocketAddr>()
                .unwrap(),
            "172.65.251.78:0".parse::<SocketAddr>().unwrap(),
        ];

        assert_eq!(
            first_ipv4(addresses.into_iter()).unwrap().to_string(),
            "172.65.251.78"
        );
    }

    #[test]
    fn a_host_that_answers_on_ipv6_alone_does_not_resolve() {
        let addresses = ["[2607:f8b0:4004:c07::71]:0".parse::<SocketAddr>().unwrap()];

        assert!(first_ipv4(addresses.into_iter()).is_none());
    }
}