zc2 0.0.23

P2P compute broker with credit-based billing, WAL, and broker mesh support
//! Native backend: brings the tunnel up directly on the host via wg-quick
//! (kernel WireGuard on Linux, wireguard-go on macOS). Requires root.

use crate::vpn::connector::Connector;
use crate::vpn::profile::WgProfile;
use crate::vpn::{Backend, ConnectionInfo, NetError, PeerStatus};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::process::Command;

const IFACE: &str = "zakuro0";

#[derive(Default)]
pub struct NativeConnector;

/// Extract the first IPv4 address from `ip -4 addr show <iface>` output.
fn inet_addr_from_ip_show(out: &str) -> Option<String> {
    out.split_whitespace()
        .skip_while(|t| *t != "inet")
        .nth(1)
        .map(|s| s.to_string())
}

#[cfg(unix)]
fn is_root() -> bool {
    // SAFETY: geteuid() is always safe; just reads the effective uid.
    unsafe { libc::geteuid() == 0 }
}

// The native backend is wg-quick/`ip`-based and Unix-only; on other hosts the
// "requires root" guard simply always fails so callers surface their normal
// unsupported-platform error instead of a compile break (libc::geteuid does
// not exist on Windows).
#[cfg(not(unix))]
fn is_root() -> bool {
    false
}

/// PATH augmented with the common Homebrew + sbin locations. `sudo` resets PATH
/// to a restricted `secure_path` that excludes `/opt/homebrew/bin` (Apple Silicon)
/// and `/usr/local/bin`, so without this a `sudo zc vpn connect` fails to find a
/// `brew install`-ed `wg-quick` / `wireguard-go` even though it is installed.
fn augmented_path() -> String {
    let base = std::env::var("PATH").unwrap_or_default();
    format!("{base}:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin")
}

/// Resolve a binary's absolute path via the augmented PATH. Returns `None` if not
/// found. Used both to detect availability and to exec by absolute path (so a
/// sudo'd run doesn't depend on Rust's program-lookup honoring a modified PATH).
fn find(bin: &str) -> Option<String> {
    let out = Command::new("sh")
        .arg("-c")
        .arg(format!("command -v {}", bin))
        .env("PATH", augmented_path())
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let p = String::from_utf8_lossy(&out.stdout).trim().to_string();
    if p.is_empty() {
        None
    } else {
        Some(p)
    }
}

fn have(bin: &str) -> bool {
    find(bin).is_some()
}

fn conf_path() -> std::path::PathBuf {
    // wg-quick uses the filename as the interface name.
    std::path::PathBuf::from("/etc/wireguard").join(format!("{}.conf", IFACE))
}

fn wg_quick(action: &str) -> Result<String, NetError> {
    let wg_quick_bin = find("wg-quick").unwrap_or_else(|| "wg-quick".to_string());
    let mut cmd = Command::new(&wg_quick_bin);
    cmd.args([action, IFACE]);
    // Give wg-quick (and its own lookup of `wireguard-go`) the Homebrew paths, so
    // it works under sudo's restricted PATH.
    cmd.env("PATH", augmented_path());
    // macOS has no kernel WireGuard — force the userspace implementation for
    // the wg-quick subprocess only (avoids mutating the process environment).
    if cfg!(target_os = "macos") {
        cmd.env("WG_QUICK_USERSPACE_IMPLEMENTATION", "wireguard-go");
    }
    crate::vpn::vlog(&format!("run: {} {} {}", wg_quick_bin, action, IFACE));
    let out = cmd
        .output()
        .map_err(|e| NetError::Backend(format!("wg-quick: {}", e)))?;
    let stdout = String::from_utf8_lossy(&out.stdout);
    let stderr = String::from_utf8_lossy(&out.stderr);
    if crate::vpn::verbose() {
        for l in stdout.lines().chain(stderr.lines()) {
            crate::vpn::vlog(&format!("wg-quick {}: {}", action, l));
        }
    }
    if out.status.success() {
        Ok(stdout.to_string())
    } else {
        Err(NetError::Backend(stderr.trim().to_string()))
    }
}

impl Connector for NativeConnector {
    fn available(&self) -> bool {
        is_root() && (have("wg-quick") || have("wireguard-go"))
    }

