vopono_core 0.1.17

Library code for running VPN connections in network namespaces
Documentation
use anyhow::{Context, anyhow};
use log::{debug, warn};
use serde::{Deserialize, Serialize};
use std::process::Command;
use std::str::FromStr;

#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct NetworkInterface {
    pub name: String,
}

impl NetworkInterface {
    pub fn new(name: String) -> anyhow::Result<Self> {
        Ok(Self { name })
    }
}

impl FromStr for NetworkInterface {
    type Err = anyhow::Error;

    fn from_str(s: &str) -> anyhow::Result<Self> {
        let interfaces = get_active_interfaces();

        let cond = interfaces.map(|is| is.iter().any(|x| x == s));

        match cond {
            Ok(true) => {}
            _ => {
                warn!(
                    "{s} may not be an active network interface, using anyway since manually set"
                );
            }
        }
        Ok(Self {
            name: String::from(s),
        })
    }
}

pub fn get_active_interfaces() -> anyhow::Result<Vec<String>> {
    debug!("ip addr");
    let output = Command::new("ip")
        .arg("addr")
        .output()
        .with_context(|| "Failed to run command: ip addr".to_string())?
        .stdout;

    let out = parse_active_interfaces(std::str::from_utf8(&output)?);

    if !out.is_empty() {
        Ok(out)
    } else {
        Err(anyhow!(
            "Failed to get active network interface - consider using -i argument to override network interface"
        ))
    }
}

pub fn interface_has_default_route(interface: &str) -> anyhow::Result<bool> {
    let v4_output = Command::new("ip")
        .args(["-o", "route", "show", "default", "dev", interface])
        .output()
        .with_context(|| {
            format!("Failed to run command: ip -o route show default dev {interface}")
        })?;
    let v6_output = Command::new("ip")
        .args(["-o", "-6", "route", "show", "default", "dev", interface])
        .output()
        .with_context(|| {
            format!("Failed to run command: ip -o -6 route show default dev {interface}")
        })?;

    Ok(
        route_output_has_default(std::str::from_utf8(&v4_output.stdout)?)
            || route_output_has_default(std::str::from_utf8(&v6_output.stdout)?),
    )
}

fn parse_active_interfaces(ip_addr_output: &str) -> Vec<String> {
    ip_addr_output
        .split('\n')
        .filter(|line| line.contains("state UP"))
        .filter_map(|line| line.split_whitespace().nth(1))
        .map(|iface| String::from(&iface[..iface.len() - 1]))
        .collect()
}

fn route_output_has_default(route_output: &str) -> bool {
    route_output
        .lines()
        .map(str::trim)
        .any(|line| line.starts_with("default "))
}

#[cfg(test)]
mod tests {
    use super::{parse_active_interfaces, route_output_has_default};

    #[test]
    fn parse_active_interfaces_extracts_up_links() {
        let ip_addr_output = "\
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 state UNKNOWN
2: wlp2s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP
3: eno1: <BROADCAST,MULTICAST> mtu 1500 state DOWN
4: tailscale0: <POINTOPOINT,UP,LOWER_UP> mtu 1280 state UNKNOWN";

        let active = parse_active_interfaces(ip_addr_output);

        assert_eq!(active, vec!["wlp2s0"]);
    }

    #[test]
    fn route_output_has_default_detects_default_line() {
        let routes = "default via 192.168.1.1 dev wlp2s0 proto dhcp src 192.168.1.100 metric 600";
        assert!(route_output_has_default(routes));
    }

    #[test]
    fn route_output_has_default_returns_false_when_missing() {
        let routes = "192.168.1.0/24 dev wlp2s0 proto kernel scope link src 192.168.1.100";
        assert!(!route_output_has_default(routes));
    }
}