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;
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::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)
}
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)))?;
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]);
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 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 {
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
));
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() {
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)),
};
crate::vpn::state::save_or_warn(&info);
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));
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]);
}
}