Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-cli/src/control/tunnel.rs

//! `gunnar control tunnel enrol | show` — a box's WireGuard tunnel address,
//! handed over at bootstrap instead of baked into the medium.
//!
//! # Why the address is not on the medium (2026-09-21, T17)
//!
//! Under the one-medium ruling ONE ISO installs the primary and the twin that
//! exists to replace it. The medium's `hub.conf` used to carry
//! `[Interface] Address`, which is a per-BOX value: two boxes installed from
//! one medium came up on the same tunnel address, and the second one's
//! handshake silently took the first one's routes on the hub. The medium now
//! carries only what is the same for every box of a site — the hub's public
//! key, its endpoint, AllowedIPs, keepalive — and the box's own half arrives
//! here:
//!
//! ```text
//!   box first start      mints its WireGuard key (the edda ceremony, unchanged)
//!   enrol, step 1        Bootstrap.AssignTunnelAddress("")  → the box's public key
//!   enrol, step 2        allocate from the site's pool, keyed by that public key,
//!                        and write the LEDGER before anything else moves
//!   enrol, step 3        Bootstrap.AssignTunnelAddress(a/p) → the box raises wg0
//!                        (a box PAST its bootstrap window: Admin.SetTunnelAddress,
//!                        signed with a gunnar-admin key, `-i`)
//!   enrol, step 4        the hub's [Peer] for that key, AllowedIPs = a/32
//! ```
//!
//! The ledger is keyed by the box's public key, so a repeated enrol of the same
//! box — a retry after a timeout, a second run by a second operator — gets the
//! SAME address back, and a box that already holds an address is recorded at
//! that address rather than given a second one. The hub file is written the
//! same way: once per key, and a key already registered at another address, or
//! an address already registered to another key, is refused by name.
//!
//! The allocation lives on the client and not on the box on purpose: a box knows
//! only itself, and "which address is free" is a question about every box of
//! the site.

use std::collections::BTreeMap;
use std::net::Ipv4Addr;
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};

use super::{authed, connect, explain, private_key, runtime};
use crate::cli::ControlTunnelCommand;

pub(crate) fn run(command: ControlTunnelCommand) -> Result<()> {
    match command {
        ControlTunnelCommand::Show { at, ssh, identity } => {
            show(&target(at, ssh)?, identity.as_deref())
        }
        ControlTunnelCommand::Enrol {
            at,
            ssh,
            pool,
            ledger,
            hub_peers,
            label,
            identity,
        } => enrol(
            &target(at, ssh)?,
            &pool,
            &ledger,
            &hub_peers,
            label.as_deref(),
            identity.as_deref(),
        ),
    }
}

/// Which box, reached how. clap already refuses both and neither.
pub(crate) enum Target {
    /// The control plane, `host:port`.
    Grpc(String),
    /// A sealed box's SSH listener, `host:port`.
    Ssh(String),
}

impl Target {
    fn name(&self) -> &str {
        match self {
            Target::Grpc(at) | Target::Ssh(at) => at,
        }
    }
}

fn target(at: Option<String>, ssh: Option<String>) -> Result<Target> {
    match (at, ssh) {
        (Some(at), None) => Ok(Target::Grpc(at)),
        (None, Some(ssh)) => Ok(Target::Ssh(ssh)),
        _ => bail!("name the box once: --at <control plane> or --ssh <a sealed box's ssh door>"),
    }
}

/// A CIDR, IPv4 — the dialect the ceremony speaks.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct Cidr {
    pub addr: Ipv4Addr,
    pub prefix: u8,
}

impl std::fmt::Display for Cidr {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}/{}", self.addr, self.prefix)
    }
}

impl std::str::FromStr for Cidr {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> Result<Cidr> {
        let (a, p) = s
            .trim()
            .split_once('/')
            .with_context(|| format!("{s:?} is not `a.b.c.d/prefix`"))?;
        let addr: Ipv4Addr = a
            .trim()
            .parse()
            .with_context(|| format!("{a:?} is not an IPv4 address"))?;
        let prefix: u8 = p
            .trim()
            .parse()
            .ok()
            .filter(|p| *p <= 32)
            .with_context(|| format!("/{p} is not a prefix 0–32"))?;
        Ok(Cidr { addr, prefix })
    }
}

