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 (branchfeat/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):
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:
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)
# 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-
.znippypacker EXISTS (cargo xtask appliance-pack, static payload proven), the--paramseam for per-site values EXISTS (nornir-build-thing[[build.installer.params]]; since 2026-08-22hub_confalso declares a FIXTURE —[build.installer.fixtures] hub_conf = "wg-hub"— so--param hub_conf=@fixturemakes the forge mint a userspace WireGuard hub itself, private half 0600 under the forge's state dir, publichub.confbaked, 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/Dockerfileas 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] Addressin 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 (netlinkRTM_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.