use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use wg_appliance_core::appliance::ApplianceEnv;
use wg_appliance_core::ceremony::{self, HubPeer, Identity};
use crate::wg::{Peer, WgConfig, CONFIG_FILE};
pub const PRODUCT: &str = "gunnar";
pub const GATE_ENV: &str = "GUNNAR_APPLIANCE";
pub const DATA_ENV: &str = "GUNNAR_APPLIANCE_DATA";
pub const UNDERLAY_ENV: &str = "GUNNAR_UNDERLAY";
pub const DEFAULT_ROOT: &str = "/data";
pub const ENV: ApplianceEnv = ApplianceEnv {
product: PRODUCT,
gate_env: GATE_ENV,
data_env: DATA_ENV,
underlay_env: UNDERLAY_ENV,
default_root: DEFAULT_ROOT,
};
pub fn gate_enabled() -> bool {
ENV.gate_enabled()
}
pub fn wg_dir(data_root: &Path) -> PathBuf {
ApplianceEnv::wg_dir(&ENV.data_root_or(data_root))
}
pub struct Ceremony {
pub public_b64: String,
pub minted: bool,
pub config: Option<WgConfig>,
}
impl std::fmt::Debug for Ceremony {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Ceremony")
.field("public_b64", &self.public_b64)
.field("minted", &self.minted)
.field("hub_declared", &self.config.is_some())
.finish()
}
}
pub fn run(data_root: &Path, out: &mut dyn FnMut(&str)) -> Result<Ceremony> {
let root = ENV.data_root_or(data_root);
let wg = ApplianceEnv::wg_dir(&root);
ENV.apply_underlay(out).map_err(|e| anyhow::anyhow!(e))?;
let identity: Identity = ENV
.announce_identity(&wg, out)
.with_context(|| format!("the appliance identity ceremony at {}", wg.display()))?;
let hub: Option<HubPeer> = ENV
.read_hub(&wg, out)
.with_context(|| format!("the hub declaration in {}", wg.display()))?;
let config = match hub {
None => None,
Some(hub) => config_from_ceremony(&identity, &hub, &wg, out)?,
};
Ok(Ceremony {
public_b64: identity.public_b64,
minted: identity.minted,
config,
})
}
fn config_from_ceremony(
identity: &Identity,
hub: &HubPeer,
wg: &Path,
out: &mut dyn FnMut(&str),
) -> Result<Option<WgConfig>> {
let assigned = assigned_address(wg)?;
let (address, prefix) = match (assigned, hub.appliance_address) {
(Some(a), Some(m)) if a != m => bail!(
"{} says this box's tunnel address is {}/{} and {} (handed over by \
{METHOD_ASSIGN}) says {}/{}. Two answers to one per-box question, and this refuses \
to rank them: the hub routes by one of them. Remove `[Interface]` from the medium's \
hub.conf — the medium carries the SITE only — or remove {} if the medium is right",
ApplianceEnv::hub_path(wg).display(),
m.0,
m.1,
wg.join(ADDRESS_FILE).display(),
a.0,
a.1,
wg.join(ADDRESS_FILE).display(),
),
(Some(a), _) => a,
(None, Some(m)) => {
out(&format!(
"{PRODUCT}-appliance wg: the tunnel address {}/{} comes from the MEDIUM ({}). An address \
on a medium is the same address on every box installed from it; a new medium carries \
the site only, and each box gets its own through {METHOD_ASSIGN}",
m.0,
m.1,
ApplianceEnv::hub_path(wg).display()
));
m
}
(None, None) => {
out(&format!(
"{PRODUCT}-appliance wg: hub declared, NO tunnel address yet — this box's address is handed \
over at bootstrap by {METHOD_ASSIGN} (`gunnar control tunnel enrol`), never baked into \
the medium. Serving on the outer address until then; the tunnel comes up the moment the \
address arrives, no restart"
));
return Ok(None);
}
};
let spec = hub.peer_spec();
let interface = Path::new(CONFIG_FILE)
.file_stem()
.and_then(|s| s.to_str())
.expect("CONFIG_FILE is a literal with a stem");
Ok(Some(WgConfig {
interface: interface.to_string(),
private_key: identity.private,
address,
prefix,
listen_port: None,
peers: vec![Peer {
public_key: spec.public_key,
preshared_key: None,
allowed_ips: spec.allowed_ips,
endpoint: spec.endpoint,
persistent_keepalive: spec.persistent_keepalive,
}],
}))
}
pub const METHOD_ASSIGN: &str = "Bootstrap.AssignTunnelAddress";
pub const ADDRESS_FILE: &str = "address";
pub fn parse_address(text: &str) -> Result<(std::net::Ipv4Addr, u8)> {
parse_cidr(text)
}
fn parse_cidr(text: &str) -> Result<(std::net::Ipv4Addr, u8)> {
let (a, p) = text
.trim()
.split_once('/')
.with_context(|| format!("{text:?} is not `a.b.c.d/prefix`"))?;
let addr: std::net::Ipv4Addr = a.trim().parse().with_context(|| format!("{a:?} is not an IPv4 address"))?;
let prefix: u8 = p
.trim()
.parse()
.ok()
.filter(|p| (1..=32).contains(p))
.with_context(|| format!("/{p} is not a prefix 1–32 (the site's real prefix: the connected route it installs is what answers the hub)"))?;
if prefix < 31 {
let mask = u32::MAX << (32 - prefix);
let host = u32::from(addr) & !mask;
anyhow::ensure!(host != 0 && host != !mask, "{addr}/{prefix} is the network or broadcast address of its subnet, not a host");
}
Ok((addr, prefix))
}
pub fn assigned_address(wg: &Path) -> Result<Option<(std::net::Ipv4Addr, u8)>> {
let path = wg.join(ADDRESS_FILE);
match std::fs::read_to_string(&path) {
Ok(text) => parse_cidr(&text).map(Some).with_context(|| format!("{}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Assignment {
pub public_b64: String,
pub address: Option<(std::net::Ipv4Addr, u8)>,
pub changed: bool,
}
pub fn assignment_door(
gate: bool,
window_open: bool,
held: Option<(std::net::Ipv4Addr, u8)>,
requested: Option<(std::net::Ipv4Addr, u8)>,
) -> Result<()> {
anyhow::ensure!(
gate,
"this box runs no appliance ceremony ({GATE_ENV} is not 1), so it has no minted WireGuard identity \
to give an address to — a dev box takes its tunnel from wg0.conf"
);
match requested {
None => Ok(()),
Some(r) if held == Some(r) => Ok(()),
Some(_) if window_open => Ok(()),
Some((a, p)) => bail!(
"{a}/{p} refused: this box is past its bootstrap (an identity is enrolled), and {METHOD_ASSIGN} \
is unauthenticated, so it writes only while the box is fresh or repeats the address it already holds{}. \
A gunnar-admin sets it with Admin.SetTunnelAddress (`gunnar control tunnel enrol -i <admin key>`)",
match held {
Some((h, hp)) => format!(" ({h}/{hp})"),
None => " (none)".to_string(),
}
),
}
}
pub fn assign_address(data_root: &Path, requested: Option<&str>) -> Result<Assignment> {
let wg = wg_dir(data_root);
let pub_path = wg.join(ceremony::IDENTITY_PUB_FILE);
let public_b64 = std::fs::read_to_string(&pub_path)
.with_context(|| format!("{} — the ceremony has not run on this box, so there is no key to address", pub_path.display()))?
.trim()
.to_string();
let medium = match std::fs::metadata(ApplianceEnv::hub_path(&wg)) {
Ok(_) => ceremony::read_hub_config(&ApplianceEnv::hub_path(&wg))
.map_err(|e| anyhow::anyhow!("{e}"))?
.appliance_address,
Err(_) => None,
};
let assigned = assigned_address(&wg)?;
let held = assigned.or(medium);
let Some(text) = requested.map(str::trim).filter(|t| !t.is_empty()) else {
return Ok(Assignment { public_b64, address: held, changed: false });
};
let want = parse_cidr(text)?;
if let Some(have) = held {
anyhow::ensure!(
have == want,
"this box already holds the tunnel address {}/{}, and a tunnel address is never re-pointed — the hub \
routes {}/32 to this key. {}/{} refused",
have.0,
have.1,
have.0,
want.0,
want.1
);
if assigned.is_some() {
return Ok(Assignment { public_b64, address: held, changed: false });
}
}
std::fs::create_dir_all(&wg).with_context(|| format!("creating {}", wg.display()))?;
let path = wg.join(ADDRESS_FILE);
let tmp = wg.join("address.new");
{
use std::io::Write as _;
let mut f = std::fs::File::create(&tmp).with_context(|| format!("writing {}", tmp.display()))?;
writeln!(f, "{}/{}", want.0, want.1)?;
f.sync_all()?;
}
std::fs::rename(&tmp, &path).with_context(|| format!("renaming into {}", path.display()))?;
if let Ok(d) = std::fs::File::open(&wg) {
let _ = d.sync_all();
}
Ok(Assignment { public_b64, address: Some(want), changed: true })
}
pub fn tunnel_address_answer(
root: &Path,
address: &str,
may_write: bool,
) -> gunnar_grpc::ControlResult<gunnar_proto::AssignTunnelAddressResponse> {
use gunnar_grpc::ControlError;
let pre = |err: anyhow::Error| ControlError::FailedPrecondition(format!("{err:#}"));
let requested = if address.is_empty() { None } else { Some(address) };
if let Some((public_b64, held, tunnel_up)) = crate::wg::pairing_tunnel(root) {
if requested.is_some() {
return Err(ControlError::FailedPrecondition(
"REFUSED [tunnel-owned-by-pairing] this box's tunnel address is the installer door's answer (<data>/pairing/); it is not handed over here".into(),
));
}
let address = held.map(|(a, p)| format!("{a}/{p}")).unwrap_or_default();
let detail = match (&held, tunnel_up) {
(None, _) => "the pairing loop owns this tunnel and the door has not assigned an address yet".to_string(),
(Some(_), true) => format!("wg0 is up at {address} (the pairing loop's tunnel; the front's relay lists {public_b64})"),
(Some(_), false) => format!("{address} is the pairing loop's address and wg0 is NOT up (the boot log says why)"),
};
return Ok(gunnar_proto::AssignTunnelAddressResponse { wg_public_key: public_b64, address, changed: false, tunnel_up, detail });
}
let held = assign_address(root, None).map_err(pre)?.address;
let parsed = match requested {
None => None,
Some(text) => Some(parse_address(text).map_err(|err| ControlError::InvalidArgument(format!("{err:#}")))?),
};
assignment_door(gate_enabled(), may_write, held, parsed).map_err(pre)?;
let got = assign_address(root, requested).map_err(pre)?;
let tunnel_up = if got.address.is_some() {
crate::wg::raise_after_assignment(root).map_err(pre)?
} else {
crate::wg::ceremony_tunnel_up()
};
let address = got.address.map(|(a, p)| format!("{a}/{p}")).unwrap_or_default();
let detail = match (&got.address, tunnel_up) {
(None, _) => format!("no tunnel address yet: hand one over with {METHOD_ASSIGN} (`gunnar control tunnel enrol`)"),
(Some(_), true) => format!(
"wg0 is up at {address}; register {} on the hub with AllowedIPs = {}/32",
got.public_b64,
address.split('/').next().unwrap_or_default()
),
(Some(_), false) => format!(
"{address} is recorded and wg0 is NOT up (no hub.conf yet, or the kernel refused — the boot log says which); it comes up at the next start"
),
};
Ok(gunnar_proto::AssignTunnelAddressResponse { wg_public_key: got.public_b64, address, changed: got.changed, tunnel_up, detail })
}
pub use gunnar_proto::{parse_tunnel_answer, render_tunnel_answer};
pub fn identity_pub_path(data_root: &Path) -> PathBuf {
wg_dir(data_root).join(ceremony::IDENTITY_PUB_FILE)
}
#[cfg(test)]
mod tests {
use super::*;
use wg_appliance_core::ceremony::{parse_identity_line, IDENTITY_KEY_FILE};
struct Scratch(PathBuf);
impl Scratch {
fn new(tag: &str) -> Scratch {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let dir = std::env::temp_dir().join(format!(
"gunnar-ceremony-{tag}-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
Scratch(dir)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn run_collecting(root: &Path) -> (Result<Ceremony>, Vec<String>) {
let mut lines = Vec::new();
let r = run(root, &mut |l| lines.push(l.to_string()));
(r, lines)
}
#[test]
fn the_line_gunnar_emits_is_the_canonical_one_and_carries_the_key_on_disk() {
let dir = Scratch::new("line");
let (out, lines) = run_collecting(dir.path());
let out = out.expect("an empty root is first boot");
let identity_lines: Vec<&String> = lines
.iter()
.filter(|l| parse_identity_line(l).is_some())
.collect();
assert_eq!(
identity_lines.len(),
1,
"exactly ONE canonical identity line, got {lines:?}"
);
let (product, pubkey) =
parse_identity_line(identity_lines[0]).expect("just filtered on it");
assert_eq!(product, "gunnar", "the line must name the product");
assert_eq!(
pubkey, out.public_b64,
"the printed key must be the key the ceremony returned"
);
let on_disk = std::fs::read_to_string(identity_pub_path(dir.path()))
.expect("identity.pub is written next to the key");
assert_eq!(
on_disk.trim(),
pubkey,
"identity.pub and the printed line must be the same key"
);
}
#[test]
fn a_corrupt_identity_refuses_and_does_not_mint_a_second_one() {
let dir = Scratch::new("refuse");
let (first, _) = run_collecting(dir.path());
first.expect("first boot mints");
let key_path = wg_dir(dir.path()).join(IDENTITY_KEY_FILE);
let minted_bytes = std::fs::read(&key_path).expect("the key file exists");
let corrupt = b"this is not a base64 x25519 key\n";
std::fs::write(&key_path, corrupt).expect("corrupt it");
let (second, lines) = run_collecting(dir.path());
let err = second.expect_err(
"a corrupt identity MUST refuse; minting a second one silently changes \
the machine's identity",
);
let text = format!("{err:#}");
assert!(
text.contains(&key_path.display().to_string()),
"the refusal must name the file the operator has to go look at: {text}"
);
assert!(
lines.is_empty(),
"a refused ceremony must emit NO identity line — a printed key an \
operator might register: {lines:?}"
);
assert_eq!(
std::fs::read(&key_path).expect("still there"),
corrupt,
"the corrupt bytes stay in place as the evidence; they must not be \
overwritten by a fresh mint"
);
assert_ne!(
minted_bytes, corrupt,
"the fixture is inert unless the corruption actually changed the file"
);
}
#[test]
fn the_identity_survives_a_reboot_and_the_second_boot_is_silent_about_minting() {
let dir = Scratch::new("survive");
let (first, first_lines) = run_collecting(dir.path());
let first = first.expect("first boot");
assert!(first.minted, "an empty root is first boot");
assert!(
first_lines.iter().any(|l| l.contains("MINTED")),
"the first boot must say MINTED: {first_lines:?}"
);
let (second, second_lines) = run_collecting(dir.path());
let second = second.expect("second boot");
assert_eq!(
second.public_b64, first.public_b64,
"the identity must survive — a re-mint stales the hub registration"
);
assert!(
!second_lines.iter().any(|l| l.contains("MINTED")),
"a MINTED line on a reboot is the appliance announcing it threw its \
identity away: {second_lines:?}"
);
}
#[test]
fn no_hub_yet_is_the_identity_only_ceremony_and_it_says_what_to_do() {
let dir = Scratch::new("nohub");
let (out, lines) = run_collecting(dir.path());
let out = out.expect("no hub is not an error");
assert!(out.config.is_none(), "there is no tunnel to raise yet");
let hub_line = lines
.iter()
.find(|l| l.contains("hub: none configured"))
.unwrap_or_else(|| panic!("the operator must be told the next move: {lines:?}"));
assert!(
hub_line.contains(ceremony::HUB_CONF_FILE),
"and told the exact filename to place: {hub_line}"
);
}
#[test]
fn a_declared_hub_becomes_gunnars_own_tunnel_config() {
let dir = Scratch::new("hub");
let (first, _) = run_collecting(dir.path());
let first = first.expect("first boot");
let wg = wg_dir(dir.path());
std::fs::write(
ApplianceEnv::hub_path(&wg),
"[Interface]\n\
Address = 10.99.0.7/24\n\
\n\
[Peer]\n\
PublicKey = 6yUuA2NvUdRuoHwFmS9OD1V8pFpXmDgVQjJ+YEptGm4=\n\
AllowedIPs = 10.99.0.0/24\n\
Endpoint = 10.0.2.2:51820\n\
PersistentKeepalive = 25\n",
)
.expect("write hub.conf");
let (second, _) = run_collecting(dir.path());
let cfg = second
.expect("a well-formed hub")
.config
.expect("a declared hub raises a tunnel");
assert_eq!(cfg.interface, "wg0");
assert_eq!(cfg.address.octets(), [10, 99, 0, 7], "the site's address");
assert_eq!(cfg.prefix, 24);
assert_eq!(cfg.listen_port, None, "the appliance only ever initiates");
assert_eq!(cfg.peers.len(), 1, "the ceremony declares exactly one hub");
let p = &cfg.peers[0];
assert_eq!(p.allowed_ips, vec![("10.99.0.0".parse().unwrap(), 24)]);
assert_eq!(
p.endpoint,
Some("10.0.2.2:51820".parse().unwrap()),
"the endpoint must survive the translation or the appliance dials nowhere"
);
assert_eq!(p.persistent_keepalive, Some(25));
assert_eq!(
p.preshared_key, None,
"the dialect refuses PresharedKey by name — a hub file is public data"
);
assert_eq!(
wg_appliance_core::encode_key(&cfg.public_key()),
first.public_b64,
"the tunnel must carry the identity the ceremony announced"
);
}
const SITE_ONLY_HUB: &str = "[Peer]\n\
PublicKey = 6yUuA2NvUdRuoHwFmS9OD1V8pFpXmDgVQjJ+YEptGm4=\n\
AllowedIPs = 10.99.0.0/24\n\
Endpoint = 10.0.2.2:51820\n\
PersistentKeepalive = 25\n";
#[test]
fn a_site_only_hub_waits_for_the_bootstrap_address_and_is_not_refused() {
let dir = Scratch::new("siteonly");
let (_, _) = run_collecting(dir.path());
std::fs::write(ApplianceEnv::hub_path(&wg_dir(dir.path())), SITE_ONLY_HUB).expect("write hub.conf");
let (out, lines) = run_collecting(dir.path());
let out = out.expect("a site-only hub.conf is the correct medium, not an error");
assert!(out.config.is_none(), "no address yet, so no tunnel yet");
assert!(
lines.iter().any(|l| l.contains("Bootstrap.AssignTunnelAddress")),
"the box must name the door its address arrives through: {lines:?}"
);
}
#[test]
fn two_boxes_from_one_medium_raise_the_address_each_was_handed() {
let (a, b) = (Scratch::new("box-a"), Scratch::new("box-b"));
let mut keys = Vec::new();
for (dir, addr) in [(&a, "10.99.0.2/24"), (&b, "10.99.0.3/24")] {
let (first, _) = run_collecting(dir.path());
keys.push(first.expect("first boot").public_b64);
std::fs::write(ApplianceEnv::hub_path(&wg_dir(dir.path())), SITE_ONLY_HUB).unwrap();
let got = assign_address(dir.path(), Some(addr)).expect("a fresh box takes its address");
assert!(got.changed);
assert_eq!(got.public_b64, *keys.last().unwrap(), "the door answers the key the hub must register");
}
let cfg = |d: &Scratch| run_collecting(d.path()).0.expect("boot").config.expect("an address raises the tunnel");
let (ca, cb) = (cfg(&a), cfg(&b));
assert_eq!((ca.address.octets(), ca.prefix), ([10, 99, 0, 2], 24));
assert_eq!((cb.address.octets(), cb.prefix), ([10, 99, 0, 3], 24));
assert_ne!(keys[0], keys[1], "each box minted its own key");
assert_eq!(ca.peers[0].endpoint, cb.peers[0].endpoint, "and both follow the one site the medium declared");
}
#[test]
fn the_same_address_is_idempotent_and_a_second_one_is_refused() {
let dir = Scratch::new("reassign");
run_collecting(dir.path()).0.expect("first boot");
assert!(assign_address(dir.path(), Some("10.99.0.2/24")).unwrap().changed);
let again = assign_address(dir.path(), Some("10.99.0.2/24")).unwrap();
assert!(!again.changed, "a retry writes nothing");
let err = format!("{:#}", assign_address(dir.path(), Some("10.99.0.3/24")).unwrap_err());
assert!(err.contains("never re-pointed") && err.contains("10.99.0.2/24"), "{err}");
let read = assign_address(dir.path(), None).unwrap();
assert_eq!(read.address, Some(("10.99.0.2".parse().unwrap(), 24)), "a read answers what is held");
}
#[test]
fn a_network_or_broadcast_address_and_a_box_with_no_identity_are_refused() {
let dir = Scratch::new("badaddr");
let err = format!("{:#}", assign_address(dir.path(), Some("10.99.0.2/24")).unwrap_err());
assert!(err.contains("identity.pub"), "no ceremony yet, no key to address: {err}");
run_collecting(dir.path()).0.expect("first boot");
for bad in ["10.99.0.0/24", "10.99.0.255/24", "10.99.0.2", "10.99.0.2/33", "fe80::1/64"] {
assert!(assign_address(dir.path(), Some(bad)).is_err(), "{bad} must be refused");
}
assert!(!wg_dir(dir.path()).join(ADDRESS_FILE).exists(), "no refusal writes anything");
}
#[test]
fn an_older_mediums_address_is_read_and_a_disagreeing_one_is_refused() {
let dir = Scratch::new("legacy");
run_collecting(dir.path()).0.expect("first boot");
let wg = wg_dir(dir.path());
std::fs::write(ApplianceEnv::hub_path(&wg), format!("[Interface]\nAddress = 10.99.0.7/24\n\n{SITE_ONLY_HUB}")).unwrap();
let (out, lines) = run_collecting(dir.path());
assert_eq!(out.unwrap().config.expect("legacy medium").address.octets(), [10, 99, 0, 7]);
assert!(lines.iter().any(|l| l.contains("from the MEDIUM")), "{lines:?}");
assert!(assign_address(dir.path(), Some("10.99.0.8/24")).is_err(), "the medium's address is held");
std::fs::write(wg.join(ADDRESS_FILE), "10.99.0.8/24\n").unwrap();
let err = format!("{:#}", run_collecting(dir.path()).0.unwrap_err());
assert!(err.contains("refuses"), "{err}");
}
#[test]
fn the_door_writes_only_while_fresh_or_the_same_address() {
let a = Some(("10.99.0.2".parse().unwrap(), 24));
let b = Some(("10.99.0.3".parse().unwrap(), 24));
assert!(assignment_door(false, true, None, a).is_err(), "no ceremony, no door");
assert!(assignment_door(true, false, a, None).is_ok(), "a read is always answered");
assert!(assignment_door(true, true, None, a).is_ok(), "a fresh box takes one");
assert!(assignment_door(true, false, a, a).is_ok(), "a retry of the held address");
assert!(assignment_door(true, false, None, a).is_err(), "an enrolled box does not take one unauthenticated");
assert!(assignment_door(true, false, a, b).is_err());
}
#[test]
fn the_ssh_doors_answer_round_trips_and_a_refusal_is_an_error() {
let a = gunnar_proto::AssignTunnelAddressResponse {
wg_public_key: "6yUuA2NvUdRuoHwFmS9OD1V8pFpXmDgVQjJ+YEptGm4=".into(),
address: "10.77.0.2/16".into(),
changed: true,
tunnel_up: false,
detail: "two\nlines".into(),
};
let back = parse_tunnel_answer(&render_tunnel_answer(&a)).unwrap();
assert_eq!((back.wg_public_key.as_str(), back.address.as_str(), back.changed, back.tunnel_up), (a.wg_public_key.as_str(), "10.77.0.2/16", true, false));
assert_eq!(back.detail, "two lines");
let none = gunnar_proto::AssignTunnelAddressResponse { wg_public_key: "k".into(), ..Default::default() };
assert_eq!(parse_tunnel_answer(&render_tunnel_answer(&none)).unwrap().address, "");
assert_eq!(parse_tunnel_answer("error: never re-pointed\n").unwrap_err(), "never re-pointed");
}
#[test]
fn the_data_env_is_its_own_and_is_not_gunnars_store_root() {
assert_ne!(DATA_ENV, "GUNNAR_DATA_ROOT");
assert_eq!(
wg_dir(Path::new("/srv/gunnar")),
Path::new("/srv/gunnar/wg"),
"with no override the ceremony lives under the root serve was given"
);
}
}