impl Cidr {
    fn mask(self) -> u32 {
        if self.prefix == 0 {
            0
        } else {
            u32::MAX << (32 - self.prefix)
        }
    }
    fn network(self) -> u32 {
        u32::from(self.addr) & self.mask()
    }
    fn broadcast(self) -> u32 {
        self.network() | !self.mask()
    }
    fn contains(self, a: Ipv4Addr) -> bool {
        u32::from(a) & self.mask() == self.network()
    }
}

/// The site's ledger: one line per box, `a.b.c.d/p <wg public key> <label>`.
///
/// Plain text on purpose — it is the site's record of which box holds which
/// tunnel address, and the operator reads it with `cat`.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct Ledger {
    /// Keyed by the box's WireGuard public key (44 chars of base64).
    pub by_key: BTreeMap<String, (Cidr, String)>,
}

impl Ledger {
    pub(crate) fn parse(text: &str) -> Result<Ledger> {
        let mut ledger = Ledger::default();
        for (n, line) in text.lines().enumerate() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            let mut parts = line.splitn(3, char::is_whitespace);
            let (Some(addr), Some(key)) = (parts.next(), parts.next()) else {
                bail!(
                    "ledger line {}: {line:?} is not `a.b.c.d/p <public key> [label]`",
                    n + 1
                );
            };
            let cidr: Cidr = addr
                .parse()
                .with_context(|| format!("ledger line {}", n + 1))?;
            let label = parts.next().unwrap_or("").trim().to_string();
            if let Some(other) = ledger.by_key.values().find(|(c, _)| c.addr == cidr.addr) {
                bail!(
                    "ledger line {}: {} is recorded twice (also for {:?})",
                    n + 1,
                    cidr.addr,
                    other.1
                );
            }
            if ledger
                .by_key
                .insert(key.to_string(), (cidr, label))
                .is_some()
            {
                bail!("ledger line {}: the key {key} is recorded twice", n + 1);
            }
        }
        Ok(ledger)
    }

    pub(crate) fn render(&self) -> String {
        let mut rows: Vec<(&String, &(Cidr, String))> = self.by_key.iter().collect();
        rows.sort_by_key(|(_, (c, _))| u32::from(c.addr));
        let mut out = String::from(
            "# gunnar tunnel ledger — `gunnar control tunnel enrol` writes it; one box per line\n",
        );
        for (key, (cidr, label)) in rows {
            out.push_str(&format!("{cidr} {key} {label}\n"));
        }
        out
    }

    /// **The address for `key`**: the one it already holds, or the lowest free
    /// host of `pool`. The pool's first host is the hub's and is never handed
    /// out; the network and broadcast addresses are not hosts. `true` = new.
    pub(crate) fn allocate(&mut self, pool: Cidr, key: &str, label: &str) -> Result<(Cidr, bool)> {
        if pool.prefix > 30 {
            bail!("pool {pool} has no room for a hub and a box — use a /30 or wider");
        }
        if let Some((cidr, _)) = self.by_key.get(key) {
            return Ok((*cidr, false));
        }
        let hub = pool.network() + 1;
        let taken: std::collections::BTreeSet<u32> = self
            .by_key
            .values()
            .map(|(c, _)| u32::from(c.addr))
            .collect();
        for host in (hub + 1)..pool.broadcast() {
            if !taken.contains(&host) {
                let cidr = Cidr {
                    addr: Ipv4Addr::from(host),
                    prefix: pool.prefix,
                };
                self.by_key
                    .insert(key.to_string(), (cidr, label.to_string()));
                return Ok((cidr, true));
            }
        }
        bail!(
            "pool {pool} is full: {} boxes hold every host after the hub's",
            taken.len()
        )
    }

    /// Record an address a box ALREADY holds (it was handed one before, or it
    /// was installed from a medium that still carried one). Refused when the
    /// ledger says that address is another box's.
    pub(crate) fn adopt(&mut self, key: &str, held: Cidr, label: &str) -> Result<bool> {
        if let Some((cidr, _)) = self.by_key.get(key) {
            if *cidr != held {
                bail!("the box {key} holds {held} but the ledger records it at {cidr} — one of the two is wrong, and a tunnel address is never re-pointed");
            }
            return Ok(false);
        }
        if let Some((other, _)) = self.by_key.iter().find(|(_, (c, _))| c.addr == held.addr) {
            bail!("the box {key} holds {held}, and the ledger gives that address to {other} — two boxes on one tunnel address is the defect this ledger exists to stop");
        }
        self.by_key
            .insert(key.to_string(), (held, label.to_string()));
        Ok(true)
    }
}

