easywireguard 0.2.0

make wireguard easy - interfaces, keys and full-mesh configs in one CLI + TUI
//! Talking to the system's WireGuard: list `.conf` interfaces across one or more
//! directories, see which are up (`wg show interfaces`), and toggle them
//! (`wg-quick up|down <path>`, by full path so any registered dir works).
//!
//! When `wg` can't be queried (no root, not installed) we degrade gracefully:
//! interfaces just show as down rather than erroring the whole listing.

use anyhow::{Context, Result, bail};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::Command;

pub const DEFAULT_DIR: &str = "/etc/wireguard";

#[derive(Debug, Clone)]
pub struct Iface {
    pub name: String,
    pub up: bool,
    pub path: PathBuf,
}

/// Every `*.conf` across `dirs`, sorted by name, each flagged up/down. When a
/// name exists in more than one dir the earlier dir wins (later ones are
/// shadowed and skipped). Errors only if none of the dirs could be read.
pub fn interfaces(dirs: &[PathBuf]) -> Result<Vec<Iface>> {
    let active = active_interfaces().unwrap_or_default();
    let mut found: BTreeMap<String, Iface> = BTreeMap::new();
    let mut any_readable = false;
    let mut last_err: Option<(PathBuf, std::io::Error)> = None;

    for dir in dirs {
        let entries = match std::fs::read_dir(dir) {
            Ok(e) => {
                any_readable = true;
                e
            }
            Err(e) => {
                last_err = Some((dir.clone(), e));
                continue;
            }
        };
        for entry in entries {
            let path = entry?.path();
            if path.extension().and_then(|e| e.to_str()) != Some("conf") {
                continue;
            }
            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
                found.entry(name.to_string()).or_insert_with(|| Iface {
                    name: name.to_string(),
                    up: active.contains(name),
                    path: path.clone(),
                });
            }
        }
    }

    if !any_readable {
        if let Some((dir, e)) = last_err {
            return Err(anyhow::Error::new(e)).with_context(|| {
                format!(
                    "reading `{}` (config dir needs root: sudo ewg)",
                    dir.display()
                )
            });
        }
        return Ok(Vec::new());
    }
    Ok(found.into_values().collect())
}

/// Full path of `<name>.conf` in the first `dirs` entry that has it.
pub fn find(dirs: &[PathBuf], name: &str) -> Result<PathBuf> {
    for dir in dirs {
        let candidate = dir.join(format!("{name}.conf"));
        if candidate.is_file() {
            return Ok(candidate);
        }
    }
    bail!("no `{name}.conf` in any registered dir - add its dir: ewg dir add <path>");
}

/// Interface names currently up, from `wg show interfaces`.
pub fn active_interfaces() -> Result<BTreeSet<String>> {
    let out = Command::new("wg")
        .args(["show", "interfaces"])
        .output()
        .context("running `wg` (install wireguard-tools)")?;
    if !out.status.success() {
        bail!("`wg show interfaces` failed (need root?)");
    }
    Ok(parse_interfaces(&String::from_utf8_lossy(&out.stdout)))
}

/// `wg show interfaces` prints names separated by whitespace on one line.
pub fn parse_interfaces(s: &str) -> BTreeSet<String> {
    s.split_whitespace().map(str::to_string).collect()
}

pub fn up(config: &Path) -> Result<()> {
    wg_quick("up", config)
}

pub fn down(config: &Path) -> Result<()> {
    wg_quick("down", config)
}

fn wg_quick(action: &str, config: &Path) -> Result<()> {
    // Capture output rather than inherit: wg-quick prints a wall of `[#] ip ...`
    // lines that would otherwise flood and corrupt the TUI's alternate screen.
    let out = Command::new("wg-quick")
        .arg(action)
        .arg(config)
        .output()
        .context("running `wg-quick` (install wireguard-tools)")?;
    if !out.status.success() {
        let stderr = String::from_utf8_lossy(&out.stderr);
        let reason = stderr.lines().last().unwrap_or("").trim();
        bail!("`wg-quick {action} {}` failed: {reason}", config.display());
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_interfaces_splits_on_whitespace() {
        let set = parse_interfaces("wg0 wg1  mesh\n");
        assert_eq!(set.len(), 3);
        assert!(set.contains("wg0") && set.contains("wg1") && set.contains("mesh"));
    }

    #[test]
    fn interfaces_lists_conf_files_across_dirs_sorted() {
        let a = tempfile::tempdir().unwrap();
        let b = tempfile::tempdir().unwrap();
        std::fs::write(a.path().join("wg1.conf"), "").unwrap();
        std::fs::write(a.path().join("notes.txt"), "").unwrap();
        std::fs::write(b.path().join("wg0.conf"), "").unwrap();

        let dirs = vec![a.path().to_path_buf(), b.path().to_path_buf()];
        let names: Vec<_> = interfaces(&dirs)
            .unwrap()
            .into_iter()
            .map(|i| i.name)
            .collect();
        assert_eq!(names, vec!["wg0", "wg1"]); // merged + sorted, .txt ignored
    }

    #[test]
    fn earlier_dir_shadows_a_duplicate_name() {
        let a = tempfile::tempdir().unwrap();
        let b = tempfile::tempdir().unwrap();
        std::fs::write(a.path().join("wg0.conf"), "").unwrap();
        std::fs::write(b.path().join("wg0.conf"), "").unwrap();

        let dirs = vec![a.path().to_path_buf(), b.path().to_path_buf()];
        let ifaces = interfaces(&dirs).unwrap();
        assert_eq!(ifaces.len(), 1);
        assert!(ifaces[0].path.starts_with(a.path()), "first dir wins");
    }

    #[test]
    fn find_locates_a_config_in_the_first_matching_dir() {
        let a = tempfile::tempdir().unwrap();
        std::fs::write(a.path().join("wg0.conf"), "").unwrap();
        let dirs = vec![a.path().to_path_buf()];
        assert_eq!(find(&dirs, "wg0").unwrap(), a.path().join("wg0.conf"));

        let e = find(&dirs, "nope").unwrap_err().to_string();
        assert!(e.contains("ewg dir add"), "got: {e}");
    }

    #[test]
    fn interfaces_all_unreadable_dirs_report_sudo() {
        let e = interfaces(&[PathBuf::from("/nope/x")])
            .unwrap_err()
            .to_string();
        assert!(e.contains("sudo ewg"), "got: {e}");
    }
}