gunnar-server 2.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
Documentation
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
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
//! **The appliance identity ceremony, gunnar-side** — a thin caller of edda's
//! `wg_appliance_core::appliance` + `::ceremony`, the ONE implementation holger,
//! gunnar and korp share. Nothing ceremony-shaped is implemented here (one implementation).
//!
//! # What gunnar was missing, precisely
//!
//! gunnar had a WireGuard tunnel before this module and it was **seeded**:
//! `wg0.conf` (or `GUNNAR_WG` + `wg0.key`) had to arrive with a private key
//! already in it, so the appliance's identity was something an operator MADE
//! and shipped rather than something the machine MINTED and announced. Two
//! consequences, both measured rather than argued:
//!
//! * The ISO ships `assets/appliance-root/data/` holding a `.keep` and nothing
//!   else, deliberately — a committed private key is a key everybody has — so a
//!   RELEASED gunnar ISO had no identity and no tunnel. The capability existed
//!   only for an operator who rebuilt the image with their own keys in it.
//! * gunnar emitted no canonical identity line. `load_or_mint_identity`
//!   references, measured 2026-08-14: holger 2, korp 2, **gunnar 0**.
//!
//! The reason gunnar seeded is gone. It seeded because `/data` on the live ISO
//! is RAM — a minted key would not survive the reboot, which is worse than no
//! key, because the operator registers the printed key with the hub and the next
//! boot then presents a different one. On INSTALLED metal that is no longer
//! true: `edda/crates/korp-installer/src/esp.rs` `format_data_partition` writes
//! a real FAT32 filesystem on P3 at install time, and gunnar takes its data root
//! as an argument rather than hardcoding `/data`, so it inherits the same
//! durable path korp and holger already mint onto.
//!
//! # The gate lives in the declaration, not in folklore
//!
//! The ceremony runs iff env [`GATE_ENV`]`=1`. `.nornir/build-variants.toml`'s
//! `[[build.installer]]` cell declares it as a boot parameter, riding the same
//! channel `GUNNAR_DATA_ROOT` and `GUNNAR_NET` already ride (Linux turns an
//! unrecognised `key=value` boot parameter into an environment variable for
//! init — `init/main.c`, `unknown_bootoption`). So "this binary is an appliance"
//! is a property of the packed artifact, never of a hand-typed flag: a plain
//! `gunnar serve` on a dev box mints nothing, writes nothing and prints nothing.
//!
//! # Fixed paths, fixed forever
//!
//! `<root>/wg/hub.conf`, `<root>/wg/identity.key`, `<root>/wg/identity.pub` —
//! the SAME names on P3, in a container volume, and under holger and korp,
//! because the filenames are constants in the shared crate and no product spells
//! them itself. `<root>` is the data root `serve` was given, overridable by
//! [`DATA_ENV`] (the test seam).
//!
//! # The seeded path is now an explicit OVERRIDE
//!
//! [`crate::wg::WgConfig::resolve`] — `wg0.conf` and `GUNNAR_WG` — is untouched
//! and still works, and is what a gate-OFF gunnar uses. It is an override
//! because it supplies the private key from outside, which is precisely what the
//! ceremony exists to stop doing.
//!
//! **Gate ON plus a seeded config present is REFUSED**, not resolved by
//! precedence. That is the same ruling `WgConfig::resolve` already makes when
//! both of ITS two sources are present, and for the same reason: two
//! configurations that disagree is the failure a precedence rule hides. The
//! operator who edited the losing one would see a working tunnel with the wrong
//! key and no way to tell from the console — and here the losing field is the
//! machine's IDENTITY, so the hub would be trusting a key nobody registered.
//!
//! # gunnar's ONE documented divergence: `[Interface] Address` is REQUIRED
//!
//! The shared dialect makes `Address` optional, and holger and korp accept a
//! hub with none — they get a transport-only tunnel that seals packets but
//! carries no IP. For gunnar that is not a degraded tunnel, it is **no product
//! at all**: this whole lane exists to give the machine "a stable address that
//! outlives the DHCP lease" (`crate::wg`'s first line), and gunnar's listeners
//! reach the tunnel by binding `0.0.0.0` and waiting for `wg0` to have an
//! address on it. A gunnar tunnel with no address is one nothing answers on.
//!
//! # …and it arrives at BOOTSTRAP, never on the medium (2026-09-21, T17)
//!
//! `Address` is a per-BOX value, and under the one-medium ruling one ISO
//! installs the primary and its twin: an address in the medium's `hub.conf` was
//! the same tunnel address on both. So the medium carries the SITE only (hub
//! key, endpoint, AllowedIPs, keepalive), and the box's own address is handed
//! over by [`METHOD_ASSIGN`] and kept in `<root>/wg/`[`ADDRESS_FILE`]. A hub with
//! no address anywhere is not refused: the box boots, announces its key, raises
//! no tunnel and names the door — the tunnel comes up when the address lands.
//! An address on an OLDER medium is still read (a box already installed keeps
//! its tunnel), said out loud every boot; the two disagreeing is refused.
//!
//! The key half was already right: the box mints it at its first start (the
//! shared ceremony, or korp-installer's `wgseal::mint_and_seal`), per box.

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

