1use std::path::{Path, PathBuf};
88
89use anyhow::{bail, Context, Result};
90use wg_appliance_core::appliance::ApplianceEnv;
91use wg_appliance_core::ceremony::{self, HubPeer, Identity};
92
93use crate::wg::{Peer, WgConfig, CONFIG_FILE};
94
95pub const PRODUCT: &str = "gunnar";
97pub const GATE_ENV: &str = "GUNNAR_APPLIANCE";
99pub const DATA_ENV: &str = "GUNNAR_APPLIANCE_DATA";
107pub const UNDERLAY_ENV: &str = "GUNNAR_UNDERLAY";
113pub const DEFAULT_ROOT: &str = "/data";
116
117pub const ENV: ApplianceEnv = ApplianceEnv {
120 product: PRODUCT,
121 gate_env: GATE_ENV,
122 data_env: DATA_ENV,
123 underlay_env: UNDERLAY_ENV,
124 default_root: DEFAULT_ROOT,
125};
126
127pub fn gate_enabled() -> bool {
131 ENV.gate_enabled()
132}
133
134pub fn wg_dir(data_root: &Path) -> PathBuf {
137 ApplianceEnv::wg_dir(&ENV.data_root_or(data_root))
138}
139
140pub struct Ceremony {
142 pub public_b64: String,
145 pub minted: bool,
147 pub config: Option<WgConfig>,
151}
152
153impl std::fmt::Debug for Ceremony {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 f.debug_struct("Ceremony")
163 .field("public_b64", &self.public_b64)
164 .field("minted", &self.minted)
165 .field("hub_declared", &self.config.is_some())
166 .finish()
167 }
168}
169
170pub fn run(data_root: &Path, out: &mut dyn FnMut(&str)) -> Result<Ceremony> {
190 let root = ENV.data_root_or(data_root);
191 let wg = ApplianceEnv::wg_dir(&root);
192
193 ENV.apply_underlay(out).map_err(|e| anyhow::anyhow!(e))?;
197
198 let identity: Identity = ENV
199 .announce_identity(&wg, out)
200 .with_context(|| format!("the appliance identity ceremony at {}", wg.display()))?;
201
202 let hub: Option<HubPeer> = ENV
203 .read_hub(&wg, out)
204 .with_context(|| format!("the hub declaration in {}", wg.display()))?;
205
206 let config = match hub {
207 None => None,
208 Some(hub) => config_from_ceremony(&identity, &hub, &wg, out)?,
209 };
210 Ok(Ceremony {
211 public_b64: identity.public_b64,
212 minted: identity.minted,
213 config,
214 })
215}
216
217fn config_from_ceremony(
225 identity: &Identity,
226 hub: &HubPeer,
227 wg: &Path,
228 out: &mut dyn FnMut(&str),
229) -> Result<Option<WgConfig>> {
230 let assigned = assigned_address(wg)?;
231 let (address, prefix) = match (assigned, hub.appliance_address) {
232 (Some(a), Some(m)) if a != m => bail!(
233 "{} says this box's tunnel address is {}/{} and {} (handed over by \
234 {METHOD_ASSIGN}) says {}/{}. Two answers to one per-box question, and this refuses \
235 to rank them: the hub routes by one of them. Remove `[Interface]` from the medium's \
236 hub.conf — the medium carries the SITE only — or remove {} if the medium is right",
237 ApplianceEnv::hub_path(wg).display(),
238 m.0,
239 m.1,
240 wg.join(ADDRESS_FILE).display(),
241 a.0,
242 a.1,
243 wg.join(ADDRESS_FILE).display(),
244 ),
245 (Some(a), _) => a,
246 (None, Some(m)) => {
247 out(&format!(
251 "{PRODUCT}-appliance wg: the tunnel address {}/{} comes from the MEDIUM ({}). An address \
252 on a medium is the same address on every box installed from it; a new medium carries \
253 the site only, and each box gets its own through {METHOD_ASSIGN}",
254 m.0,
255 m.1,
256 ApplianceEnv::hub_path(wg).display()
257 ));
258 m
259 }
260 (None, None) => {
261 out(&format!(
262 "{PRODUCT}-appliance wg: hub declared, NO tunnel address yet — this box's address is handed \
263 over at bootstrap by {METHOD_ASSIGN} (`gunnar control tunnel enrol`), never baked into \
264 the medium. Serving on the outer address until then; the tunnel comes up the moment the \
265 address arrives, no restart"
266 ));
267 return Ok(None);
268 }
269 };
270 let spec = hub.peer_spec();
271 let interface = Path::new(CONFIG_FILE)
275 .file_stem()
276 .and_then(|s| s.to_str())
277 .expect("CONFIG_FILE is a literal with a stem");
278 Ok(Some(WgConfig {
279 interface: interface.to_string(),
280 private_key: identity.private,
281 address,
282 prefix,
283 listen_port: None,
287 peers: vec![Peer {
288 public_key: spec.public_key,
289 preshared_key: None,
293 allowed_ips: spec.allowed_ips,
294 endpoint: spec.endpoint,
295 persistent_keepalive: spec.persistent_keepalive,
296 }],
297 }))
298}
299
300pub const METHOD_ASSIGN: &str = "Bootstrap.AssignTunnelAddress";
303
304pub const ADDRESS_FILE: &str = "address";
311
312pub fn parse_address(text: &str) -> Result<(std::net::Ipv4Addr, u8)> {
315 parse_cidr(text)
316}
317
318fn parse_cidr(text: &str) -> Result<(std::net::Ipv4Addr, u8)> {
319 let (a, p) = text
320 .trim()
321 .split_once('/')
322 .with_context(|| format!("{text:?} is not `a.b.c.d/prefix`"))?;
323 let addr: std::net::Ipv4Addr = a.trim().parse().with_context(|| format!("{a:?} is not an IPv4 address"))?;
324 let prefix: u8 = p
325 .trim()
326 .parse()
327 .ok()
328 .filter(|p| (1..=32).contains(p))
329 .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)"))?;
330 if prefix < 31 {
331 let mask = u32::MAX << (32 - prefix);
332 let host = u32::from(addr) & !mask;
333 anyhow::ensure!(host != 0 && host != !mask, "{addr}/{prefix} is the network or broadcast address of its subnet, not a host");
334 }
335 Ok((addr, prefix))
336}
337
338pub fn assigned_address(wg: &Path) -> Result<Option<(std::net::Ipv4Addr, u8)>> {
343 let path = wg.join(ADDRESS_FILE);
344 match std::fs::read_to_string(&path) {
345 Ok(text) => parse_cidr(&text).map(Some).with_context(|| format!("{}", path.display())),
346 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
347 Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
348 }
349}
350
351#[derive(Debug, Clone, PartialEq, Eq)]
353pub struct Assignment {
354 pub public_b64: String,
356 pub address: Option<(std::net::Ipv4Addr, u8)>,
358 pub changed: bool,
360}
361
362pub fn assignment_door(
367 gate: bool,
368 window_open: bool,
369 held: Option<(std::net::Ipv4Addr, u8)>,
370 requested: Option<(std::net::Ipv4Addr, u8)>,
371) -> Result<()> {
372 anyhow::ensure!(
373 gate,
374 "this box runs no appliance ceremony ({GATE_ENV} is not 1), so it has no minted WireGuard identity \
375 to give an address to — a dev box takes its tunnel from wg0.conf"
376 );
377 match requested {
378 None => Ok(()),
379 Some(r) if held == Some(r) => Ok(()),
380 Some(_) if window_open => Ok(()),
381 Some((a, p)) => bail!(
382 "{a}/{p} refused: this box is past its bootstrap (an identity is enrolled), and {METHOD_ASSIGN} \
383 is unauthenticated, so it writes only while the box is fresh or repeats the address it already holds{}. \
384 A gunnar-admin sets it with Admin.SetTunnelAddress (`gunnar control tunnel enrol -i <admin key>`)",
385 match held {
386 Some((h, hp)) => format!(" ({h}/{hp})"),
387 None => " (none)".to_string(),
388 }
389 ),
390 }
391}
392
393pub fn assign_address(data_root: &Path, requested: Option<&str>) -> Result<Assignment> {
402 let wg = wg_dir(data_root);
403 let pub_path = wg.join(ceremony::IDENTITY_PUB_FILE);
404 let public_b64 = std::fs::read_to_string(&pub_path)
405 .with_context(|| format!("{} — the ceremony has not run on this box, so there is no key to address", pub_path.display()))?
406 .trim()
407 .to_string();
408 let medium = match std::fs::metadata(ApplianceEnv::hub_path(&wg)) {
409 Ok(_) => ceremony::read_hub_config(&ApplianceEnv::hub_path(&wg))
410 .map_err(|e| anyhow::anyhow!("{e}"))?
411 .appliance_address,
412 Err(_) => None,
413 };
414 let assigned = assigned_address(&wg)?;
415 let held = assigned.or(medium);
416 let Some(text) = requested.map(str::trim).filter(|t| !t.is_empty()) else {
417 return Ok(Assignment { public_b64, address: held, changed: false });
418 };
419 let want = parse_cidr(text)?;
420 if let Some(have) = held {
421 anyhow::ensure!(
422 have == want,
423 "this box already holds the tunnel address {}/{}, and a tunnel address is never re-pointed — the hub \
424 routes {}/32 to this key. {}/{} refused",
425 have.0,
426 have.1,
427 have.0,
428 want.0,
429 want.1
430 );
431 if assigned.is_some() {
432 return Ok(Assignment { public_b64, address: held, changed: false });
433 }
434 }
435 std::fs::create_dir_all(&wg).with_context(|| format!("creating {}", wg.display()))?;
436 let path = wg.join(ADDRESS_FILE);
437 let tmp = wg.join("address.new");
438 {
439 use std::io::Write as _;
440 let mut f = std::fs::File::create(&tmp).with_context(|| format!("writing {}", tmp.display()))?;
441 writeln!(f, "{}/{}", want.0, want.1)?;
442 f.sync_all()?;
443 }
444 std::fs::rename(&tmp, &path).with_context(|| format!("renaming into {}", path.display()))?;
445 if let Ok(d) = std::fs::File::open(&wg) {
446 let _ = d.sync_all();
447 }
448 Ok(Assignment { public_b64, address: Some(want), changed: true })
449}
450
451pub fn tunnel_address_answer(
455 root: &Path,
456 address: &str,
457 may_write: bool,
458) -> gunnar_grpc::ControlResult<gunnar_proto::AssignTunnelAddressResponse> {
459 use gunnar_grpc::ControlError;
460 let pre = |err: anyhow::Error| ControlError::FailedPrecondition(format!("{err:#}"));
461 let requested = if address.is_empty() { None } else { Some(address) };
462 if let Some((public_b64, held, tunnel_up)) = crate::wg::pairing_tunnel(root) {
467 if requested.is_some() {
468 return Err(ControlError::FailedPrecondition(
469 "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(),
470 ));
471 }
472 let address = held.map(|(a, p)| format!("{a}/{p}")).unwrap_or_default();
473 let detail = match (&held, tunnel_up) {
474 (None, _) => "the pairing loop owns this tunnel and the door has not assigned an address yet".to_string(),
475 (Some(_), true) => format!("wg0 is up at {address} (the pairing loop's tunnel; the front's relay lists {public_b64})"),
476 (Some(_), false) => format!("{address} is the pairing loop's address and wg0 is NOT up (the boot log says why)"),
477 };
478 return Ok(gunnar_proto::AssignTunnelAddressResponse { wg_public_key: public_b64, address, changed: false, tunnel_up, detail });
479 }
480 let held = assign_address(root, None).map_err(pre)?.address;
481 let parsed = match requested {
482 None => None,
483 Some(text) => Some(parse_address(text).map_err(|err| ControlError::InvalidArgument(format!("{err:#}")))?),
484 };
485 assignment_door(gate_enabled(), may_write, held, parsed).map_err(pre)?;
486 let got = assign_address(root, requested).map_err(pre)?;
487 let tunnel_up = if got.address.is_some() {
488 crate::wg::raise_after_assignment(root).map_err(pre)?
489 } else {
490 crate::wg::ceremony_tunnel_up()
491 };
492 let address = got.address.map(|(a, p)| format!("{a}/{p}")).unwrap_or_default();
493 let detail = match (&got.address, tunnel_up) {
494 (None, _) => format!("no tunnel address yet: hand one over with {METHOD_ASSIGN} (`gunnar control tunnel enrol`)"),
495 (Some(_), true) => format!(
496 "wg0 is up at {address}; register {} on the hub with AllowedIPs = {}/32",
497 got.public_b64,
498 address.split('/').next().unwrap_or_default()
499 ),
500 (Some(_), false) => format!(
501 "{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"
502 ),
503 };
504 Ok(gunnar_proto::AssignTunnelAddressResponse { wg_public_key: got.public_b64, address, changed: got.changed, tunnel_up, detail })
505}
506
507pub use gunnar_proto::{parse_tunnel_answer, render_tunnel_answer};
511
512pub fn identity_pub_path(data_root: &Path) -> PathBuf {
515 wg_dir(data_root).join(ceremony::IDENTITY_PUB_FILE)
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use wg_appliance_core::ceremony::{parse_identity_line, IDENTITY_KEY_FILE};
522
523 struct Scratch(PathBuf);
525 impl Scratch {
526 fn new(tag: &str) -> Scratch {
527 use std::sync::atomic::{AtomicU32, Ordering};
528 static N: AtomicU32 = AtomicU32::new(0);
529 let dir = std::env::temp_dir().join(format!(
530 "gunnar-ceremony-{tag}-{}-{}",
531 std::process::id(),
532 N.fetch_add(1, Ordering::Relaxed)
533 ));
534 std::fs::create_dir_all(&dir).unwrap();
535 Scratch(dir)
536 }
537 fn path(&self) -> &Path {
538 &self.0
539 }
540 }
541 impl Drop for Scratch {
542 fn drop(&mut self) {
543 let _ = std::fs::remove_dir_all(&self.0);
544 }
545 }
546
547 fn run_collecting(root: &Path) -> (Result<Ceremony>, Vec<String>) {
552 let mut lines = Vec::new();
553 let r = run(root, &mut |l| lines.push(l.to_string()));
554 (r, lines)
555 }
556
557 #[test]
564 fn the_line_gunnar_emits_is_the_canonical_one_and_carries_the_key_on_disk() {
565 let dir = Scratch::new("line");
566 let (out, lines) = run_collecting(dir.path());
567 let out = out.expect("an empty root is first boot");
568
569 let identity_lines: Vec<&String> = lines
570 .iter()
571 .filter(|l| parse_identity_line(l).is_some())
572 .collect();
573 assert_eq!(
574 identity_lines.len(),
575 1,
576 "exactly ONE canonical identity line, got {lines:?}"
577 );
578 let (product, pubkey) =
579 parse_identity_line(identity_lines[0]).expect("just filtered on it");
580 assert_eq!(product, "gunnar", "the line must name the product");
581 assert_eq!(
582 pubkey, out.public_b64,
583 "the printed key must be the key the ceremony returned"
584 );
585
586 let on_disk = std::fs::read_to_string(identity_pub_path(dir.path()))
590 .expect("identity.pub is written next to the key");
591 assert_eq!(
592 on_disk.trim(),
593 pubkey,
594 "identity.pub and the printed line must be the same key"
595 );
596 }
597
598 #[test]
609 fn a_corrupt_identity_refuses_and_does_not_mint_a_second_one() {
610 let dir = Scratch::new("refuse");
611 let (first, _) = run_collecting(dir.path());
612 first.expect("first boot mints");
613 let key_path = wg_dir(dir.path()).join(IDENTITY_KEY_FILE);
614 let minted_bytes = std::fs::read(&key_path).expect("the key file exists");
615
616 let corrupt = b"this is not a base64 x25519 key\n";
617 std::fs::write(&key_path, corrupt).expect("corrupt it");
618
619 let (second, lines) = run_collecting(dir.path());
620 let err = second.expect_err(
621 "a corrupt identity MUST refuse; minting a second one silently changes \
622 the machine's identity",
623 );
624 let text = format!("{err:#}");
625 assert!(
626 text.contains(&key_path.display().to_string()),
627 "the refusal must name the file the operator has to go look at: {text}"
628 );
629 assert!(
630 lines.is_empty(),
631 "a refused ceremony must emit NO identity line — a printed key an \
632 operator might register: {lines:?}"
633 );
634 assert_eq!(
635 std::fs::read(&key_path).expect("still there"),
636 corrupt,
637 "the corrupt bytes stay in place as the evidence; they must not be \
638 overwritten by a fresh mint"
639 );
640 assert_ne!(
641 minted_bytes, corrupt,
642 "the fixture is inert unless the corruption actually changed the file"
643 );
644 }
645
646 #[test]
649 fn the_identity_survives_a_reboot_and_the_second_boot_is_silent_about_minting() {
650 let dir = Scratch::new("survive");
651 let (first, first_lines) = run_collecting(dir.path());
652 let first = first.expect("first boot");
653 assert!(first.minted, "an empty root is first boot");
654 assert!(
655 first_lines.iter().any(|l| l.contains("MINTED")),
656 "the first boot must say MINTED: {first_lines:?}"
657 );
658
659 let (second, second_lines) = run_collecting(dir.path());
660 let second = second.expect("second boot");
661 assert_eq!(
662 second.public_b64, first.public_b64,
663 "the identity must survive — a re-mint stales the hub registration"
664 );
665 assert!(
666 !second_lines.iter().any(|l| l.contains("MINTED")),
667 "a MINTED line on a reboot is the appliance announcing it threw its \
668 identity away: {second_lines:?}"
669 );
670 }
671
672 #[test]
675 fn no_hub_yet_is_the_identity_only_ceremony_and_it_says_what_to_do() {
676 let dir = Scratch::new("nohub");
677 let (out, lines) = run_collecting(dir.path());
678 let out = out.expect("no hub is not an error");
679 assert!(out.config.is_none(), "there is no tunnel to raise yet");
680 let hub_line = lines
681 .iter()
682 .find(|l| l.contains("hub: none configured"))
683 .unwrap_or_else(|| panic!("the operator must be told the next move: {lines:?}"));
684 assert!(
685 hub_line.contains(ceremony::HUB_CONF_FILE),
686 "and told the exact filename to place: {hub_line}"
687 );
688 }
689
690 #[test]
695 fn a_declared_hub_becomes_gunnars_own_tunnel_config() {
696 let dir = Scratch::new("hub");
697 let (first, _) = run_collecting(dir.path());
699 let first = first.expect("first boot");
700 let wg = wg_dir(dir.path());
701 std::fs::write(
702 ApplianceEnv::hub_path(&wg),
703 "[Interface]\n\
704 Address = 10.99.0.7/24\n\
705 \n\
706 [Peer]\n\
707 PublicKey = 6yUuA2NvUdRuoHwFmS9OD1V8pFpXmDgVQjJ+YEptGm4=\n\
708 AllowedIPs = 10.99.0.0/24\n\
709 Endpoint = 10.0.2.2:51820\n\
710 PersistentKeepalive = 25\n",
711 )
712 .expect("write hub.conf");
713
714 let (second, _) = run_collecting(dir.path());
715 let cfg = second
716 .expect("a well-formed hub")
717 .config
718 .expect("a declared hub raises a tunnel");
719
720 assert_eq!(cfg.interface, "wg0");
721 assert_eq!(cfg.address.octets(), [10, 99, 0, 7], "the site's address");
722 assert_eq!(cfg.prefix, 24);
723 assert_eq!(cfg.listen_port, None, "the appliance only ever initiates");
724 assert_eq!(cfg.peers.len(), 1, "the ceremony declares exactly one hub");
725 let p = &cfg.peers[0];
726 assert_eq!(p.allowed_ips, vec![("10.99.0.0".parse().unwrap(), 24)]);
727 assert_eq!(
728 p.endpoint,
729 Some("10.0.2.2:51820".parse().unwrap()),
730 "the endpoint must survive the translation or the appliance dials nowhere"
731 );
732 assert_eq!(p.persistent_keepalive, Some(25));
733 assert_eq!(
734 p.preshared_key, None,
735 "the dialect refuses PresharedKey by name — a hub file is public data"
736 );
737 assert_eq!(
740 wg_appliance_core::encode_key(&cfg.public_key()),
741 first.public_b64,
742 "the tunnel must carry the identity the ceremony announced"
743 );
744 }
745
746 const SITE_ONLY_HUB: &str = "[Peer]\n\
748 PublicKey = 6yUuA2NvUdRuoHwFmS9OD1V8pFpXmDgVQjJ+YEptGm4=\n\
749 AllowedIPs = 10.99.0.0/24\n\
750 Endpoint = 10.0.2.2:51820\n\
751 PersistentKeepalive = 25\n";
752
753 #[test]
764 fn a_site_only_hub_waits_for_the_bootstrap_address_and_is_not_refused() {
765 let dir = Scratch::new("siteonly");
766 let (_, _) = run_collecting(dir.path());
767 std::fs::write(ApplianceEnv::hub_path(&wg_dir(dir.path())), SITE_ONLY_HUB).expect("write hub.conf");
768
769 let (out, lines) = run_collecting(dir.path());
770 let out = out.expect("a site-only hub.conf is the correct medium, not an error");
771 assert!(out.config.is_none(), "no address yet, so no tunnel yet");
772 assert!(
773 lines.iter().any(|l| l.contains("Bootstrap.AssignTunnelAddress")),
774 "the box must name the door its address arrives through: {lines:?}"
775 );
776 }
777
778 #[test]
783 fn two_boxes_from_one_medium_raise_the_address_each_was_handed() {
784 let (a, b) = (Scratch::new("box-a"), Scratch::new("box-b"));
785 let mut keys = Vec::new();
786 for (dir, addr) in [(&a, "10.99.0.2/24"), (&b, "10.99.0.3/24")] {
787 let (first, _) = run_collecting(dir.path());
788 keys.push(first.expect("first boot").public_b64);
789 std::fs::write(ApplianceEnv::hub_path(&wg_dir(dir.path())), SITE_ONLY_HUB).unwrap();
790 let got = assign_address(dir.path(), Some(addr)).expect("a fresh box takes its address");
791 assert!(got.changed);
792 assert_eq!(got.public_b64, *keys.last().unwrap(), "the door answers the key the hub must register");
793 }
794 let cfg = |d: &Scratch| run_collecting(d.path()).0.expect("boot").config.expect("an address raises the tunnel");
795 let (ca, cb) = (cfg(&a), cfg(&b));
796 assert_eq!((ca.address.octets(), ca.prefix), ([10, 99, 0, 2], 24));
797 assert_eq!((cb.address.octets(), cb.prefix), ([10, 99, 0, 3], 24));
798 assert_ne!(keys[0], keys[1], "each box minted its own key");
799 assert_eq!(ca.peers[0].endpoint, cb.peers[0].endpoint, "and both follow the one site the medium declared");
800 }
801
802 #[test]
805 fn the_same_address_is_idempotent_and_a_second_one_is_refused() {
806 let dir = Scratch::new("reassign");
807 run_collecting(dir.path()).0.expect("first boot");
808 assert!(assign_address(dir.path(), Some("10.99.0.2/24")).unwrap().changed);
809 let again = assign_address(dir.path(), Some("10.99.0.2/24")).unwrap();
810 assert!(!again.changed, "a retry writes nothing");
811 let err = format!("{:#}", assign_address(dir.path(), Some("10.99.0.3/24")).unwrap_err());
812 assert!(err.contains("never re-pointed") && err.contains("10.99.0.2/24"), "{err}");
813 let read = assign_address(dir.path(), None).unwrap();
814 assert_eq!(read.address, Some(("10.99.0.2".parse().unwrap(), 24)), "a read answers what is held");
815 }
816
817 #[test]
818 fn a_network_or_broadcast_address_and_a_box_with_no_identity_are_refused() {
819 let dir = Scratch::new("badaddr");
820 let err = format!("{:#}", assign_address(dir.path(), Some("10.99.0.2/24")).unwrap_err());
821 assert!(err.contains("identity.pub"), "no ceremony yet, no key to address: {err}");
822 run_collecting(dir.path()).0.expect("first boot");
823 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"] {
824 assert!(assign_address(dir.path(), Some(bad)).is_err(), "{bad} must be refused");
825 }
826 assert!(!wg_dir(dir.path()).join(ADDRESS_FILE).exists(), "no refusal writes anything");
827 }
828
829 #[test]
832 fn an_older_mediums_address_is_read_and_a_disagreeing_one_is_refused() {
833 let dir = Scratch::new("legacy");
834 run_collecting(dir.path()).0.expect("first boot");
835 let wg = wg_dir(dir.path());
836 std::fs::write(ApplianceEnv::hub_path(&wg), format!("[Interface]\nAddress = 10.99.0.7/24\n\n{SITE_ONLY_HUB}")).unwrap();
837 let (out, lines) = run_collecting(dir.path());
838 assert_eq!(out.unwrap().config.expect("legacy medium").address.octets(), [10, 99, 0, 7]);
839 assert!(lines.iter().any(|l| l.contains("from the MEDIUM")), "{lines:?}");
840 assert!(assign_address(dir.path(), Some("10.99.0.8/24")).is_err(), "the medium's address is held");
841 std::fs::write(wg.join(ADDRESS_FILE), "10.99.0.8/24\n").unwrap();
842 let err = format!("{:#}", run_collecting(dir.path()).0.unwrap_err());
843 assert!(err.contains("refuses"), "{err}");
844 }
845
846 #[test]
847 fn the_door_writes_only_while_fresh_or_the_same_address() {
848 let a = Some(("10.99.0.2".parse().unwrap(), 24));
849 let b = Some(("10.99.0.3".parse().unwrap(), 24));
850 assert!(assignment_door(false, true, None, a).is_err(), "no ceremony, no door");
851 assert!(assignment_door(true, false, a, None).is_ok(), "a read is always answered");
852 assert!(assignment_door(true, true, None, a).is_ok(), "a fresh box takes one");
853 assert!(assignment_door(true, false, a, a).is_ok(), "a retry of the held address");
854 assert!(assignment_door(true, false, None, a).is_err(), "an enrolled box does not take one unauthenticated");
855 assert!(assignment_door(true, false, a, b).is_err());
856 }
857
858 #[test]
859 fn the_ssh_doors_answer_round_trips_and_a_refusal_is_an_error() {
860 let a = gunnar_proto::AssignTunnelAddressResponse {
861 wg_public_key: "6yUuA2NvUdRuoHwFmS9OD1V8pFpXmDgVQjJ+YEptGm4=".into(),
862 address: "10.77.0.2/16".into(),
863 changed: true,
864 tunnel_up: false,
865 detail: "two\nlines".into(),
866 };
867 let back = parse_tunnel_answer(&render_tunnel_answer(&a)).unwrap();
868 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));
869 assert_eq!(back.detail, "two lines");
870 let none = gunnar_proto::AssignTunnelAddressResponse { wg_public_key: "k".into(), ..Default::default() };
871 assert_eq!(parse_tunnel_answer(&render_tunnel_answer(&none)).unwrap().address, "");
872 assert_eq!(parse_tunnel_answer("error: never re-pointed\n").unwrap_err(), "never re-pointed");
873 }
874
875 #[test]
879 fn the_data_env_is_its_own_and_is_not_gunnars_store_root() {
880 assert_ne!(DATA_ENV, "GUNNAR_DATA_ROOT");
881 assert_eq!(
882 wg_dir(Path::new("/srv/gunnar")),
883 Path::new("/srv/gunnar/wg"),
884 "with no override the ceremony lives under the root serve was given"
885 );
886 }
887}