zc2 0.0.27

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;

/// This node's WireGuard mesh IP, or `None` when unreachable by any means.
///
/// `None` is a normal state, not an error: a broker with no tunnel is still
/// authorized, just temporarily unreachable, and must register without an
/// endpoint rather than guessing one.
///
/// Delegates wholesale to [`crate::broker::discovery::get_mesh_ip`], which is
/// the detector the rest of this binary already trusts -- `/health`'s
/// `wireguard_connected`, the node-sync tick's discovery gate, and the worker
/// sync all read it. It calls `getifaddrs(3)` via the `ifaces` crate, so it
/// needs no `ip` binary (the broker image ships none), matches
/// `zakuro0`/`wg*`, constrains the result to the `10.13.13.0/24` mesh, and
/// honours the `ZAKURO_MESH_IP` / `ZAKURO_WIREGUARD_IP` overrides.
///
/// This used to try the `ip` binary and then fall back to reading a
/// `/var/run/zakuro/mesh-ip` file published by a WireGuard sidecar. Both are
/// gone: `getifaddrs` sees strictly everything `ip -o addr show zakuro0`
/// reports, from the same kernel source and without a subprocess, so the
/// binary path added nothing; and the file was a second, lagging source of
/// truth that disagreed with `get_mesh_ip()` in exactly the container where
/// it was supposed to help. The manifest still publishes that file -- the
/// broker's readiness probe is a shell with no `ip` binary and still reads
/// it -- it is simply no longer how Rust learns its own address.
pub fn mesh_ip() -> Option<String> {
    crate::broker::discovery::get_mesh_ip()
}

#[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> {
    crate::vpn::host_ops_allowed(&format!("wg-quick {action} {IFACE}"))?;
    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> {
        crate::vpn::host_ops_allowed("native connect")?;
        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> {
        if crate::vpn::host_ops_allowed("wg show").is_err() {
            return Ok(None);
        }
        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 {
    /// Serializes mutation of the mesh-IP env overrides across this module's
    /// tests (`std::env` is process-global), matching the lock pattern used
    /// elsewhere in this crate for the same reason.
    static MESH_IP_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

    struct MeshIpEnvGuard {
        _guard: std::sync::MutexGuard<'static, ()>,
        prev_mesh: Option<String>,
        prev_wg: Option<String>,
    }

    impl MeshIpEnvGuard {
        /// Take the lock, then force the mesh-IP overrides to `value`
        /// (`None` clears both). Holding the lock for the whole test is what
        /// keeps the two tests in this module from observing each other's
        /// override — `std::env` is process-global and the test runner is
        /// threaded, so an unlocked reader here races its sibling writer.
        fn set(value: Option<&str>) -> Self {
            let guard = MESH_IP_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
            let prev_mesh = std::env::var("ZAKURO_MESH_IP").ok();
            let prev_wg = std::env::var("ZAKURO_WIREGUARD_IP").ok();
            std::env::remove_var("ZAKURO_WIREGUARD_IP");
            match value {
                Some(v) => std::env::set_var("ZAKURO_MESH_IP", v),
                None => std::env::remove_var("ZAKURO_MESH_IP"),
            }
            Self {
                _guard: guard,
                prev_mesh,
                prev_wg,
            }
        }
    }

    impl Drop for MeshIpEnvGuard {
        fn drop(&mut self) {
            match &self.prev_mesh {
                Some(v) => std::env::set_var("ZAKURO_MESH_IP", v),
                None => std::env::remove_var("ZAKURO_MESH_IP"),
            }
            match &self.prev_wg {
                Some(v) => std::env::set_var("ZAKURO_WIREGUARD_IP", v),
                None => std::env::remove_var("ZAKURO_WIREGUARD_IP"),
            }
        }
    }

    /// Fix A/E: `vpn::native::mesh_ip()` and `discovery::get_mesh_ip()` are the
    /// same detector, so they can never disagree. Before this fix `mesh_ip()`
    /// shelled out to `ip` and then read a sidecar file, honouring neither
    /// override -- so in-container it reported "down" while `/health`'s
    /// `wireguard_connected`, computed from `get_mesh_ip()`, reported connected.
    #[test]
    fn mesh_ip_agrees_with_discovery_get_mesh_ip_under_env_override() {
        let _env = MeshIpEnvGuard::set(Some("10.13.13.42"));
        assert_eq!(
            super::mesh_ip(),
            crate::broker::discovery::get_mesh_ip(),
            "mesh_ip() must be the same detector as discovery::get_mesh_ip()"
        );
        assert_eq!(super::mesh_ip().as_deref(), Some("10.13.13.42"));

        // The contract `mesh_endpoint_for` depends on: a bare IP, never a
        // CIDR and never a URL, so `format!("{ip}:{port}")` is dialable.
        let ip = super::mesh_ip().expect("override always resolves");
        assert!(!ip.contains('/'), "mesh_ip must strip any prefix length");
        assert!(!ip.contains("://"), "mesh_ip must not be a URL");
    }

    /// The same agreement must hold with NO override in play, which is the
    /// container case: both detectors fall through to `getifaddrs`.
    ///
    /// Takes the same lock as the override test — without it this reads
    /// `ZAKURO_MESH_IP` while its sibling is mid-`set_var` and fails
    /// intermittently (observed ~25% of runs).
    #[test]
    fn mesh_ip_agrees_with_discovery_get_mesh_ip_in_the_ambient_environment() {
        let _env = MeshIpEnvGuard::set(None);
        assert_eq!(super::mesh_ip(), crate::broker::discovery::get_mesh_ip());
    }

    /// zc#211: `wg-quick` re-execs itself through `sudo`, so on a host with
    /// passwordless sudo a unit test that reached it brought `zakuro0` down.
    #[test]
    fn wg_quick_is_refused_in_unit_tests() {
        assert!(matches!(
            super::wg_quick("down"),
            Err(crate::vpn::NetError::Refused(_))
        ));
    }

    /// `connect()` writes `/etc/wireguard/zakuro0.conf` and runs `wg-quick up`;
    /// in a unit test it must refuse before doing either.
    #[test]
    fn native_connect_is_refused_in_unit_tests() {
        use crate::vpn::connector::Connector;
        let profile: crate::vpn::profile::WgProfile = serde_json::from_str(
            r#"{
            "interface": { "private_key": "MKCl4DM7FbX1sFUiFd8VqKzdIGUMwJ28rTgFYezsclc=", "address": "10.13.13.6/24" },
            "peer": { "public_key": "/Lzp+YIUBrNgdABzyR221uhfx2uOWi4m5ZAdgxXzhFs=",
                      "endpoint": "144.202.121.242:51822", "allowed_ips": "10.13.13.0/24",
                      "persistent_keepalive": 25 }
        }"#,
        )
        .expect("sample profile parses");
        assert!(matches!(
            super::NativeConnector.connect(&profile),
            Err(crate::vpn::NetError::Refused(_))
        ));
    }
}