nettui 0.2.0

Unified TUI for Wi-Fi and Ethernet
Documentation
use crate::{
    backend::traits::{CommandResult, EthernetBackend},
    domain::ethernet::EthernetIface,
};
use anyhow::{Context, Result};
use if_addrs::IfAddr;
use std::{fs, net::Ipv4Addr, path::Path};
use tokio::process::Command;

pub struct NetworkdBackend;

impl NetworkdBackend {
    pub fn new() -> Self {
        Self
    }

    pub async fn renew_dhcp(&self, iface: &str) -> Result<CommandResult> {
        // Try plain first.
        if let Ok(out) = Command::new("networkctl")
            .arg("renew")
            .arg(iface)
            .output()
            .await
        {
            if out.status.success() {
                return Ok(CommandResult {
                    program: "networkctl".to_string(),
                    args: vec!["renew".to_string(), iface.to_string()],
                    used_sudo: false,
                    status: out.status.code().unwrap_or(0),
                    stdout: String::from_utf8_lossy(&out.stdout).trim().to_string(),
                    stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),
                });
            }

            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
            if stderr.contains("Operation not permitted")
                || stderr.contains("Permission denied")
                || out.status.code() == Some(1)
            {
                let pkexec_out = Command::new("pkexec")
                    .arg("networkctl")
                    .arg("renew")
                    .arg(iface)
                    .output()
                    .await;

                if let Ok(pkexec_out) = pkexec_out
                    && pkexec_out.status.success()
                {
                    return Ok(CommandResult {
                        program: "networkctl".to_string(),
                        args: vec!["renew".to_string(), iface.to_string()],
                        used_sudo: true,
                        status: pkexec_out.status.code().unwrap_or(0),
                        stdout: String::from_utf8_lossy(&pkexec_out.stdout)
                            .trim()
                            .to_string(),
                        stderr: String::from_utf8_lossy(&pkexec_out.stderr)
                            .trim()
                            .to_string(),
                    });
                }

                let sudo_out = Command::new("sudo")
                    .arg("-n")
                    .arg("networkctl")
                    .arg("renew")
                    .arg(iface)
                    .output()
                    .await?;

                if sudo_out.status.success() {
                    return Ok(CommandResult {
                        program: "networkctl".to_string(),
                        args: vec!["renew".to_string(), iface.to_string()],
                        used_sudo: true,
                        status: sudo_out.status.code().unwrap_or(0),
                        stdout: String::from_utf8_lossy(&sudo_out.stdout).trim().to_string(),
                        stderr: String::from_utf8_lossy(&sudo_out.stderr).trim().to_string(),
                    });
                }

                return Err(std::io::Error::other(
                    String::from_utf8_lossy(&sudo_out.stderr).trim().to_string(),
                )
                .into());
            }

            return Err(std::io::Error::other(if stderr.is_empty() {
                "networkctl renew failed".to_string()
            } else {
                stderr
            })
            .into());
        }

        Err(std::io::Error::other("failed to run networkctl").into())
    }

    pub async fn set_link_admin_state(&self, iface: &str, up: bool) -> Result<CommandResult> {
        let state_arg = if up { "up" } else { "down" };

        if let Ok(out) = Command::new("ip")
            .arg("link")
            .arg("set")
            .arg("dev")
            .arg(iface)
            .arg(state_arg)
            .output()
            .await
        {
            if out.status.success() {
                return Ok(CommandResult {
                    program: "ip".to_string(),
                    args: vec![
                        "link".to_string(),
                        "set".to_string(),
                        "dev".to_string(),
                        iface.to_string(),
                        state_arg.to_string(),
                    ],
                    used_sudo: false,
                    status: out.status.code().unwrap_or(0),
                    stdout: String::from_utf8_lossy(&out.stdout).trim().to_string(),
                    stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),
                });
            }

            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
            if stderr.contains("Operation not permitted")
                || stderr.contains("Permission denied")
                || out.status.code() == Some(1)
            {
                let pkexec_out = Command::new("pkexec")
                    .arg("ip")
                    .arg("link")
                    .arg("set")
                    .arg("dev")
                    .arg(iface)
                    .arg(state_arg)
                    .output()
                    .await;

                if let Ok(pkexec_out) = pkexec_out
                    && pkexec_out.status.success()
                {
                    return Ok(CommandResult {
                        program: "ip".to_string(),
                        args: vec![
                            "link".to_string(),
                            "set".to_string(),
                            "dev".to_string(),
                            iface.to_string(),
                            state_arg.to_string(),
                        ],
                        used_sudo: true,
                        status: pkexec_out.status.code().unwrap_or(0),
                        stdout: String::from_utf8_lossy(&pkexec_out.stdout)
                            .trim()
                            .to_string(),
                        stderr: String::from_utf8_lossy(&pkexec_out.stderr)
                            .trim()
                            .to_string(),
                    });
                }

                let sudo_out = Command::new("sudo")
                    .arg("-n")
                    .arg("ip")
                    .arg("link")
                    .arg("set")
                    .arg("dev")
                    .arg(iface)
                    .arg(state_arg)
                    .output()
                    .await?;

                if sudo_out.status.success() {
                    return Ok(CommandResult {
                        program: "ip".to_string(),
                        args: vec![
                            "link".to_string(),
                            "set".to_string(),
                            "dev".to_string(),
                            iface.to_string(),
                            state_arg.to_string(),
                        ],
                        used_sudo: true,
                        status: sudo_out.status.code().unwrap_or(0),
                        stdout: String::from_utf8_lossy(&sudo_out.stdout).trim().to_string(),
                        stderr: String::from_utf8_lossy(&sudo_out.stderr).trim().to_string(),
                    });
                }

                return Err(std::io::Error::other(
                    String::from_utf8_lossy(&sudo_out.stderr).trim().to_string(),
                )
                .into());
            }

            return Err(std::io::Error::other(if stderr.is_empty() {
                format!("ip link set dev {iface} {state_arg} failed")
            } else {
                stderr
            })
            .into());
        }

        Err(std::io::Error::other("failed to run ip").into())
    }

    pub fn iface_details(&self, iface: &str) -> Result<EthernetIface> {
        let base = Path::new("/sys/class/net").join(iface);
        if !base.exists() {
            return Err(std::io::Error::other(format!("interface not found: {iface}")).into());
        }
        if iface == "lo" {
            return Err(std::io::Error::other("loopback interface is not supported").into());
        }
        if !is_physical_iface(iface) {
            return Err(std::io::Error::other(format!("not a physical interface: {iface}")).into());
        }

        build_iface(iface)
    }
}