/// The product name in the canonical identity line.
pub const PRODUCT: &str = "gunnar";
/// The gate: the ceremony runs iff this env is exactly `1`.
pub const GATE_ENV: &str = "GUNNAR_APPLIANCE";
/// Overrides the durable root — the test seam, and the escape hatch for a site
/// whose identity partition is not the store root.
///
/// Deliberately NOT `GUNNAR_DATA_ROOT`: that is the store's root, a thing an
/// operator may legitimately move, and overloading one env with both meanings
/// would let a storage decision silently relocate the identity — which re-mints
/// it, which stales the hub registration the operator already made.
pub const DATA_ENV: &str = "GUNNAR_APPLIANCE_DATA";
/// The optional per-site underlay address, `<a.b.c.d>/<prefix>@<ifname>`.
///
/// Rarely needed on gunnar, which already has `GUNNAR_NET` and its own netlink
/// writer for the outer address; carried so the shared driver's shape is whole
/// and a site that declares it on one product gets it on all three.
pub const UNDERLAY_ENV: &str = "GUNNAR_UNDERLAY";
/// The durable identity root when `serve` was given none and [`DATA_ENV`] is
/// unset: P3's mountpoint on installed metal, the volume in a container.
pub const DEFAULT_ROOT: &str = "/data";

/// gunnar's envelope for the shared driver — the five strings that differ
/// between the products, and nothing else.
pub const ENV: ApplianceEnv = ApplianceEnv {
    product: PRODUCT,
    gate_env: GATE_ENV,
    data_env: DATA_ENV,
    underlay_env: UNDERLAY_ENV,
    default_root: DEFAULT_ROOT,
};

/// Is the appliance gate on?
///
/// Exact `1`, nothing fuzzy — see [`ApplianceEnv::gate_enabled`].
pub fn gate_enabled() -> bool {
    ENV.gate_enabled()
}

/// The ceremony's directory for a given data root: `<root>/wg`, honouring
/// [`DATA_ENV`].
pub fn wg_dir(data_root: &Path) -> PathBuf {
    ApplianceEnv::wg_dir(&ENV.data_root_or(data_root))
}

/// What the ceremony produced.
pub struct Ceremony {
    /// The appliance's public key, spelled for the operator — 44 characters of
    /// padded base64, the same string [`ceremony::identity_line`] carried.
    pub public_b64: String,
    /// `true` iff THIS boot minted the identity.
    pub minted: bool,
    /// The tunnel gunnar should raise, or `None` when no hub has been declared
    /// yet (first-boot reality: the operator needs the public key OUT before the
    /// hub can be configured at all).
    pub config: Option<WgConfig>,
}

/// `Debug` BY HAND, and it does not print the config.
///
/// `WgConfig` derives `Debug` and carries `private_key` in the clear, so a
/// derived impl here would put the minted secret one `{:?}` away from every log
/// line, every `expect_err` message and every panic. The ceremony's whole
/// discipline is that the private half is written at 0600 and never formatted
/// into a string; this keeps that true one level up.
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()
    }
}

/// **Run the ceremony**: load or mint the identity at `<root>/wg`, emit the
/// MINTED line (first boot only) and the ONE canonical identity line through
/// `out`, then read the hub declaration if the operator has placed one.
///
/// The gate is the CALLER's check, so a gated-off run does nothing at all and
/// emits nothing at all.
///
/// `out` is a sink rather than `println!` so the SAME bytes reach the serial
/// console (which on the appliance is `/dev/console`, the line
/// `installtest/src/iso.rs` greps its boot proof off) and gunnar's `tracing`
/// log, formatted once. It is also what lets a test assert the emitted LINES —
/// the applied output — instead of round-tripping the struct.
///
/// Every refusal names the thing to go look at, and NOTHING is emitted on one:
/// a corrupt, truncated, unreadable or group-readable identity file returns the
/// shared crate's error rather than minting identity number two. That is the
/// whole promise — an appliance that silently re-mints on reboot has changed its
/// identity without telling anyone, and every peer that trusted it is now
/// trusting a stranger.
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);

    // The underlay before anything needs the network — the tunnel's own UDP
    // needs a route out. Almost always absent on gunnar (`GUNNAR_NET` and
    // `appliance_pid1::net::apply` own the outer address); declared-but-unappliable fails loud.
    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,
    })
}

