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";
const LABEL_MODE: &str = "zakuro.mode";
const LABEL_ADDRESS: &str = "zakuro.address";
const LABEL_PROXY: &str = "zakuro.proxy";
#[derive(Default)]
pub struct DockerConnector;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Mode {
HostNet,
Proxy,
}
impl Mode {
fn label(self) -> &'static str {
match self {
Mode::HostNet => "hostnet",
Mode::Proxy => "proxy",
}
}
fn parse(s: &str) -> Option<Mode> {
match s.trim() {
"hostnet" => Some(Mode::HostNet),
"proxy" => Some(Mode::Proxy),
_ => None,
}
}
}
pub(crate) fn choose_mode(env_override: Option<&str>, kernel_wg: bool, linux: bool) -> Mode {
match env_override.map(str::trim) {
Some("proxy") => Mode::Proxy,
Some("hostnet") => Mode::HostNet,
_ => {
if linux && kernel_wg {
Mode::HostNet
} else {
Mode::Proxy
}
}
}
}
fn kernel_wireguard_present() -> bool {
std::path::Path::new("/sys/module/wireguard").exists()
}
fn preferred_mode() -> Mode {
choose_mode(
std::env::var("ZAKURO_WG_DOCKER_MODE").ok().as_deref(),
kernel_wireguard_present(),
cfg!(target_os = "linux"),
)
}
pub(crate) fn pick_proxy_port() -> Option<u16> {
(18888..18899).find(|p| std::net::TcpListener::bind(("127.0.0.1", *p)).is_ok())
}
pub(crate) const BROKER_PORT: u16 = 9000;
pub(crate) fn broker_port_free() -> bool {
std::net::TcpListener::bind(("127.0.0.1", BROKER_PORT)).is_ok()
}
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::host_ops_allowed(&format!("docker {}", args.first().copied().unwrap_or("")))?;
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(),
))
}
}
fn host_iface_ip() -> Option<String> {
let all = ifaces::Interface::get_all().ok()?;
all.into_iter()
.filter(|i| i.name == IFACE)
.filter_map(|i| i.addr)
.map(|a| a.ip().to_string())
.find(|ip| crate::vpn::is_mesh_ip(ip))
}
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)
}
fn labels(&self) -> Option<(Mode, String, Option<String>)> {
let out = docker(&[
"inspect",
"-f",
&format!(
"{{{{index .Config.Labels \"{LABEL_MODE}\"}}}}|{{{{index .Config.Labels \"{LABEL_ADDRESS}\"}}}}|{{{{index .Config.Labels \"{LABEL_PROXY}\"}}}}"
),
SIDECAR,
])
.ok()?;
let mut parts = out.splitn(3, '|');
let mode = Mode::parse(parts.next()?)?;
let address = parts.next()?.trim().to_string();
if address.is_empty() {
return None;
}
let proxy = parts
.next()
.map(str::trim)
.filter(|p| !p.is_empty())
.map(str::to_string);
Some((mode, address, proxy))
}
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()
}
fn fail_bringup(&self, mode: Mode) -> NetError {
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() {
format!(
"tunnel did not come up ({} mode; run `zc vpn connect --docker --verbose` for container logs)",
mode.label()
)
} else {
format!(
"tunnel did not come up — wg-quick ({}): {}",
mode.label(),
detail
)
};
NetError::Backend(msg)
}
fn connect_hostnet(
&self,
profile: &WgProfile,
conf_b64: &str,
) -> Result<ConnectionInfo, NetError> {
let address = profile.interface.address.clone();
let run_cmd = format!(
"apk add -q wireguard-tools iproute2 2>/dev/null; \
mkdir -p /etc/wireguard; \
echo '{b64}' | base64 -d > /etc/wireguard/{iface}.conf; \
chmod 600 /etc/wireguard/{iface}.conf; \
wg-quick down {iface} >/dev/null 2>&1; \
ip link delete {iface} >/dev/null 2>&1; \
wg-quick up {iface} >/tmp/wg.log 2>&1; \
exec sleep infinity",
b64 = conf_b64,
iface = IFACE
);
let mode_label = format!("{LABEL_MODE}={}", Mode::HostNet.label());
let addr_label = format!("{LABEL_ADDRESS}={address}");
let args: Vec<&str> = vec![
"run",
"-d",
"--name",
SIDECAR,
"--network",
"host",
"--cap-add",
"NET_ADMIN",
"--label",
&mode_label,
"--label",
&addr_label,
"alpine",
"sh",
"-c",
&run_cmd,
];
docker(&args)?;
crate::vpn::vlog(&format!(
"helper {} started (hostnet); waiting for {} in the host netns…",
SIDECAR, IFACE
));
let mut seen = None;
for _ in 0..12 {
if let Some(ip) = host_iface_ip() {
seen = Some(ip);
break;
}
std::thread::sleep(std::time::Duration::from_secs(2));
}
if seen.is_none() {
return Err(self.fail_bringup(Mode::HostNet));
}
let info = ConnectionInfo {
backend: Backend::Docker,
address,
link: SIDECAR.to_string(),
peers: self.read_peers(),
host_routable: true,
proxy: None,
};
crate::vpn::state::save_or_warn(&info);
Ok(info)
}
fn connect_proxy(
&self,
profile: &WgProfile,
conf_b64: &str,
) -> Result<ConnectionInfo, NetError> {
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 proxy_addr = format!("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
);
let publish_broker = format!("127.0.0.1:{p}:{p}", p = BROKER_PORT);
let mode_label = format!("{LABEL_MODE}={}", Mode::Proxy.label());
let addr_label = format!("{LABEL_ADDRESS}={}", profile.interface.address);
let proxy_label = format!("{LABEL_PROXY}={proxy_addr}");
let mut args: Vec<&str> = vec![
"run",
"-d",
"--name",
SIDECAR,
"--cap-add",
"NET_ADMIN",
"--device",
"/dev/net/tun",
"--label",
&mode_label,
"--label",
&addr_label,
"--label",
&proxy_label,
"-p",
&publish,
];
if broker_port_free() {
args.extend_from_slice(&["-p", &publish_broker]);
} else {
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 (proxy); waiting for {} to get a mesh address…",
SIDECAR, IFACE
));
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() {
return Err(self.fail_bringup(Mode::Proxy));
}
let info = ConnectionInfo {
backend: Backend::Docker,
address,
link: SIDECAR.to_string(),
peers: self.read_peers(),
host_routable: false,
proxy: Some(proxy_addr),
};
crate::vpn::state::save_or_warn(&info);
Ok(info)
}
fn delete_host_iface(&self) {
if crate::vpn::host_ops_allowed("delete the host zakuro0").is_err() {
return;
}
if host_iface_ip().is_none() {
return;
}
let _ = docker(&[
"run",
"--rm",
"--network",
"host",
"--cap-add",
"NET_ADMIN",
"alpine",
"ip",
"link",
"delete",
IFACE,
]);
}
}
impl Connector for DockerConnector {
fn available(&self) -> bool {
if crate::vpn::host_ops_allowed("docker info").is_err() {
return false;
}
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)))?;
let conf_b64 = base64::engine::general_purpose::STANDARD.encode(conf_text.as_bytes());
let _ = docker(&["rm", "-f", SIDECAR]);
match preferred_mode() {
Mode::HostNet => match self.connect_hostnet(profile, &conf_b64) {
Ok(info) => Ok(info),
Err(e) => {
crate::vpn::vlog(&format!(
"hostnet mode failed ({e}); falling back to proxy mode"
));
eprintln!(
" ⚠ host-routable tunnel unavailable ({e}); using the proxy sidecar instead"
);
self.delete_host_iface();
self.connect_proxy(profile, &conf_b64)
}
},
Mode::Proxy => self.connect_proxy(profile, &conf_b64),
}
}
fn status(&self) -> Result<Option<ConnectionInfo>, NetError> {
if !self.is_up() {
return Ok(None);
}
if let Some(saved) = crate::vpn::state::load() {
if saved.host_routable && host_iface_ip().is_none() {
return Ok(None);
}
return Ok(Some(saved));
}
let Some((mode, address, proxy)) = self.labels() else {
return Ok(None);
};
let host_routable = match mode {
Mode::HostNet => host_iface_ip().is_some(),
Mode::Proxy => false,
};
if mode == Mode::HostNet && !host_routable {
return Ok(None);
}
let info = ConnectionInfo {
backend: Backend::Docker,
address,
link: SIDECAR.to_string(),
peers: self.read_peers(),
host_routable,
proxy: if mode == Mode::Proxy { proxy } else { None },
};
crate::vpn::state::save_or_warn(&info);
Ok(Some(info))
}
fn disconnect(&self) -> Result<(), NetError> {
if self.is_up() {
let _ = self.exec_capture(&["wg-quick", "down", IFACE]);
}
let _ = docker(&["rm", "-f", SIDECAR]);
self.delete_host_iface();
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));
std::net::TcpListener::bind(("127.0.0.1", p)).unwrap();
}
#[test]
fn broker_port_is_the_documented_one() {
assert_eq!(BROKER_PORT, 9000);
}
#[test]
fn broker_port_free_reports_false_when_taken() {
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");
}
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]);
}
#[test]
fn mode_selection() {
assert_eq!(choose_mode(None, true, true), Mode::HostNet);
assert_eq!(choose_mode(None, false, true), Mode::Proxy);
assert_eq!(choose_mode(None, true, false), Mode::Proxy);
assert_eq!(choose_mode(Some("proxy"), true, true), Mode::Proxy);
assert_eq!(choose_mode(Some("hostnet"), false, false), Mode::HostNet);
assert_eq!(choose_mode(Some("garbage"), true, true), Mode::HostNet);
}
#[test]
fn mode_labels_roundtrip() {
for m in [Mode::HostNet, Mode::Proxy] {
assert_eq!(Mode::parse(m.label()), Some(m));
}
assert_eq!(Mode::parse("nope"), None);
}
#[test]
fn docker_is_refused_in_unit_tests() {
assert!(matches!(docker(&["ps"]), Err(NetError::Refused(_))));
}
#[test]
fn docker_backend_is_unavailable_in_unit_tests() {
assert!(!DockerConnector.available());
}
}