impl Default for NetworkdBackend {
    fn default() -> Self {
        Self::new()
    }
}

impl EthernetBackend for NetworkdBackend {
    fn list_ifaces(&self) -> Result<Vec<EthernetIface>> {
        list_ethernet_ifaces()
    }
}

fn is_physical_iface(name: &str) -> bool {
    Path::new("/sys/class/net")
        .join(name)
        .join("device")
        .exists()
}

fn is_wifi_iface(name: &str) -> bool {
    let p = Path::new("/sys/class/net").join(name);
    p.join("wireless").is_dir() || p.join("phy80211").exists()
}

fn read_to_string(path: impl AsRef<Path>) -> Option<String> {
    fs::read_to_string(path).ok().map(|s| s.trim().to_string())
}

fn read_bool(path: impl AsRef<Path>) -> Option<bool> {
    read_to_string(path).and_then(|s| match s.as_str() {
        "0" => Some(false),
        "1" => Some(true),
        _ => None,
    })
}

fn read_u32(path: impl AsRef<Path>) -> Option<u32> {
    read_to_string(path).and_then(|s| s.parse::<u32>().ok())
}

fn list_dns_servers() -> Vec<String> {
    let resolv = fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
    resolv
        .lines()
        .filter_map(|line| {
            let line = line.trim();
            if line.starts_with("nameserver ") {
                line.split_whitespace().nth(1).map(|s| s.to_string())
            } else {
                None
            }
        })
        .collect()
}

fn parse_default_gateway_v4_for_iface(iface: &str) -> Option<Ipv4Addr> {
    let content = fs::read_to_string("/proc/net/route").ok()?;
    for (i, line) in content.lines().enumerate() {
        if i == 0 {
            continue;
        }

        let cols: Vec<&str> = line.split_whitespace().collect();
        if cols.len() < 3 {
            continue;
        }

        let ifname = cols[0];
        let destination = cols[1];
        let gateway_hex = cols[2];

        if ifname != iface || destination != "00000000" {
            continue;
        }

        let gw = u32::from_str_radix(gateway_hex, 16).ok()?;
        let b = gw.to_le_bytes();
        return Some(Ipv4Addr::new(b[0], b[1], b[2], b[3]));
    }

    None
}

fn list_ip_addrs_for_iface(iface: &str) -> Result<(Vec<String>, Vec<String>)> {
    let ifas = if_addrs::get_if_addrs().context("get_if_addrs failed")?;
    let mut v4 = Vec::new();
    let mut v6 = Vec::new();

    for ifa in ifas {
        if ifa.name != iface {
            continue;
        }
        match ifa.addr {
            IfAddr::V4(a) => {
                let prefix = v4_netmask_to_prefix(a.netmask);
                v4.push(format!("{}/{}", a.ip, prefix));
            }
            IfAddr::V6(a) => {
                let prefix = v6_netmask_to_prefix(a.netmask);
                v6.push(format!("{}/{}", a.ip, prefix));
            }
        }
    }

    Ok((v4, v6))
}

fn v4_netmask_to_prefix(mask: Ipv4Addr) -> u8 {
    let bits = u32::from_be_bytes(mask.octets());
    bits.count_ones() as u8
}

fn v6_netmask_to_prefix(mask: std::net::Ipv6Addr) -> u8 {
    mask.octets()
        .into_iter()
        .map(|b| b.count_ones() as u16)
        .sum::<u16>() as u8
}

fn list_ethernet_ifaces() -> Result<Vec<EthernetIface>> {
    let mut devices = Vec::new();

    for entry in fs::read_dir("/sys/class/net").context("read_dir /sys/class/net failed")? {
        let entry = entry?;
        let name = entry.file_name().to_string_lossy().to_string();
        if name == "lo" {
            continue;
        }

        if !is_physical_iface(&name) || is_wifi_iface(&name) {
            continue;
        }

        devices.push(build_iface(&name)?);
    }

    devices.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(devices)
}

fn build_iface(name: &str) -> Result<EthernetIface> {
    let base = Path::new("/sys/class/net").join(name);
    let operstate = read_to_string(base.join("operstate")).unwrap_or_else(|| "?".into());
    let carrier = read_bool(base.join("carrier"));
    let mac = read_to_string(base.join("address"));
    let speed_mbps = read_u32(base.join("speed"));

    let (ipv4, ipv6) = list_ip_addrs_for_iface(name).unwrap_or_default();
    let gateway_v4 = parse_default_gateway_v4_for_iface(name).map(|g| g.to_string());
    let dns = list_dns_servers();

    Ok(EthernetIface {
        name: name.to_string(),
        operstate,
        carrier,
        mac,
        speed_mbps,
        ipv4,
        ipv6,
        gateway_v4,
        dns,
    })
}