    fn connect(&self, profile: &WgProfile) -> Result<ConnectionInfo, NetError> {
        std::fs::create_dir_all("/etc/wireguard")
            .map_err(|e| NetError::Backend(format!("mkdir /etc/wireguard: {}", e)))?;
        let conf_text = profile
            .to_conf()
            .map_err(|e| NetError::Backend(format!("invalid profile: {}", e)))?;
        std::fs::write(conf_path(), conf_text)
            .map_err(|e| NetError::Backend(format!("writing conf: {}", e)))?;
        #[cfg(unix)]
        std::fs::set_permissions(conf_path(), std::fs::Permissions::from_mode(0o600))
            .map_err(|e| NetError::Backend(format!("chmod conf: {}", e)))?;

        let _ = wg_quick("down"); // idempotent: clear any stale link
        wg_quick("up")?;

        // Verify the tunnel is actually up via `wg show IFACE`. This is
        // cross-platform: on macOS wg-quick maps IFACE to a `utunN` device (there
        // is no `ip` command and no `zakuro0` interface), but `wg show <name>`
        // resolves the config name on both macOS and Linux. The address is taken
        // from the profile rather than parsing Linux-only `ip -4 addr`.
        let wg_bin = find("wg").unwrap_or_else(|| "wg".to_string());
        crate::vpn::vlog(&format!("verifying tunnel via {} show {}", wg_bin, IFACE));
        let wg_out = Command::new(&wg_bin)
            .args(["show", IFACE])
            .env("PATH", augmented_path())
            .output();
        let up = match &wg_out {
            Ok(o) => {
                if crate::vpn::verbose() {
                    for l in String::from_utf8_lossy(&o.stdout).lines() {
                        crate::vpn::vlog(&format!("wg show: {}", l));
                    }
                    for l in String::from_utf8_lossy(&o.stderr).lines() {
                        crate::vpn::vlog(&format!("wg show (err): {}", l));
                    }
                }
                o.status.success()
            }
            Err(e) => {
                crate::vpn::vlog(&format!(
                    "wg show failed to run: {} (is `wg` installed?)",
                    e
                ));
                false
            }
        };
        if !up {
            return Err(NetError::Backend(
                "tunnel did not come up (run `zc vpn connect --verbose` for details)".into(),
            ));
        }
        let address = profile.interface.address.clone();
        crate::vpn::vlog(&format!("tunnel up: {} address {}", IFACE, address));

        let info = ConnectionInfo {
            backend: Backend::Native,
            address,
            link: IFACE.to_string(),
            peers: read_peers(),
            host_routable: true,
            proxy: None,
        };
        // Non-fatal: the tunnel is already up (native mode runs under sudo, and
        // save() re-owns the state dir back to the invoking user).
        crate::vpn::state::save_or_warn(&info);
        Ok(info)
    }

    fn status(&self) -> Result<Option<ConnectionInfo>, NetError> {
        let up = Command::new("wg")
            .args(["show", IFACE])
            .output()
            .map(|o| o.status.success())
            .unwrap_or(false);
        if up {
            Ok(crate::vpn::state::load())
        } else {
            Ok(None)
        }
    }

    fn disconnect(&self) -> Result<(), NetError> {
        let _ = wg_quick("down");
        let _ = crate::vpn::state::clear();
        Ok(())
    }
}

/// Read peers from `wg show zakuro0` on the host. Best-effort.
fn read_peers() -> Vec<PeerStatus> {
    let allowed = Command::new(find("wg").unwrap_or_else(|| "wg".to_string()))
        .env("PATH", augmented_path())
        .args(["show", IFACE, "allowed-ips"])
        .output()
        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
        .unwrap_or_default();
    let hs = Command::new(find("wg").unwrap_or_else(|| "wg".to_string()))
        .env("PATH", augmented_path())
        .args(["show", IFACE, "latest-handshakes"])
        .output()
        .map(|o| {
            String::from_utf8_lossy(&o.stdout)
                .lines()
                .filter_map(|l| l.split_whitespace().nth(1))
                .filter_map(|s| s.parse::<u64>().ok())
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    allowed
        .lines()
        .enumerate()
        .filter_map(|(i, line)| {
            let cidr = line.split_whitespace().nth(1)?;
            let ip = cidr.split('/').next()?.to_string();
            let secs = hs.get(i).copied();
            Some(PeerStatus {
                ip,
                last_handshake_secs: secs.filter(|s| *s > 0),
                reachable: secs.map(|s| s > 0).unwrap_or(false),
            })
        })
        .collect()
}

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

    #[test]
    fn extracts_inet_address() {
        let out = "5: zakuro0: <POINTOPOINT,NOARP,UP> mtu 1420 ...\n    inet 10.13.13.6/24 scope global zakuro0\n";
        assert_eq!(
            inet_addr_from_ip_show(out).as_deref(),
            Some("10.13.13.6/24")
        );
    }
}