zc2 0.0.27

P2P compute broker with credit-based billing, WAL, and broker mesh support
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
//! Docker backend: holds the WireGuard tunnel with the help of an alpine
//! container named `zakuro-wg`. It runs in one of two modes:
//!
//! - **hostnet** (Linux with the kernel `wireguard` module): the helper runs
//!   with `--network host --cap-add NET_ADMIN` and `wg-quick` brings
//!   `zakuro0` up *in the host's network namespace*. The host is then routable
//!   into `10.13.13.0/24` exactly as with the native backend — the CLI, a host
//!   `zc broker`, and the broker's QUIC/UDP peer transport all reach the mesh
//!   directly, and a host broker sees its own mesh IP (`discovery::get_mesh_ip`)
//!   so it can advertise a `mesh_endpoint` and be dialed by peers. No root on
//!   the host is needed: the privilege lives in the container, which is why
//!   this exists at all.
//!
//! - **proxy** (fallback: no kernel module, rootless Docker, Docker Desktop):
//!   the original sidecar. wireguard-go in the container's own netns plus a
//!   tinyproxy HTTP CONNECT proxy published on `127.0.0.1:1888x`. The host is
//!   not routable; `vpn::mesh_agent`/`mesh_proxy` route mesh-bound HTTP
//!   through the proxy. UDP (QUIC) cannot cross it, so a host broker in this
//!   mode is a client, not a peer.
//!
//! Both modes stamp the container with labels (`zakuro.mode`, `zakuro.address`,
//! `zakuro.proxy`) so `status()` can rebuild the `ConnectionInfo` when the
//! state file is missing — a live tunnel must never be torn down and rebuilt
//! just because `connection.json` went away.

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;

/// How the helper container carries the tunnel. See the module docs.
#[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,
        }
    }
}

/// Pure mode decision. `hostnet` is only possible where the kernel module is
/// present (Linux); `ZAKURO_WG_DOCKER_MODE=proxy|hostnet` forces either way so
/// an operator can pin the old behaviour if a host's Docker cannot share the
/// host netns (rootless daemons).
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"),
    )
}

/// 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 (proxy mode) 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?
///
/// Proxy mode only. A container that joins the 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 has no way to reach a broker running inside the mesh netns.
///
/// 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.
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::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(),
        ))
    }
}

/// The `zakuro0` address as seen from THIS process's netns (host), CIDR-less.
/// `Some` only in hostnet mode (or native); `None` in proxy mode.
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)
    }

    /// Read the labels stamped at `connect` time: (mode, address, proxy).
    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))
    }

    /// Read peers from `wg show <iface>` inside the sidecar. Best-effort. Works
    /// in both modes: in hostnet mode the container shares the host netns.
    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()
    }

    /// Surface WHY a bring-up failed: the container's wg-quick log plus recent
    /// docker logs, then remove the dead container.
    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)
    }

    /// hostnet mode: `zakuro0` lands in the HOST netns.
    fn connect_hostnet(
        &self,
        profile: &WgProfile,
        conf_b64: &str,
    ) -> Result<ConnectionInfo, NetError> {
        let address = profile.interface.address.clone();
        // `wg-quick down` + `ip link delete` first: a helper that was removed
        // without disconnecting leaves the host interface behind, and wg-quick
        // refuses to bring up an interface that already exists.
        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
        ));
        // apk + wg-quick take a few seconds; the interface appears in OUR netns.
        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)
    }

    /// proxy mode: the original wireguard-go + tinyproxy sidecar.
    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);
        // 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
        );
        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)
    }

    /// Remove a host-netns `zakuro0` left behind by a helper that vanished
    /// without `wg-quick down` (best-effort, one-shot privileged container).
    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)))?;
        // 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 _ = docker(&["rm", "-f", SIDECAR]);
        match preferred_mode() {
            Mode::HostNet => match self.connect_hostnet(profile, &conf_b64) {
                Ok(info) => Ok(info),
                Err(e) => {
                    // Docker that cannot share the host netns (rootless, Desktop
                    // without the module) — fall back to the proxy sidecar so the
                    // user still gets a tunnel, just not a routable host.
                    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() {
            // hostnet: the interface must still exist in our netns, otherwise the
            // helper is a zombie and the caller should reconnect.
            if saved.host_routable && host_iface_ip().is_none() {
                return Ok(None);
            }
            return Ok(Some(saved));
        }
        // No state file but a live helper: rebuild from the container labels and
        // re-persist. Tearing a working tunnel down here (the old behaviour via
        // `connect`'s `rm -f`) is exactly what left users "disconnected".
        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> {
        // hostnet: take the host interface down through the helper (same netns)
        // BEFORE removing it; otherwise `zakuro0` outlives the container.
        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));
        // 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]);
    }

    /// hostnet only where it can work: Linux with the kernel module. Everything
    /// else keeps the proxy sidecar, and the env override wins either way.
    #[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);
    }

    /// zc#211: every `docker` call this backend makes (`ps`, `exec`,
    /// `rm -f zakuro-wg`, the privileged `ip link delete zakuro0`) goes
    /// through `docker()`, which refuses inside the unit-test binary.
    #[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());
    }
}