/// **Register `key` at `addr/32` in the hub's WireGuard config**, once.
///
/// Returns the new text, or `None` when the stanza is already there. A key
/// already registered at another address, or the address already given to
/// another key, is refused rather than overwritten: the hub routes by
/// AllowedIPs, so either would steal a live box's traffic.
pub(crate) fn register_peer(
    hub_text: &str,
    key: &str,
    addr: Ipv4Addr,
    label: &str,
) -> Result<Option<String>> {
    let want = format!("{addr}/32");
    let mut section_key: Option<String> = None;
    let mut section_ips: Vec<String> = Vec::new();
    let mut peers: Vec<(Option<String>, Vec<String>)> = Vec::new();
    let mut in_peer = false;
    for line in hub_text.lines() {
        let t = line.trim();
        if t.starts_with('[') {
            if in_peer {
                peers.push((section_key.take(), std::mem::take(&mut section_ips)));
            }
            in_peer = t.eq_ignore_ascii_case("[peer]");
            continue;
        }
        if !in_peer {
            continue;
        }
        if let Some((k, v)) = t.split_once('=') {
            match k.trim().to_ascii_lowercase().as_str() {
                "publickey" => section_key = Some(v.trim().to_string()),
                "allowedips" => section_ips.extend(v.split(',').map(|s| s.trim().to_string())),
                _ => {}
            }
        }
    }
    if in_peer {
        peers.push((section_key, section_ips));
    }
    for (pk, ips) in &peers {
        let same_key = pk.as_deref() == Some(key);
        let has_addr = ips.iter().any(|ip| ip == &want);
        match (same_key, has_addr) {
            (true, true) => return Ok(None),
            (true, false) => bail!("the hub already has a [Peer] for {key} with AllowedIPs {ips:?}, not {want} — refusing to register one box twice"),
            (false, true) => bail!("the hub already routes {want} to {} — refusing to give one tunnel address to two boxes", pk.as_deref().unwrap_or("a peer with no PublicKey")),
            (false, false) => {}
        }
    }
    let mut out = hub_text.to_string();
    if !out.is_empty() && !out.ends_with('\n') {
        out.push('\n');
    }
    out.push_str(&format!(
        "\n[Peer]\n# gunnar {label} — registered by `gunnar control tunnel enrol`\nPublicKey = {key}\nAllowedIPs = {want}\n"
    ));
    Ok(Some(out))
}

/// Write-then-rename, so a crash leaves the old file or the new one.
fn write_atomic(path: &Path, text: &str) -> Result<()> {
    use std::io::Write as _;
    let tmp = path.with_extension("tmp-enrol");
    let mut f =
        std::fs::File::create(&tmp).with_context(|| format!("writing {}", tmp.display()))?;
    f.write_all(text.as_bytes())?;
    f.sync_all()?;
    drop(f);
    std::fs::rename(&tmp, path).with_context(|| format!("renaming {} into place", tmp.display()))
}

/// The ledger's lock: `create_new`, so two enrolments cannot both read the same
/// free host. Removed on drop; a stale one names itself.
struct LedgerLock(PathBuf);
impl LedgerLock {
    fn take(ledger: &Path) -> Result<LedgerLock> {
        let mut name = ledger.as_os_str().to_owned();
        name.push(".lock");
        let path = PathBuf::from(name);
        std::fs::OpenOptions::new().write(true).create_new(true).open(&path).with_context(|| {
            format!("{} exists: another enrolment holds the ledger (or one died — remove the file once you are sure none is running)", path.display())
        })?;
        Ok(LedgerLock(path))
    }
}
impl Drop for LedgerLock {
    fn drop(&mut self) {
        let _ = std::fs::remove_file(&self.0);
    }
}