/// The ceremony's two halves — the minted private key and the declared hub —
/// wired into the config shape [`crate::wg::bring_up`] already establishes.
///
/// Deliberately reuses `WgConfig` rather than growing a second establish path:
/// gunnar's netlink address writer, its unrouted-`AllowedIPs` warning and its
/// handshake watch are all worth having on the ceremony lane, and duplicating
/// `establish` to get them would be the twin one implementation forbids.
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)) => {
            // A box installed from a medium mastered before the one-medium
            // ruling: accepted, because refusing would take a working box off
            // its tunnel, and said out loud every boot.
            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();
    // The interface name comes from the config file's stem, the same single
    // source `WgConfig::from_data_root` uses — two spellings of `wg0` is a way
    // for them to disagree.
    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,
        // `None` — the appliance only ever initiates, so the kernel picks. A
        // fixed port would be a firewall rule the site has to know about for no
        // gain.
        listen_port: None,
        peers: vec![Peer {
            public_key: spec.public_key,
            // The ceremony's dialect refuses `PresharedKey` BY NAME: a hub file
            // is baked into per-site ISOs and copied freely, so it carries
            // public data only.
            preshared_key: None,
            allowed_ips: spec.allowed_ips,
            endpoint: spec.endpoint,
            persistent_keepalive: spec.persistent_keepalive,
        }],
    }))
}

/// The gRPC door the tunnel address arrives through — named in every line
/// that tells an operator where the address comes from.
pub const METHOD_ASSIGN: &str = "Bootstrap.AssignTunnelAddress";

/// **This box's tunnel address**, `<root>/wg/address`: one line,
/// `a.b.c.d/prefix`, written by [`assign_address`] when the site hands it over
/// at bootstrap. gunnar's own file beside the shared ceremony's three — the
/// shared dialect keeps `[Interface] Address` in `hub.conf` for holger and korp,
/// and gunnar reads it there only for a box installed from an older medium.
/// 8.3-safe for P3's FAT.
pub const ADDRESS_FILE: &str = "address";

/// Parse a tunnel address the way [`assign_address`] will: `a.b.c.d/prefix`,
/// a host of its subnet.
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))
}

/// The address [`assign_address`] recorded, if any. A file that does not parse
/// is REFUSED, never treated as absent: absent means "wait for the bootstrap",
/// and a box that silently waited again would re-ask for an address and could
/// be handed a second one.
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())),
    }
}

/// What [`assign_address`] answers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Assignment {
    /// This box's WireGuard public key — what the hub registers.
    pub public_b64: String,
    /// The address the box holds now (assigned, or off an older medium).
    pub address: Option<(std::net::Ipv4Addr, u8)>,
    /// `true` iff THIS call wrote the address.
    pub changed: bool,
}

/// **The door's decision, pure.** `requested = None` is a read and always
/// allowed. Writing an address is allowed while the box is FRESH (the
/// bootstrap window is open — the same rule `Enroll` and `BecomeTwin` keep), or
/// when it is the address the box already holds (an idempotent retry).
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(),
            }
        ),
    }
}

/// **`Bootstrap.AssignTunnelAddress`, the box half**: record `requested` as
/// this box's tunnel address, or with `None` just answer what the box holds.
///
/// Idempotent for the same address; a DIFFERENT address is refused — a tunnel
/// address is never re-pointed, because the hub has already routed the old one
/// to this key. The identity must already exist (the ceremony runs at start,
/// before the control plane listens); a box with none is refused by name rather
/// than minting one here, which would be a second minting door.
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 })
}

/// **Both tunnel-address doors, one implementation**: `Bootstrap.AssignTunnelAddress`
/// (`may_write` = the bootstrap window is open) and `Admin.SetTunnelAddress`
/// (`may_write` = true; the service held the caller to server Admin).
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) };
    // **A paired box's tunnel is the pairing loop's** (2026-09-24): `show`
    // reports THAT tunnel — the key the door recorded, the address it
    // assigned, whether wg0 carries it now — not the ceremony's unused
    // identity; and an address is never handed over beside the door's.
    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 })
}

