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);
}
}