zc2 0.0.23

P2P compute broker with credit-based billing, WAL, and broker mesh support
//! Docker sidecar backend: holds the WireGuard tunnel in an alpine +
//! wireguard-go container (`zakuro-wg`). Container workloads share its netns
//! via `--network container:zakuro-wg`. Host-level routing into the mesh is a
//! planned follow-up; for now `host_routable` is reported as false.

use crate::vpn::connector::Connector;
use crate::vpn::profile::WgProfile;
use crate::vpn::{Backend, ConnectionInfo, NetError, PeerStatus};
use std::process::Command;

const SIDECAR: &str = "zakuro-wg";
const IFACE: &str = "zakuro0";

#[derive(Default)]
pub struct DockerConnector;

/// First bindable port in 18888..18899 (loopback only).
pub(crate) fn pick_proxy_port() -> Option<u16> {
    (18888..18899).find(|p| std::net::TcpListener::bind(("127.0.0.1", *p)).is_ok())
}

/// The broker's HTTP port, published on the sidecar so a netns-attached broker
/// is reachable from the host.
pub(crate) const BROKER_PORT: u16 = 9000;

/// Can the host still bind the broker port?
///
/// A container that joins this sidecar's netns (`--network container:zakuro-wg`)
/// cannot publish ports of its own -- port mapping belongs to the netns owner,
/// which is the sidecar. So the sidecar has to publish 9000 up front, before any
/// broker exists, or the host `zc` CLI (`zc workers`, `zc bench`) has no way to
/// reach a broker running inside the mesh.
///
/// Publishing is best-effort: if something already holds 9000 the tunnel itself
/// is still perfectly good, so we skip the mapping rather than fail the connect.
/// The mesh-side broker keeps working either way; only host-side CLI access is
/// lost, and `zc vpn status` reports the port so that is diagnosable.
pub(crate) fn broker_port_free() -> bool {
    std::net::TcpListener::bind(("127.0.0.1", BROKER_PORT)).is_ok()
}

/// Parse `wg show <iface> latest-handshakes` output into unix-secs values.
/// Each line is "<pubkey>\t<unix_secs>"; 0 means "no handshake yet".
fn parse_handshakes(out: &str) -> Vec<u64> {
    out.lines()
        .filter_map(|l| l.split_whitespace().nth(1))
        .filter_map(|s| s.parse::<u64>().ok())
        .collect()
}

fn docker(args: &[&str]) -> Result<String, NetError> {
    crate::vpn::vlog(&format!("docker {}", args.join(" ")));
    let out = Command::new("docker")
        .args(args)
        .output()
        .map_err(|e| NetError::Backend(format!("docker: {}", e)))?;
    if out.status.success() {
        Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
    } else {
        Err(NetError::Backend(
            String::from_utf8_lossy(&out.stderr).trim().to_string(),
        ))
    }
}

impl DockerConnector {
    fn exec_capture(&self, args: &[&str]) -> Result<String, NetError> {
        let mut full = vec!["exec", SIDECAR];
        full.extend_from_slice(args);
        docker(&full)
    }

    fn is_up(&self) -> bool {
        docker(&[
            "ps",
            "--filter",
            &format!("name=^{}$", SIDECAR),
            "--format",
            "{{.Names}}",
        ])
        .map(|s| s.lines().any(|l| l == SIDECAR))
        .unwrap_or(false)
    }

    /// Read peers from `wg show <iface>` inside the sidecar. Best-effort.
    fn read_peers(&self) -> Vec<PeerStatus> {
        let allowed = self
            .exec_capture(&["wg", "show", IFACE, "allowed-ips"])
            .unwrap_or_default();
        let hs = self
            .exec_capture(&["wg", "show", IFACE, "latest-handshakes"])
            .map(|o| parse_handshakes(&o))
            .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()
    }
}

impl Connector for DockerConnector {
    fn available(&self) -> bool {
        Command::new("docker")
            .arg("info")
            .output()
            .map(|o| o.status.success())
            .unwrap_or(false)
    }