/// The SSH door's `key value` lines: one writer and one reader, in
/// `gunnar-proto` so the CLI (which may not link the server) reads what the
/// server writes.
pub use gunnar_proto::{parse_tunnel_answer, render_tunnel_answer};

/// The path of the identity's public half, for an operator (or a one-shot
/// container) that wants to read the key back without parsing logs.
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};

    /// A std-only scratch dir. Unique per test, removed on drop.
    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);
        }
    }

    /// Collect what the ceremony EMITTED, which is the whole product: the
    /// operator reads these bytes off a serial console and registers what is in
    /// them. Asserting the struct instead would pass with the sink wired to
    /// nothing.
    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)
    }

    /// gunnar emits the ONE canonical line, in the shape every product's
    /// watcher greps — and the key IN the line is the key on disk.
    ///
    /// **RED-when-broken**: the regex is the shared crate's constant, so a
    /// gunnar-private spelling of the line fails here rather than being
    /// discovered by a boot proof that quietly matched nothing.
    #[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"
        );

        // The applied output on disk, not a state round-trip: identity.pub is
        // what a one-shot `cat` reads back, and it must agree byte for byte
        // with what the operator was told.
        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"
        );
    }

    /// **REFUSE, NEVER RE-MINT — the guard that matters most.**
    ///
    /// An appliance that silently re-mints on reboot has changed its identity
    /// without telling anyone: the operator registered the FIRST key with the
    /// hub, and every peer that trusted it is now trusting a stranger while the
    /// console shows a healthy boot.
    ///
    /// So: mint once, corrupt the identity, and assert the second boot REFUSES —
    /// naming the file — and that it emitted NOTHING (no identity line, no
    /// MINTED line) and left the corrupt bytes in place as the evidence.
    #[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"
        );
    }

    /// The identity SURVIVES a reboot, and the second boot says so by NOT
    /// saying MINTED.
    #[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:?}"
        );
    }

    /// No hub yet is FIRST-BOOT REALITY, not an error — and the appliance says
    /// what to do next rather than failing.
    #[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}"
        );
    }

    /// A declared hub becomes the config gunnar's own `establish` raises —
    /// asserted field by field on the WIRE-SHAPED output, because a hub whose
    /// endpoint or AllowedIPs were dropped in translation is a tunnel that
    /// "came up" and carries nothing.
    #[test]
    fn a_declared_hub_becomes_gunnars_own_tunnel_config() {
        let dir = Scratch::new("hub");
        // Mint first so the hub file lands next to a real identity.
        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"
        );
        // The private key in the wire config is the MINTED one, not a seeded
        // one: this is the whole point of the ceremony.
        assert_eq!(
            wg_appliance_core::encode_key(&cfg.public_key()),
            first.public_b64,
            "the tunnel must carry the identity the ceremony announced"
        );
    }

    /// The medium's hub.conf as the one-medium ruling wants it: the SITE only.
    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";

    /// **ONE MEDIUM, MANY BOXES** (2026-09-21, T17): the medium's `hub.conf`
    /// carries only the site — hub key, endpoint, AllowedIPs, keepalive — and
    /// NO `[Interface] Address`, because an address on the medium is the same
    /// address on every box installed from it. A box that has not been handed
    /// its address yet is NOT refused: it boots, announces its identity, raises
    /// no tunnel, and says which door hands the address over.
    ///
    /// RED on d54e3f8: `config_from_ceremony` bailed ("declares a hub but no
    /// `[Interface] Address`"), so a correct one-medium box refused to start its
    /// ceremony at all.
    #[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:?}"
        );
    }

    /// **Two boxes, one medium, two tunnel addresses** — the defect T9 found,
    /// closed on the box: the same site-only hub.conf on two data roots, each
    /// handed its own address, raises two different `wg0` addresses under two
    /// different minted keys.
    #[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");
    }

    /// The same address again is a no-op (a retried enrol); a different one is
    /// refused — the hub already routes the first to this key.
    #[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");
    }

    /// An older medium's address still raises the tunnel (and says where it
    /// came from); a bootstrap address that disagrees with it is refused.
    #[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");
    }

    /// The identity root is the ceremony's OWN env, never gunnar's store root.
    /// Overloading one env with both meanings lets a storage decision relocate
    /// the identity, which re-mints it, which stales the hub registration.
    #[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"
        );
    }
}