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,
}
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())
}
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>");
}
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)))
}
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<()> {
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"]); }
#[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}");
}
}