fn read_or_empty(path: &Path) -> Result<String> {
    match std::fs::read_to_string(path) {
        Ok(t) => Ok(t),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
        Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
    }
}

/// Which door writes the address: the bootstrap one while the box is fresh,
/// `Admin.SetTunnelAddress` (server Admin, a signed credential) once it is past
/// its bootstrap window. Decided by asking the box (`Bootstrap.Status`), never
/// by trying one and falling back on an error text.
pub(crate) enum Door {
    Bootstrap,
    Admin(Box<ssh_key::PrivateKey>),
    /// A sealed box's SSH listener: `gunnar-tunnel-address`, as the admin key.
    Ssh {
        key: Option<std::path::PathBuf>,
    },
}

impl Door {
    /// The line `enrol` prints, so the operator sees which door answered.
    fn describe(&self) -> &'static str {
        match self {
            Door::Bootstrap => "grpc Bootstrap.AssignTunnelAddress (a fresh box, no credential)",
            Door::Admin(_) => {
                "grpc Admin.SetTunnelAddress (an enrolled box, signed as a gunnar-admin)"
            }
            Door::Ssh { .. } => "ssh gunnar-tunnel-address (a sealed box, as a gunnar-admin)",
        }
    }
}

/// **The verb a sealed box answers** — `gunnar_ssh::TUNNEL_ADDRESS_VERB`,
/// spelled here because a control-only build links no server crate; the
/// over-the-wire test in `tests/tunnel_address_ssh.rs` holds the two together.
#[cfg(feature = "client")]
pub(crate) const SSH_VERB: &str = "gunnar-tunnel-address";

#[cfg(feature = "client")]
fn ask_ssh(
    at: &str,
    address: &str,
    key: Option<&Path>,
) -> Result<gunnar_proto::AssignTunnelAddressResponse> {
    use std::io::Read as _;
    let (host, port) = match at.rsplit_once(':') {
        Some((h, p)) => (
            h.to_string(),
            p.parse::<u16>()
                .with_context(|| format!("--ssh {at}: the port"))?,
        ),
        None => (at.to_string(), 2222),
    };
    let loc = gunnar_client::ssh::SshLocation {
        user: "git".into(),
        host,
        port,
        path: String::new(),
    };
    let opts = match key {
        Some(path) => gunnar_client::SshOptions::new().with_key(path),
        None => gunnar_client::SshOptions::new(),
    };
    let command = if address.is_empty() {
        SSH_VERB.to_string()
    } else {
        format!("{SSH_VERB} {address}")
    };
    let mut channel = gunnar_client::ssh::connect_exec(&loc, &command, &opts)
        .map_err(|e| anyhow::anyhow!("{at}: {e}"))?;
    let mut out = String::new();
    channel
        .io()
        .0
        .read_to_string(&mut out)
        .with_context(|| format!("reading {at}'s answer"))?;
    gunnar_proto::parse_tunnel_answer(&out).map_err(|why| anyhow::anyhow!("{at} refused: {why}"))
}

#[cfg(not(feature = "client"))]
fn ask_ssh(
    _at: &str,
    _address: &str,
    _key: Option<&Path>,
) -> Result<gunnar_proto::AssignTunnelAddressResponse> {
    bail!("--ssh needs the `client` feature (an SSH dialler); this build is control-only")
}

fn door(target: &Target, identity: Option<&Path>) -> Result<Door> {
    let at = match target {
        Target::Ssh(_) => {
            return Ok(Door::Ssh {
                key: identity.map(Path::to_path_buf),
            })
        }
        Target::Grpc(at) => at.as_str(),
    };
    let rt = runtime()?;
    let mut client = gunnar_proto::bootstrap_client::BootstrapClient::new(connect(&rt, at)?);
    let status = rt
        .block_on(client.status(gunnar_proto::BootstrapStatusRequest {}))
        .map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
        .into_inner();
    if status.window_open {
        return Ok(Door::Bootstrap);
    }
    let key = private_key(identity).context(
        "this box is past its bootstrap window, so its tunnel address is set with Admin.SetTunnelAddress, \
         which needs a gunnar-admin key (-i <path>)",
    )?;
    Ok(Door::Admin(Box::new(key)))
}

