use crate::vpn::connector::Connector;
use crate::vpn::profile::WgProfile;
use crate::vpn::{
Backend, ConnectionInfo, NetError, PeerStatus, RELAY_FIRST_PORT, RELAY_LAST_PORT, RELAY_VERSION,
};
use std::process::Command;
pub(crate) 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";
const LABEL_NODE: &str = "zakuro.node";
const LABEL_RELAY: &str = "zakuro.relay";
pub(crate) const NODE_SHARED: &str = "shared";
fn node_label(node: Option<&str>) -> String {
format!("{LABEL_NODE}={}", node.unwrap_or(NODE_SHARED))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SidecarLabels {
pub mode: Mode,
pub address: String,
pub proxy: Option<String>,
pub node: Option<String>,
pub relay: Option<u32>,
}
fn label_value(s: &str) -> Option<String> {
let s = s.trim();
(!s.is_empty() && s != "<no value>").then(|| s.to_string())
}
pub(crate) fn parse_labels(out: &str) -> Option<SidecarLabels> {
let mut parts = out.trim().splitn(5, '|');
let mode = Mode::parse(parts.next()?)?;
let address = label_value(parts.next()?)?;
let proxy = parts.next().and_then(label_value);
let node = parts.next().and_then(label_value);
let relay = parts
.next()
.and_then(label_value)
.and_then(|v| v.parse().ok());
Some(SidecarLabels {
mode,
address,
proxy,
node,
relay,
})
}
fn info_from_labels(
labels: SidecarLabels,
host_routable: bool,
peers: Vec<PeerStatus>,
) -> ConnectionInfo {
let proxy_mode = labels.mode == Mode::Proxy;
ConnectionInfo {
backend: Backend::Docker,
address: labels.address,
link: SIDECAR.to_string(),
peers,
host_routable,
proxy: if proxy_mode { labels.proxy } else { None },
relay: if proxy_mode { labels.relay } else { None },
}
}
pub(crate) fn proxy_start_script(conf_b64: &str) -> String {
format!(
"apk add -q wireguard-tools wireguard-go tinyproxy socat 2>/dev/null; \
if ! command -v socat >/dev/null 2>&1; then \
echo 'socat did not install, so the mesh relay cannot run' | tee /tmp/wg.log >&2; \
exit 1; \
fi; \
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 & \
WG_IP=$(ip -4 addr show {iface} | awk '/inet /{{print $2}}' | cut -d/ -f1); \
if [ -n \"$WG_IP\" ]; then \
for p in $(seq {first} {last}); do \
socat TCP-LISTEN:$p,bind=$WG_IP,fork,reuseaddr TCP:host.docker.internal:$p >/tmp/relay-$p.log 2>&1 & \
done; \
fi; \
exec sleep infinity",
b64 = conf_b64,
iface = IFACE,
first = RELAY_FIRST_PORT,
last = RELAY_LAST_PORT,
)
}
pub(crate) fn proxy_run_args(
address: &str,
proxy_port: u16,
node: Option<&str>,
run_cmd: &str,
) -> Vec<String> {
let mut args: Vec<String> = [
"run",
"-d",
"--name",
SIDECAR,
"--cap-add",
"NET_ADMIN",
"--device",
"/dev/net/tun",
"--add-host",
"host.docker.internal:host-gateway",
]
.map(String::from)
.to_vec();
let labels = [
format!("{LABEL_MODE}={}", Mode::Proxy.label()),
format!("{LABEL_ADDRESS}={address}"),
format!("{LABEL_PROXY}=127.0.0.1:{proxy_port}"),
format!("{LABEL_RELAY}={RELAY_VERSION}"),
node_label(node),
];
for label in labels {
args.push("--label".to_string());
args.push(label);
}
args.push("-p".to_string());
args.push(format!("127.0.0.1:{proxy_port}:8888"));
args.extend(["alpine", "sh", "-c", run_cmd].map(String::from));
args
}
#[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())
}
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))
}
fn bringup_error(mode: Mode, wglog: &str, dlog: &str) -> NetError {
fn last(log: &str) -> Option<&str> {
log.lines().map(str::trim).rfind(|l| !l.is_empty())
}
let msg = match (last(wglog), last(dlog)) {
(Some(detail), _) => format!(
"tunnel did not come up — wg-quick ({}): {}",
mode.label(),
detail
),
(None, Some(detail)) => format!(
"tunnel did not come up — {SIDECAR} ({}): {}",
mode.label(),
detail
),
(None, None) => format!(
"tunnel did not come up ({} mode; run `zc vpn connect --docker --verbose` for container logs)",
mode.label()
),
};
NetError::Backend(msg)
}
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)
}
pub(crate) fn sidecar_labels(&self) -> Option<SidecarLabels> {
let format = [
LABEL_MODE,
LABEL_ADDRESS,
LABEL_PROXY,
LABEL_NODE,
LABEL_RELAY,
]
.map(|l| format!("{{{{index .Config.Labels \"{l}\"}}}}"))
.join("|");
parse_labels(&docker(&["inspect", "-f", &format, SIDECAR]).ok()?)
}
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]);
bringup_error(mode, &wglog, &dlog)
}
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 node = node_label(profile.node.as_deref());
let args: Vec<&str> = vec![
"run",
"-d",
"--name",
SIDECAR,
"--network",
"host",
"--cap-add",
"NET_ADMIN",
"--label",
&mode_label,
"--label",
&addr_label,
"--label",
&node,
"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,
relay: 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 proxy_addr = format!("127.0.0.1:{}", proxy_port);
let args = proxy_run_args(
&profile.interface.address,
proxy_port,
profile.node.as_deref(),
&proxy_start_script(conf_b64),
);
docker(&args.iter().map(String::as_str).collect::<Vec<_>>())?;
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),
relay: Some(RELAY_VERSION),
};
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(labels) = self.sidecar_labels() else {
return Ok(None);
};
let host_routable = match labels.mode {
Mode::HostNet => host_iface_ip().is_some(),
Mode::Proxy => false,
};
if labels.mode == Mode::HostNet && !host_routable {
return Ok(None);
}
let info = info_from_labels(labels, host_routable, self.read_peers());
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 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());
}
const FP: &str = "0123456789abcdef";
fn values_after<'a>(args: &'a [String], flag: &str) -> Vec<&'a str> {
args.windows(2)
.filter(|w| w[0] == flag)
.map(|w| w[1].as_str())
.collect()
}
#[test]
fn proxy_run_args_publish_only_the_connect_proxy() {
let args = proxy_run_args("10.13.13.7/24", 18888, Some(FP), "true");
assert_eq!(
values_after(&args, "-p"),
vec!["127.0.0.1:18888:8888"],
"no broker port: peers come in through the relay"
);
assert!(
!args.iter().any(|a| a.contains("9000")),
"no 127.0.0.1:9000 publish to take the agent's broker port: {args:?}"
);
}
#[test]
fn proxy_run_args_reach_the_host_through_host_gateway() {
let args = proxy_run_args("10.13.13.7/24", 18888, Some(FP), "true");
assert_eq!(
values_after(&args, "--add-host"),
vec!["host.docker.internal:host-gateway"]
);
}
#[test]
fn proxy_run_args_stamp_the_labels_status_and_connect_read() {
let args = proxy_run_args("10.13.13.7/24", 18888, Some(FP), "true");
assert_eq!(
values_after(&args, "--label"),
vec![
"zakuro.mode=proxy",
"zakuro.address=10.13.13.7/24",
"zakuro.proxy=127.0.0.1:18888",
"zakuro.relay=1",
"zakuro.node=0123456789abcdef",
]
);
let shared = proxy_run_args("10.13.13.7/24", 18888, None, "true");
let nodes: Vec<&str> = values_after(&shared, "--label")
.into_iter()
.filter(|l| l.starts_with("zakuro.node="))
.collect();
assert_eq!(
nodes,
vec!["zakuro.node=shared"],
"a profile the hub didn't make per device is labeled shared, so an older hub \
doesn't get the sidecar recreated on every connect: {shared:?}"
);
}
#[test]
fn proxy_run_args_name_the_sidecar_and_end_with_the_script() {
let args = proxy_run_args("10.13.13.7/24", 18888, Some(FP), "the script");
assert_eq!(&args[..4], ["run", "-d", "--name", "zakuro-wg"]);
assert_eq!(
&args[args.len() - 4..],
["alpine", "sh", "-c", "the script"]
);
}
#[test]
fn node_label_names_the_fingerprint_or_shared() {
assert_eq!(node_label(Some(FP)), "zakuro.node=0123456789abcdef");
assert_eq!(node_label(None), "zakuro.node=shared");
assert_eq!(
parse_labels("hostnet|10.13.13.7/24||shared")
.unwrap()
.node
.as_deref(),
Some(NODE_SHARED)
);
}
#[test]
fn labels_parse_the_node_and_the_relay_version() {
assert_eq!(
parse_labels("proxy|10.13.13.7/24|127.0.0.1:18888|0123456789abcdef|1"),
Some(SidecarLabels {
mode: Mode::Proxy,
address: "10.13.13.7/24".into(),
proxy: Some("127.0.0.1:18888".into()),
node: Some(FP.into()),
relay: Some(1),
})
);
assert_eq!(
parse_labels("proxy|10.13.13.7/24|127.0.0.1:18888|0123456789abcdef|")
.unwrap()
.relay,
None,
"a sidecar from before the relay"
);
assert_eq!(
parse_labels("proxy|10.13.13.7/24|127.0.0.1:18888||<no value>")
.unwrap()
.relay,
None
);
}
#[test]
fn labels_from_an_older_sidecar_have_no_node() {
assert_eq!(
parse_labels("proxy|10.13.13.7/24|127.0.0.1:18888|")
.unwrap()
.node,
None
);
assert_eq!(
parse_labels("proxy|10.13.13.7/24|127.0.0.1:18888|<no value>")
.unwrap()
.node,
None
);
assert_eq!(parse_labels("hostnet|10.13.13.7/24||").unwrap().proxy, None);
assert_eq!(
parse_labels("proxy||127.0.0.1:18888|x"),
None,
"no address: not a sidecar zc made"
);
}
#[test]
fn proxy_start_script_relays_9000_through_9010_after_the_tunnel_is_up() {
let s = proxy_start_script("Q09ORg==");
assert!(
s.starts_with("apk add -q wireguard-tools wireguard-go tinyproxy socat "),
"{s}"
);
let up = s.find("wg-quick up zakuro0").expect("the tunnel comes up");
let relay = s
.find("socat TCP-LISTEN:$p,bind=$WG_IP,fork,reuseaddr TCP:host.docker.internal:$p")
.expect("the relay");
assert!(
up < relay,
"the relay binds the mesh address, which exists only once wg-quick is up"
);
assert!(
s.contains("WG_IP=$(ip -4 addr show zakuro0 | awk '/inet /{print $2}' | cut -d/ -f1)"),
"{s}"
);
assert!(
s.contains("if [ -n \"$WG_IP\" ]; then for p in $(seq 9000 9010); do "),
"{s}"
);
assert!(
s.contains(">/tmp/relay-$p.log 2>&1 & done; fi; "),
"each socat runs in the background: {s}"
);
assert!(s.ends_with("exec sleep infinity"), "{s}");
}
#[test]
fn proxy_start_script_stops_before_the_tunnel_without_socat() {
let s = proxy_start_script("Q09ORg==");
let guard = s
.find(
"apk add -q wireguard-tools wireguard-go tinyproxy socat 2>/dev/null; \
if ! command -v socat >/dev/null 2>&1; then ",
)
.expect("the socat guard, right after apk add");
let bail = s
.find(" | tee /tmp/wg.log >&2; exit 1; fi; ")
.expect("the guard says why, in wg.log and the docker logs, then exits non-zero");
assert!(
s[guard..bail].contains("echo 'socat did not install"),
"{s}"
);
let up = s.find("wg-quick up zakuro0").unwrap();
let relay = s.find("for p in $(seq 9000 9010)").unwrap();
let sleep = s.find("exec sleep infinity").unwrap();
assert!(guard < bail && bail < up, "no tunnel without socat: {s}");
assert!(up < relay && relay < sleep, "{s}");
}
#[test]
fn a_connection_rebuilt_from_the_labels_records_the_relay() {
let current =
parse_labels("proxy|10.13.13.7/24|127.0.0.1:18888|0123456789abcdef|1").unwrap();
let info = info_from_labels(current.clone(), false, vec![]);
assert_eq!(info.relay, Some(RELAY_VERSION));
assert_eq!(info.proxy.as_deref(), Some("127.0.0.1:18888"));
assert_eq!(
(info.address.as_str(), info.link.as_str()),
("10.13.13.7/24", "zakuro-wg")
);
let old = SidecarLabels {
relay: None,
..current.clone()
};
assert_eq!(
info_from_labels(old, false, vec![]).relay,
None,
"a sidecar from before the relay"
);
let hostnet = SidecarLabels {
mode: Mode::HostNet,
..current
};
let info = info_from_labels(hostnet, true, vec![]);
assert_eq!(
(info.proxy, info.relay),
(None, None),
"hostnet has no proxy and no relay"
);
}
#[test]
fn a_failed_bringup_says_why() {
let socat = bringup_error(
Mode::Proxy,
"",
"socat did not install, so the mesh relay cannot run\n",
)
.to_string();
assert!(
socat.contains("zakuro-wg (proxy): socat did not install"),
"an exited container's docker logs say why: {socat}"
);
let wg = bringup_error(
Mode::Proxy,
"[#] ip link add zakuro0 type wireguard\nRTNETLINK answers: Operation not permitted\n\n",
"ip: can't find device 'zakuro0'\n",
)
.to_string();
assert!(
wg.contains("wg-quick (proxy): RTNETLINK answers: Operation not permitted"),
"wg-quick's own log wins: {wg}"
);
let silent = bringup_error(Mode::HostNet, "", "").to_string();
assert!(silent.contains("hostnet mode; run"), "{silent}");
}
}