<!-- nornir:class: idiot -->
# holger as an appliance — the identity ceremony, for idiots
> **What this is.** holger built as an *appliance*: a box (container today, ISO
> later) that takes the WireGuard hub's PUBLIC key IN as a file, mints its OWN
> keypair on first start, and gives its public key OUT — on stdout and in a
> file — so an operator can register it with the hub. The identity survives
> reboots, container replacement, and image upgrades. One implementation for
> the whole suite: edda's `wg-appliance-core::ceremony` (gunnar consumes the
> same crate); holger implements **nothing** ceremony-shaped itself.
> Everything below was run for real on 2026-08-13 (branch
> `feat/appliance-identity`); transcripts live in the commits.
---
## §1 The three files and the one line (fixed forever)
Everything lives under the durable root `/data` (override for tests:
`HOLGER_APPLIANCE_DATA=<dir>`), in `wg/`:
| file | direction | what |
|---|---|---|
| `/data/wg/hub.conf` | IN | the hub's side: ONE `[Peer]` with `PublicKey`, `AllowedIPs`, optional `Endpoint` + `PersistentKeepalive`. Everything else — including `PrivateKey`/`PresharedKey`, by name — is refused with file:line. |
| `/data/wg/identity.key` | (stays put) | the appliance's private key, minted once, 0600. **The file IS the identity.** |
| `/data/wg/identity.pub` | OUT | the public key, re-derived from the key file whenever missing or stale — read it back without parsing logs. |
The ONE canonical line every product prints and every watcher greps
(`wg_appliance_core::IDENTITY_LINE_REGEX` is the published regex;
`parse_identity_line` the shared reader — never write your own):
```
wg-appliance identity: product=holger pubkey=<44-char-base64>
```
On the boot that MINTS, one extra loud line precedes it (the word `MINTED`
appears in no other output — a new identity is one grep, and it means the hub
must be updated):
```
wg-appliance identity: MINTED new keypair at /data/wg/identity.key (product=holger) — register the public key below with the hub
```
## §2 The gate — declared, not folklore
The ceremony runs iff env `HOLGER_APPLIANCE=1`, and only binaries built with
the `appliance` cargo feature carry it (the `mini` and `mvp-server` atoms do;
nothing else changed — a plain `holger-mvp start` on a dev box prints nothing
appliance-shaped). The container cell DECLARES the env into the image, so
"this artifact is an appliance" is a property of the declaration. A binary
built *without* the feature but started with the env set refuses loudly and
exits nonzero — never a silent non-appliance that looks healthy.
Durability laws, enforced and robot-proven: an existing key is NEVER silently
re-minted; a corrupt/truncated/group-readable key file REFUSES BY NAME (the
file is evidence — never rewritten); a declared `hub.conf` whose tunnel cannot
come up (no `/dev/net/tun`, no `CAP_NET_ADMIN`) is a loud
`wg-appliance tunnel: FAILED — …` naming the precondition plus a nonzero exit
— never half-up. No `hub.conf` at all is the legitimate first-boot shape:
key out, serve on.
## §3 The container shape (works today)
The declared cell is `[[build.container]] holger-mvp-appliance` in
`.nornir/build-variants.toml`: **`base = "scratch"`** carrying the
`mvp-server-musl` atom's STATICALLY LINKED binary (the 14th atom, ruled in
2026-08-13 when the musl wall fell — the image is holger and NOTHING else: no
libc, no shell, no base pull), `HOLGER_APPLIANCE=1` in the image env, `/data`
as the ONE volume (config + registry state + wg identity), archive export
`.container-out/holger-mvp-appliance.tar` so a release carries the image as a
file. Building the musl atom needs the declared cross toolchain
(`.cargo/config.toml` §musl — a linker answering to `x86_64-linux-musl-gcc`;
the zig recipe lives beside the cell in build-variants.toml). Two recorded
consequences: `scratch` has no `/etc/passwd` so the container runs uid 0
(root-in-userns under rootless podman), and the old glibc-floor law
(cc-debian13, host-2.39-vs-debian12-2.36 — measured) is moot for a static
layer.
Run it (rootless podman; `:U` chowns the volume to the container user):
```bash
podman run --rm -v holger-data:/data:U holger-mvp-appliance:0.6.9
```
First start prints MINTED + the canonical line; `podman logs` shows it, and
the volume's `wg/identity.pub` agrees. With a hub declared, add the tunnel
preconditions or it fails loudly by design:
```bash
podman run --rm -v holger-data:/data:U --cap-add NET_ADMIN --device /dev/net/tun holger-mvp-appliance:0.6.9
```
## §4 Prove it yourself (the robots)
```bash
# the ceremony contract on the real binary — 6 robots, fast, no container
cargo test -p holger --no-default-features --features mvp-server,appliance --test appliance_gate
# the container shape end-to-end — heavy, pulls the base once, opt in:
cargo test -p holger-build-space --test appliance_container_robot -- --ignored --nocapture
# the shared ceremony's own suite (in ../edda)
cargo test -p wg-appliance-core
```
The robots' reds are the point: deleted key → LOUD new mint; corrupt key →
named refusal, file byte-identical afterwards; unknown `hub.conf` key → named
with its line; declared hub without tun → the FAILED line; gate off → not one
line of ceremony output.
## §5 What is NOT done (measured walls, owners named)
* **ISO shape** — the cell is still undeclared, but its inputs closed one by
one on 2026-08-13: the app-`.znippy` packer EXISTS (`cargo xtask
appliance-pack`, static payload proven), the `--param` seam for per-site
values EXISTS (nornir-build-thing `[[build.installer.params]]`; since
2026-08-22 `hub_conf` also declares a FIXTURE — `[build.installer.fixtures]
hub_conf = "wg-hub"` — so `--param hub_conf=@fixture` makes the forge mint a
userspace WireGuard hub itself, private half 0600 under the forge's state dir,
public `hub.conf` baked, the build record naming the fixture), and the
installer/boot chain is KVM-proven in edda (INSTALL-OK off the real PID-1,
RAID single-member, Secure-Boot refusal). What remains: the composition
verb that stages korp-boot + korp-start + a kernel + the packed app into a
holger-declared `[[build.installer]]` cell — the PID-1 comes from edda's
korp-installer (consumed, never twinned) and the kernel is a declared
input.
* **`distribution/Dockerfile` as a file-mode cell** — unbuildable from the
committed tree (a DIRECT path dep on the znippy sibling, not just
`[patch.crates-io]`); declared the day the tree builds sibling-free.
* **Tunnel: fully proven.** Handshake + sealed transport (the
traffic-crossed floor) AND the IP rung — the hub fixture's ping returns
through the tunnel (`echo_replies`), with the site address applied by the
shared crate's ioctl writer (`[Interface] Address` in hub.conf, the
kernel's connected-subnet route doing the routing). The wrong-key red seals
nothing and is counted. Routes BEYOND the connected subnet (netlink
`RTM_NEWROUTE`) remain deliberately absent — declare the address with its
real site prefix.
* **tunnr fold-in** — tunnr still runs its own keys/config copy; folding it
onto wg-appliance-core changes its boot-marker contract and stays its own
task.