fn ask(at: &str, address: &str, door: &Door) -> Result<gunnar_proto::AssignTunnelAddressResponse> {
    if let Door::Ssh { key } = door {
        return ask_ssh(at, address, key.as_deref());
    }
    let rt = runtime()?;
    let channel = connect(&rt, at)?;
    let request = gunnar_proto::AssignTunnelAddressRequest {
        address: address.to_string(),
    };
    let answer = match door {
        Door::Bootstrap => {
            let mut client = gunnar_proto::bootstrap_client::BootstrapClient::new(channel);
            rt.block_on(client.assign_tunnel_address(request))
        }
        Door::Admin(key) => {
            let mut client = gunnar_proto::admin_client::AdminClient::new(channel);
            rt.block_on(client.set_tunnel_address(authed(key, request)?))
        }
        Door::Ssh { .. } => unreachable!("answered above"),
    };
    Ok(answer
        .map_err(|status| anyhow::anyhow!("{}", explain(&status)))?
        .into_inner())
}

fn show(target: &Target, identity: Option<&Path>) -> Result<()> {
    let door = match target {
        Target::Ssh(_) => Door::Ssh {
            key: identity.map(Path::to_path_buf),
        },
        Target::Grpc(_) => Door::Bootstrap,
    };
    let answer = ask(target.name(), "", &door)?;
    println!("door          {}", door.describe());
    println!("wg_public_key {}", answer.wg_public_key);
    println!(
        "address       {}",
        if answer.address.is_empty() {
            "-"
        } else {
            &answer.address
        }
    );
    println!("tunnel_up     {}", answer.tunnel_up);
    eprintln!("{}", answer.detail);
    Ok(())
}