    fn connect(&self, profile: &WgProfile) -> Result<ConnectionInfo, NetError> {
        use base64::Engine;
        let conf_text = profile
            .to_conf()
            .map_err(|e| NetError::Backend(format!("invalid profile: {}", e)))?;
        // Embed the WireGuard conf as base64 in the container's start script rather
        // than bind-mounting it. On Docker Desktop for macOS the host temp dir
        // (/var/folders/…) is NOT a shared path, so `-v <tmp>.conf:/etc/wireguard/…`
        // silently materializes an empty DIRECTORY inside the container — wg-quick
        // then reads a directory ("read error: Is a directory") and never sets the
        // Address. Writing the conf from base64 inside the container avoids the
        // whole file-sharing dependency and works on macOS + Linux.
        let conf_b64 = base64::engine::general_purpose::STANDARD.encode(conf_text.as_bytes());

        let proxy_port = pick_proxy_port()
            .ok_or_else(|| NetError::Backend("no free local port in 18888..18899".into()))?;
        let publish = format!("127.0.0.1:{}:8888", proxy_port);

        let _ = docker(&["rm", "-f", SIDECAR]);
        // tinyproxy gives the host an HTTP CONNECT path into the mesh: zc sends
        // requests for 10.13.13.0/24 through 127.0.0.1:<proxy_port>.
        let run_cmd = format!(
            "apk add -q wireguard-tools wireguard-go tinyproxy 2>/dev/null; \
             mkdir -p /etc/wireguard; \
             echo '{b64}' | base64 -d > /etc/wireguard/{iface}.conf; \
             chmod 600 /etc/wireguard/{iface}.conf; \
             export WG_QUICK_USERSPACE_IMPLEMENTATION=wireguard-go; \
             wg-quick up {iface} >/tmp/wg.log 2>&1; \
             printf 'Port 8888\\nListen 0.0.0.0\\nTimeout 60\\nAllow 172.16.0.0/12\\nAllow 127.0.0.1\\n' > /etc/tinyproxy/tinyproxy.conf; \
             tinyproxy -d >/tmp/tinyproxy.log 2>&1 & \
             exec sleep infinity",
            b64 = conf_b64,
            iface = IFACE
        );
        // Publish the broker port too, so a container attached to this netns
        // (`--network container:zakuro-wg`) is reachable from the host. A
        // netns-joining container cannot publish its own ports -- the mapping
        // belongs to the netns owner -- so it has to be declared here, before
        // any broker exists.
        let publish_broker = format!("127.0.0.1:{p}:{p}", p = BROKER_PORT);
        let mut args: Vec<&str> = vec![
            "run",
            "-d",
            "--name",
            SIDECAR,
            "--cap-add",
            "NET_ADMIN",
            "--device",
            "/dev/net/tun",
            "-p",
            &publish,
        ];
        let broker_published = broker_port_free();
        if broker_published {
            args.extend_from_slice(&["-p", &publish_broker]);
        } else {
            // Not fatal: the tunnel is the point of this command, and a mesh
            // broker still works without host-side port access.
            crate::vpn::vlog(&format!(
                "port {} busy on the host; skipping the broker port mapping",
                BROKER_PORT
            ));
        }
        args.extend_from_slice(&["alpine", "sh", "-c", &run_cmd]);
        docker(&args)?;

        crate::vpn::vlog(&format!(
            "sidecar {} started; waiting for {} to get a mesh address…",
            SIDECAR, IFACE
        ));
        // Wait for the interface address (up to ~16s).
        let mut address = String::new();
        for _ in 0..8 {
            if let Ok(a) = self.exec_capture(&["ip", "-4", "addr", "show", IFACE]) {
                if let Some(ip) = a.split_whitespace().skip_while(|t| *t != "inet").nth(1) {
                    address = ip.to_string();
                    break;
                }
            }
            std::thread::sleep(std::time::Duration::from_secs(2));
        }
        if address.is_empty() {
            // Surface WHY: the container's wg-quick log (apk failure, no
            // /dev/net/tun, handshake never established, …) and recent docker logs.
            let wglog = self
                .exec_capture(&["cat", "/tmp/wg.log"])
                .unwrap_or_default();
            let dlog = docker(&["logs", "--tail", "30", SIDECAR]).unwrap_or_default();
            if crate::vpn::verbose() {
                for l in wglog.lines() {
                    crate::vpn::vlog(&format!("wg.log: {}", l));
                }
                for l in dlog.lines() {
                    crate::vpn::vlog(&format!("docker logs: {}", l));
                }
            }
            let _ = docker(&["rm", "-f", SIDECAR]);
            let detail = wglog.lines().last().unwrap_or("").trim();
            let msg = if detail.is_empty() {
                "tunnel did not come up (run `zc vpn connect --docker --verbose` for container logs)"
                    .to_string()
            } else {
                format!("tunnel did not come up — wg-quick: {}", detail)
            };
            return Err(NetError::Backend(msg));
        }

        let info = ConnectionInfo {
            backend: Backend::Docker,
            address,
            link: SIDECAR.to_string(),
            peers: self.read_peers(),
            host_routable: false,
            proxy: Some(format!("127.0.0.1:{}", proxy_port)),
        };
        // Non-fatal: the tunnel is already up. A root-owned state dir (from a
        // prior `sudo zc`) must not fail an otherwise-successful docker connect.
        crate::vpn::state::save_or_warn(&info);
        // Conf is now baked into the running container's mount view; the temp
        // file on disk is no longer needed. Best-effort cleanup.
        Ok(info)
    }

    fn status(&self) -> Result<Option<ConnectionInfo>, NetError> {
        if !self.is_up() {
            return Ok(None);
        }
        Ok(crate::vpn::state::load())
    }

    fn disconnect(&self) -> Result<(), NetError> {
        let _ = docker(&["rm", "-f", SIDECAR]);
        let _ = crate::vpn::state::clear();
        Ok(())
    }
}

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

    #[test]
    fn picks_a_free_proxy_port_in_range() {
        let p = pick_proxy_port().expect("some port free in 18888..18899");
        assert!((18888..18899).contains(&p));
        // it really is bindable
        std::net::TcpListener::bind(("127.0.0.1", p)).unwrap();
    }

    /// The sidecar must publish the broker port itself: a container joining its
    /// netns cannot publish ports, so if this is not declared at connect time
    /// there is no way to add it later without recreating the tunnel.
    #[test]
    fn broker_port_is_the_documented_one() {
        assert_eq!(BROKER_PORT, 9000);
    }

    #[test]
    fn broker_port_free_reports_false_when_taken() {
        // Hold the port, then assert we notice -- this is what makes publishing
        // best-effort instead of failing an otherwise-good `vpn connect`.
        match std::net::TcpListener::bind(("127.0.0.1", BROKER_PORT)) {
            Ok(held) => {
                assert!(!broker_port_free(), "should report busy while bound");
                drop(held);
                assert!(broker_port_free(), "should report free once released");
            }
            // A real broker is already running on this machine; the "busy"
            // half is then exactly what we want to observe.
            Err(_) => assert!(!broker_port_free()),
        }
    }

    #[test]
    fn parses_latest_handshakes() {
        let out = "ABCKEY=\t1780820000\nDEFKEY=\t0\n";
        let hs = parse_handshakes(out);
        assert_eq!(hs, vec![1780820000, 0]);
    }
}