#![allow(dead_code)]
pub mod connector;
pub mod docker;
pub mod native;
pub mod profile;
pub mod state;
use connector::{select_connector, Connector, Preference};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
static VERBOSE: AtomicBool = AtomicBool::new(false);
pub fn set_verbose(v: bool) {
VERBOSE.store(v, Ordering::Relaxed);
}
pub fn verbose() -> bool {
VERBOSE.load(Ordering::Relaxed)
}
pub fn vlog(msg: &str) {
if verbose() {
eprintln!(" [vpn] {msg}");
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Backend {
Native,
Docker,
}
impl Backend {
pub fn label(&self) -> &'static str {
match self {
Backend::Native => "native",
Backend::Docker => "docker",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerStatus {
pub ip: String,
pub last_handshake_secs: Option<u64>,
pub reachable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionInfo {
pub backend: Backend,
pub address: String, pub link: String, pub peers: Vec<PeerStatus>,
pub host_routable: bool,
#[serde(default)]
pub proxy: Option<String>, }
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MeshAccess {
Host,
Proxy(String), }
#[derive(Debug)]
pub enum NetError {
NoApiKey,
Fetch(String),
NoBackend,
Backend(String),
Profile(String),
Refused(String),
}
impl std::fmt::Display for NetError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NetError::NoApiKey => write!(f, "p2p requires ZAKURO_API_KEY (set it and retry)"),
NetError::Fetch(e) => write!(f, "failed to fetch WireGuard profile: {}", e),
NetError::NoBackend => {
let hint = if cfg!(target_os = "macos") {
"install WireGuard (`brew install wireguard-tools wireguard-go`) then re-run with `sudo`, or start Docker Desktop"
} else {
"install WireGuard (`sudo apt install wireguard-tools`) and run as root, or install Docker"
};
write!(f, "no usable backend: {hint}")
}
NetError::Backend(e) => write!(f, "tunnel backend error: {}", e),
NetError::Profile(e) => write!(f, "invalid WireGuard profile: {}", e),
NetError::Refused(e) => write!(f, "{}", e),
}
}
}
impl std::error::Error for NetError {}
#[cfg(not(test))]
pub(crate) fn host_ops_allowed(_what: &str) -> Result<(), NetError> {
Ok(())
}
#[cfg(test)]
pub(crate) fn host_ops_allowed(what: &str) -> Result<(), NetError> {
if std::env::var("ZAKURO_TEST_REAL_VPN").as_deref() == Ok("1") {
return Ok(());
}
Err(NetError::Refused(format!(
"{what}: refused in unit tests (set ZAKURO_TEST_REAL_VPN=1 to allow)"
)))
}
fn access_of(info: &ConnectionInfo) -> Result<MeshAccess, NetError> {
match (&info.proxy, info.host_routable) {
(_, true) => Ok(MeshAccess::Host),
(Some(p), false) => Ok(MeshAccess::Proxy(p.clone())),
(None, false) => Err(NetError::Backend(
"tunnel up but host cannot route and no proxy available".into(),
)),
}
}
fn mesh_handshake_ok(info: &ConnectionInfo) -> bool {
use std::time::{Duration, SystemTime, UNIX_EPOCH};
if host_ops_allowed("wg show latest-handshakes").is_err() {
return false;
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
for attempt in 0..5 {
if attempt > 0 {
std::thread::sleep(Duration::from_millis(1200));
}
let out = match info.backend {
Backend::Native => std::process::Command::new("wg")
.args(["show", "zakuro0", "latest-handshakes"])
.output()
.ok(),
Backend::Docker => std::process::Command::new("docker")
.args([
"exec",
&info.link,
"wg",
"show",
"zakuro0",
"latest-handshakes",
])
.output()
.ok(),
};
let text = match out {
Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(),
_ => continue,
};
let fresh = text
.lines()
.filter_map(|l| l.split_whitespace().nth(1))
.filter_map(|s| s.parse::<u64>().ok())
.any(|hs| hs > 0 && now.saturating_sub(hs) < 300);
if fresh {
return true;
}
}
false
}
fn ensure_with(
verify_fn: &dyn Fn(&ConnectionInfo) -> bool,
connect_fn: &dyn Fn() -> Result<ConnectionInfo, NetError>,
saved: Option<&ConnectionInfo>,
) -> Result<MeshAccess, NetError> {
if let Some(s) = saved {
if verify_fn(s) {
return access_of(s);
}
}
let info = connect_fn()?;
if !verify_fn(&info) {
return Err(NetError::Backend(
"mesh probe failed — tunnel is up but no handshake with the mesh server".into(),
));
}
access_of(&info)
}
pub fn ensure(pref: Preference) -> Result<MeshAccess, NetError> {
let saved = status()?; ensure_with(&mesh_handshake_ok, &|| connect(pref), saved.as_ref())
}
pub fn is_mesh_ip(host: &str) -> bool {
let p: Vec<&str> = host.split('.').collect();
p.len() == 4 && p[0] == "10" && p[1] == "13" && p[2] == "13" && p[3].parse::<u8>().is_ok()
}
pub fn mesh_proxy_addr() -> Option<String> {
state::load().and_then(|s| s.proxy)
}
pub fn mesh_proxy() -> Option<ureq::Proxy> {
mesh_proxy_addr().and_then(|p| ureq::Proxy::new(&format!("http://{}", p)).ok())
}
pub fn sync_mesh_peer_key() {
crate::credentials::load_into_env();
let Ok(api_key) = std::env::var("ZAKURO_API_KEY") else {
return;
};
if api_key.trim().is_empty() {
return;
}
let api_url = crate::credentials::default_api_url();
let endpoint = format!("{}/api/broker/config/mesh", api_url.trim_end_matches('/'));
let resp = match ureq::get(&endpoint)
.config()
.timeout_global(Some(std::time::Duration::from_secs(10)))
.http_status_as_error(false)
.build()
.header("X-Broker-Api-Key", &api_key)
.call()
{
Ok(r) => r,
Err(e) => {
vlog(&format!("mesh peer key: request failed ({e})"));
return;
}
};
let status = resp.status().as_u16();
if status != 200 {
vlog(&format!(
"mesh peer key: hub answered HTTP {status}; not stored"
));
return;
}
let Ok(text) = resp.into_body().read_to_string() else {
return;
};
let key = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| {
v.get("peer_key")
.and_then(|k| k.as_str())
.map(str::to_string)
})
.filter(|k| !k.trim().is_empty());
match key {
Some(k) => match crate::credentials::save_mesh_peer_key(&k) {
Ok(()) => vlog("mesh peer key stored"),
Err(e) => eprintln!(" ⚠ could not store the mesh peer key: {e}"),
},
None => vlog("mesh peer key: hub response carried no peer_key"),
}
}
pub fn mesh_agent(timeout: std::time::Duration) -> ureq::Agent {
let mut cfg = ureq::Agent::config_builder()
.timeout_connect(Some(timeout))
.timeout_global(Some(timeout));
if let Some(saved) = state::load() {
if let Some(p) = saved.proxy {
if let Ok(proxy) = ureq::Proxy::new(&format!("http://{}", p)) {
cfg = cfg.proxy(Some(proxy));
}
}
}
ureq::Agent::new_with_config(cfg.build())
}
pub fn connect(pref: Preference) -> Result<ConnectionInfo, NetError> {
if let Some(existing) = status()? {
sync_mesh_peer_key();
return Ok(existing);
}
let profile = profile::fetch_wg_profile()?;
let connector = select_connector(pref)?;
let info = connector.connect(&profile)?;
sync_mesh_peer_key();
Ok(info)
}
pub fn status() -> Result<Option<ConnectionInfo>, NetError> {
if let Some(saved) = state::load() {
let c = select_for_backend(saved.backend);
if let Ok(Some(info)) = c.status() {
return Ok(Some(info));
}
return Ok(None);
}
if let Ok(Some(info)) = docker::DockerConnector.status() {
return Ok(Some(info));
}
if let Ok(Some(info)) = native::NativeConnector.status() {
return Ok(Some(info));
}
Ok(None)
}
pub fn conf() -> Result<String, NetError> {
profile::fetch_wg_profile()?.to_conf()
}
pub fn disconnect() -> Result<(), NetError> {
if let Some(saved) = state::load() {
select_for_backend(saved.backend).disconnect()?;
} else {
let _ = docker::DockerConnector.disconnect();
let _ = native::NativeConnector.disconnect();
}
Ok(())
}
fn select_for_backend(b: Backend) -> Box<dyn connector::Connector> {
match b {
Backend::Native => Box::new(native::NativeConnector),
Backend::Docker => Box::new(docker::DockerConnector),
}
}
fn parse_pref(args: &[String]) -> Preference {
if args.iter().any(|a| a == "--native") {
Preference::Native
} else if args.iter().any(|a| a == "--docker") {
Preference::Docker
} else {
Preference::Auto
}
}
fn render(info: &ConnectionInfo) -> String {
let reachable = info.peers.iter().filter(|p| p.reachable).count();
let access = match &info.proxy {
Some(p) if !info.host_routable => format!("proxy {}", p),
_ => "host".to_string(),
};
format!(
"connected to zakuro mesh — {} · {} · {} peer(s), {} reachable · access: {}",
info.address,
info.backend.label(),
info.peers.len(),
reachable,
access,
)
}
pub fn run_cli(args: &[String]) {
use colored::Colorize;
set_verbose(args.iter().any(|a| a == "--verbose" || a == "-v"));
let sub = args.first().map(|s| s.as_str()).unwrap_or("status");
match sub {
"connect" | "up" => match connect(parse_pref(args)) {
Ok(info) => println!(" {} {}", "✓".green(), render(&info)),
Err(e) => {
eprintln!(" {} {}", "✗".red(), e);
std::process::exit(1);
}
},
"ensure" => match ensure(parse_pref(args)) {
Ok(MeshAccess::Host) => {
println!(" {} mesh verified via host tunnel", "✓".green())
}
Ok(MeshAccess::Proxy(p)) => {
println!(" {} mesh verified via container proxy {}", "✓".green(), p)
}
Err(e) => {
eprintln!(" {} {}", "✗".red(), e);
std::process::exit(1);
}
},
"disconnect" | "down" => match disconnect() {
Ok(()) => println!(" {} disconnected from zakuro mesh", "✓".green()),
Err(e) => {
eprintln!(" {} {}", "✗".red(), e);
std::process::exit(1);
}
},
"status" => match status() {
Ok(Some(info)) => println!(" {}", render(&info)),
Ok(None) => println!(" not connected (local mode)"),
Err(e) => {
eprintln!(" {} {}", "✗".red(), e);
std::process::exit(1);
}
},
"conf" => match conf() {
Ok(text) => print!("{}", text),
Err(e) => {
eprintln!(" {} {}", "✗".red(), e);
std::process::exit(1);
}
},
other => {
eprintln!(
"usage: zc vpn [connect [--native|--docker] | ensure | disconnect | status | conf] (got '{}')",
other
);
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vpn::connector::Preference;
#[test]
fn parse_pref_reads_flags() {
assert_eq!(parse_pref(&["connect".into()]), Preference::Auto);
assert_eq!(
parse_pref(&["connect".into(), "--native".into()]),
Preference::Native
);
assert_eq!(
parse_pref(&["connect".into(), "--docker".into()]),
Preference::Docker
);
}
const SAMPLE_PROFILE: &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 }
}"#;
#[test]
fn connection_info_proxy_roundtrip_and_default() {
let legacy = r#"{"backend":"Docker","address":"10.13.13.6/24","link":"zakuro-wg",
"peers":[],"host_routable":false}"#;
let info: ConnectionInfo = serde_json::from_str(legacy).unwrap();
assert!(info.proxy.is_none());
let with = ConnectionInfo {
proxy: Some("127.0.0.1:18888".into()),
..info
};
let back: ConnectionInfo =
serde_json::from_str(&serde_json::to_string(&with).unwrap()).unwrap();
assert_eq!(back.proxy.as_deref(), Some("127.0.0.1:18888"));
}
#[test]
fn ensure_ladder_reuse_host_reuse_proxy_then_connect() {
use std::cell::Cell;
let native_saved = ConnectionInfo {
backend: Backend::Native,
address: "10.13.13.6/24".into(),
link: "zakuro0".into(),
peers: vec![],
host_routable: true,
proxy: None,
};
let r = ensure_with(&|_| true, &|| Err(NetError::NoBackend), Some(&native_saved)).unwrap();
assert!(matches!(r, MeshAccess::Host));
let saved = ConnectionInfo {
backend: Backend::Docker,
address: "10.13.13.6/24".into(),
link: "zakuro-wg".into(),
peers: vec![],
host_routable: false,
proxy: Some("127.0.0.1:18888".into()),
};
let r = ensure_with(&|_| true, &|| Err(NetError::NoBackend), Some(&saved)).unwrap();
assert!(matches!(r, MeshAccess::Proxy(ref p) if p == "127.0.0.1:18888"));
let called = Cell::new(false);
let err = ensure_with(
&|_| false,
&|| {
called.set(true);
Ok(saved.clone())
},
Some(&saved),
)
.unwrap_err();
assert!(called.get());
assert!(format!("{err}").contains("mesh probe failed"));
let r = ensure_with(&|_| true, &|| Ok(saved.clone()), None).unwrap();
assert!(matches!(r, MeshAccess::Proxy(_)));
}
#[test]
fn mesh_ip_detection() {
assert!(is_mesh_ip("10.13.13.4"));
assert!(is_mesh_ip("10.13.13.254"));
assert!(!is_mesh_ip("100.82.173.52")); assert!(!is_mesh_ip("192.168.0.23"));
assert!(!is_mesh_ip("10.13.14.4"));
assert!(!is_mesh_ip("localhost"));
}
#[test]
fn mesh_agent_uses_connect_proxy_when_state_has_one() {
use std::io::{Read, Write};
use std::net::TcpListener;
let peer = TcpListener::bind("127.0.0.1:0").unwrap();
let peer_port = peer.local_addr().unwrap().port();
std::thread::spawn(move || {
for s in peer.incoming().flatten() {
let mut s = s;
let mut b = [0u8; 1024];
let _ = s.read(&mut b);
let _ = s.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
);
}
});
let proxy = TcpListener::bind("127.0.0.1:0").unwrap();
let proxy_addr = proxy.local_addr().unwrap();
std::thread::spawn(move || {
for c in proxy.incoming().flatten() {
let mut c = c;
let mut req = Vec::new();
let mut b = [0u8; 256];
loop {
match c.read(&mut b) {
Ok(0) | Err(_) => break,
Ok(n) => {
req.extend_from_slice(&b[..n]);
if req.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
}
}
if !String::from_utf8_lossy(&req).starts_with("CONNECT 10.13.13.9:9000") {
continue;
}
c.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n")
.unwrap();
let mut up = std::net::TcpStream::connect(("127.0.0.1", peer_port)).unwrap();
let mut c2 = c.try_clone().unwrap();
let mut up2 = up.try_clone().unwrap();
std::thread::spawn(move || {
let _ = std::io::copy(&mut c2, &mut up);
});
let _ = std::io::copy(&mut up2, &mut c);
}
});
let _env = crate::credentials::HOME_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let prev_state_dir = std::env::var_os("ZAKURO_STATE_DIR");
let dir = std::env::temp_dir().join(format!("zc-vpn-agent-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("ZAKURO_STATE_DIR", &dir);
state::save(&ConnectionInfo {
backend: Backend::Docker,
address: "10.13.13.6/24".into(),
link: "zakuro-wg".into(),
peers: vec![],
host_routable: false,
proxy: Some(proxy_addr.to_string()),
})
.unwrap();
let agent = mesh_agent(std::time::Duration::from_secs(3));
let body = agent
.get("http://10.13.13.9:9000/health")
.call()
.unwrap()
.into_body()
.read_to_string()
.unwrap();
assert_eq!(body, "ok");
match prev_state_dir {
Some(v) => std::env::set_var("ZAKURO_STATE_DIR", v),
None => std::env::remove_var("ZAKURO_STATE_DIR"),
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn conf_renders_wg_quick_from_profile_file() {
let dir = std::env::temp_dir().join(format!("zc-vpn-conf-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("wg.json");
std::fs::write(&path, SAMPLE_PROFILE).unwrap();
std::env::set_var("ZAKURO_ALLOW_FILE_PROFILE", "1");
std::env::set_var("ZAKURO_WG_PROFILE_FILE", &path);
let conf = super::conf().expect("conf renders");
assert!(conf.contains("[Interface]"));
assert!(conf.contains("Address = 10.13.13.6/24"));
assert!(conf.contains("[Peer]"));
assert!(conf.contains("Endpoint = 144.202.121.242:51822"));
std::env::remove_var("ZAKURO_ALLOW_FILE_PROFILE");
std::env::remove_var("ZAKURO_WG_PROFILE_FILE");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn host_ops_are_refused_in_unit_tests() {
let err = host_ops_allowed("wg-quick down").unwrap_err();
assert!(matches!(err, NetError::Refused(_)));
assert!(err.to_string().contains("ZAKURO_TEST_REAL_VPN"), "{err}");
}
}