fn enrol(
    target: &Target,
    pool: &str,
    ledger_path: &Path,
    hub_peers: &Path,
    label: Option<&str>,
    identity: Option<&Path>,
) -> Result<()> {
    let pool: Cidr = pool.parse().context("--pool")?;
    let at = target.name();
    let label = label.unwrap_or(at);
    let door = door(target, identity)?;
    eprintln!("door: {}", door.describe());
    let first = ask(at, "", &door)?;
    let key = first.wg_public_key.trim().to_string();
    if key.len() != 44 {
        bail!("{at} answered a WireGuard public key of {} characters ({key:?}); expected 44 — is its appliance ceremony on?", key.len());
    }
    let addr = {
        let _lock = LedgerLock::take(ledger_path)?;
        let mut ledger = Ledger::parse(&read_or_empty(ledger_path)?)
            .with_context(|| format!("{}", ledger_path.display()))?;
        let (cidr, changed) = if first.address.is_empty() {
            ledger.allocate(pool, &key, label)?
        } else {
            let held: Cidr = first.address.parse().context("the box's own answer")?;
            (held, ledger.adopt(&key, held, label)?)
        };
        if !pool.contains(cidr.addr) {
            eprintln!("warning: {at} holds {cidr}, outside --pool {pool}");
        }
        // The ledger FIRST: a crash between here and the box leaves an
        // allocation the next run reuses (keyed by the same public key), never
        // a box holding an address the ledger does not know.
        if changed {
            write_atomic(ledger_path, &ledger.render())?;
        }
        cidr
    };
    let answer = ask(at, &addr.to_string(), &door)?;
    let hub_text = read_or_empty(hub_peers)?;
    let registered = match register_peer(&hub_text, &key, addr.addr, label)? {
        Some(text) => {
            write_atomic(hub_peers, &text)?;
            true
        }
        None => false,
    };
    println!("door          {}", door.describe());
    println!("wg_public_key {key}");
    println!("address       {addr}");
    println!("tunnel_up     {}", answer.tunnel_up);
    println!(
        "hub_peer      {} ({})",
        if registered {
            "registered"
        } else {
            "already registered"
        },
        hub_peers.display()
    );
    eprintln!("{}", answer.detail);
    if registered {
        eprintln!("the hub reads its [Peer] list at start: reload its WireGuard ({}) for the new peer to handshake", hub_peers.display());
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    const K1: &str = "6yUuA2NvUdRuoHwFmS9OD1V8pFpXmDgVQjJ+YEptGm4=";
    const K2: &str = "aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=";

    /// **Two boxes from one medium get two addresses** — the defect, as a
    /// number. The medium used to carry one `Address`; the ledger now hands
    /// each box its own, never the hub's, and the same box its own again.
    #[test]
    fn two_boxes_from_one_pool_get_different_addresses_and_the_same_box_keeps_its_own() {
        let pool: Cidr = "10.99.0.0/24".parse().unwrap();
        let mut ledger = Ledger::default();
        let (a, new_a) = ledger.allocate(pool, K1, "primary").unwrap();
        let (b, new_b) = ledger.allocate(pool, K2, "twin").unwrap();
        assert!(new_a && new_b);
        assert_ne!(a, b, "one medium, two boxes, two tunnel addresses");
        assert_eq!(
            a.to_string(),
            "10.99.0.2/24",
            "the pool's first host is the hub's"
        );
        assert_eq!(b.to_string(), "10.99.0.3/24");
        let (again, new_again) = ledger.allocate(pool, K1, "primary").unwrap();
        assert_eq!(
            (again, new_again),
            (a, false),
            "a retried enrol gets the SAME address back"
        );

        let reread = Ledger::parse(&ledger.render()).unwrap();
        assert_eq!(reread, ledger, "the ledger survives its own file");
    }

    #[test]
    fn a_full_pool_and_a_duplicate_ledger_line_are_refused() {
        let pool: Cidr = "10.99.0.0/30".parse().unwrap();
        let mut ledger = Ledger::default();
        ledger.allocate(pool, K1, "a").unwrap();
        let err = ledger.allocate(pool, K2, "b").unwrap_err().to_string();
        assert!(err.contains("full"), "{err}");
        let twice = format!("10.99.0.2/24 {K1} a\n10.99.0.2/24 {K2} b\n");
        assert!(
            Ledger::parse(&twice).is_err(),
            "one address recorded for two boxes must not parse"
        );
    }

    #[test]
    fn a_box_that_already_holds_an_address_is_adopted_never_given_a_second() {
        let pool: Cidr = "10.99.0.0/24".parse().unwrap();
        let mut ledger = Ledger::default();
        let (a, _) = ledger.allocate(pool, K1, "primary").unwrap();
        // K2 claims K1's address: refused.
        assert!(ledger.adopt(K2, a, "twin").is_err());
        // K1 claims another: refused.
        assert!(ledger
            .adopt(K1, "10.99.0.9/24".parse().unwrap(), "primary")
            .is_err());
        assert!(
            !ledger.adopt(K1, a, "primary").unwrap(),
            "the same pair is a no-op"
        );
    }

    #[test]
    fn the_hub_peer_is_registered_once_and_never_stolen() {
        let hub = "[Interface]\nPrivateKey = x\nAddress = 10.99.0.1/24\nListenPort = 51820\n";
        let once = register_peer(hub, K1, "10.99.0.2".parse().unwrap(), "primary")
            .unwrap()
            .expect("new");
        assert!(
            once.contains(&format!("PublicKey = {K1}\nAllowedIPs = 10.99.0.2/32")),
            "{once}"
        );
        assert!(
            register_peer(&once, K1, "10.99.0.2".parse().unwrap(), "primary")
                .unwrap()
                .is_none(),
            "idempotent"
        );
        assert!(
            register_peer(&once, K1, "10.99.0.3".parse().unwrap(), "primary").is_err(),
            "one key, two addresses"
        );
        assert!(
            register_peer(&once, K2, "10.99.0.2".parse().unwrap(), "twin").is_err(),
            "one address, two keys"
        );
        let twice = register_peer(&once, K2, "10.99.0.3".parse().unwrap(), "twin")
            .unwrap()
            .expect("second box");
        assert_eq!(twice.matches("[Peer]").count(), 2);
    }
}