Airgap (Skíðblaðnir) — reasoning
Class:
reasoning. This file holds the why, the trade-offs, and every design we rejected. The descriptive shape we actually run is inairgap-design.md, which by rule mentions none of this; how to drive it isairgap-guide.md. If you want "why not X?" — X is here.
Why a sealed bundle at all (the motif)
The name is the argument. Skíðblaðnir is the Norse ship large enough to carry every god and all their war-gear, yet folds up like cloth into a pouch. That is exactly the job: fold the entire stack — binaries, all target-alike deps, even the "digger and cement-mixer" tooling — into one portable archive, carry it across the gap once, unfold it on the far side, and raise the house. The moving-house analogy (from the airgap-kubernetes-main prior art) is load the truck with your belongings and the tools to build the house, drive across, dig the hole, pour the foundation, then unpack the belongings.
The hard constraint that forces this shape: an air-gapped machine has no network after the initial staging step. Every byte that will ever run on it must arrive before the gap closes. This is not an edge case — it is the x14-datalabb default. So the design cannot lean on any run-time fetch; the bundle must be complete and self-describing. A single signed archive + two sidecars (.manifest.json, VERSION) is the smallest thing that is both.
Why verify-on-receipt, both ways
The airgap law is: every byte that crosses the gap is verified before it is trusted. The transfer channel (USB, one-time relay, age courier) is out-of-band and untrusted — a stick can flip bits, a courier can be swapped. So verification recomputes the manifest's own root hash (the packing list wasn't edited) and every entry's content hash and rejects both directions of set-mismatch: a manifest entry missing from the bundle (a truncated transfer) and an archive entry the manifest never listed (a smuggled file). A one-directional check (manifest ⊆ archive) would let an attacker append an unlisted binary that install then extracts — so the set must match exactly. fetch_and_verify refuses to hand back a Bundle on any failure because "verify then maybe install anyway" is not a security boundary.
The out-of-band --sha256 is optional on purpose: with no secure side-channel you still get tamper/truncation detection from the internal chain; with one you also pin the whole file byte-for-byte. We did not make it mandatory because requiring a second channel would block the common "verified USB in a locked room" case.
Why 100% pure-Rust, no shell, no network at build
The LAW: containers and installers are controlled in Rust — no *_up.sh, no ansible, no setenforce/systemctl/virsh subprocesses where a library call exists. Reasons:
- Auditability on a sealed box. On the far side there is no package manager to trust and no way to
curl | sha fix. A single statically-analysable Rust binary whose every side effect is a typed call (systemd over D-Bus, firewalld over D-Bus, SELinux via/sys/fs/selinux/enforce) is far easier to certify for a locked environment than a web of shell glue. - It always builds offline. The lean core pulls only always-on crates, so the airgap framework compiles with
net-scanoff and no registry. Heavier capabilities (the znippy engine, Vault PKI, KVM/matrix, Redfish/Terraform) sit behind features precisely so a leaf consumer — holger, tunnr — can adopt the installer without dragging in nornir's whole dep stack. - Dep capture belongs on the connected host, never the target.
dist's reason for building in a container is that a component's runtime deps must be 100% target-alike — the base image is the target's userland. We kept that (PodmanBuild) but bounded it to the build host: the target never runs podman, never needs a runtime. Absent podman is an honest error, not a faked capture, because a silently-empty capture would ship a broken bundle across a one-way gap.
Rejected: replace the airgap installer with a private registry mirror
The obvious alternative to a sealed bundle is: stand up a private crates.io / OCI / apt mirror inside the enclave and let the target "just install normally." We rejected it:
- It reintroduces a network the enclave forbids. A mirror is still a service the target must reach; the whole premise is zero network after staging. A mirror moves the trust boundary inside the wire but does not remove the wire.
- A mirror is an ongoing liability, a bundle is a one-shot artifact. A registry must be run, patched, backed up, and secured inside the air gap forever. A bundle is inert: verify it, install it, done — nothing to keep alive.
- Mirrors don't capture target-alike native deps. A crate/package mirror gives you sources or generic binaries, not the exact
libssl.so.3the target's userland links.PodmanBuildcaptures the real target ABI; a mirror punts that problem to the target, which has no toolchain. - Completeness is unprovable across a mirror. With a bundle,
verify_entriesproves the delivered set is exactly the intended set. With a mirror, "did every transitive dep resolve offline?" is discovered at install time on the sealed box — the worst place to find a gap.
So: sealed, signed, complete bundle — not a mirror.
Why version-dated dirs + an atomic symlink swap
Software lives at software/<YYYYMMDD>/<product> and the running service resolves through a current symlink. Two reasons:
- Upgrades and rollback without downtime windows. A new generation unpacks into a fresh dated dir while the old one keeps serving; activation is a single
rename(2)of the symlink, which is atomic on one filesystem — a reader sees either the old target or the new, never a half-written tree. The prior generation stays on disk as the rollback target. - No NTP, so no clock-sensitive layout. The date stamp is a generation label, not a validity check. This pairs with the PKI decision below.
Why SelfGenerated PKI with a 10-year window
An air-gapped target has no time server. Certificate validity that relies on accurate wall-clock at install time is a foot-gun: a target whose clock is wrong rejects its own freshly-issued cert. So SelfGenerated mints a real ed25519 self-signed CA + SAN leaf with a deliberately wide 10-year window (effectively monotonic-only). We kept ExistingVault / TempVault as explicit seams rather than deleting them because some enclaves do run a Vault and want their own chain of custody — but the default must work with nothing, offline, which only SelfGenerated does.
Rejected: a hand-rolled certificate format
An earlier shape issued a bespoke, hand-rolled cert blob. Rejected: rustls / x509-parser could not load it, so nothing downstream could actually use the identity — it verified in our own code and nowhere else. Real X.509 via rcgen (ring backend, no C/cmake, matching the workspace's rustls-via-ring posture) loads straight into the TLS stack the products already speak. An identity only the issuer understands is not an identity.
Why re-implement topo sort instead of reusing nornir's dep graph
Products in a lake point at each other, so bring-up must be dependency-ordered — nornir already owns dep-graph machinery (dep_graph.rs / mimir.rs). We deliberately re-implemented a small Kahn topological sort inside skidbladnir::orchestrate instead of depending on nornir's, because that machinery lives behind nornir's heavy deps and the whole point of the crate is that a leaf product can adopt the installer without nornir's stack. The algorithm is a few dozen lines and the seam is just &[Product]; paying that duplication buys the lean-core independence. A cycle is a hard error (an install that can never converge), and waves (not just a flat order) are returned so a caller can start independent products concurrently — with std::thread::scope, not rayon (the lean budget forbids the rayon dep).
Why describe holger/tunnr in a registry instead of linking them
ComponentSpec::holger() / tunnr() live in skidbladnir's registry, not in each product's crate, and nornir does not link holger or tunnr as code deps. If we linked them, nornir's build would drag in their entire trees and the "one lean installer any product can use" property would collapse. Describing a product as data (a spec) means nornir airgap component add holger renders holger's unit and installs it end-to-end while nornir stays lean and holger can also self-install by driving the same skidbladnir::systemd engine itself. One install system, two entry points, zero cross-links.
Rejected: a second nornir-install crate
The install system was first sketched as its own nornir-install crate in the nornir workspace (see the historical install-system.md). We folded it into skidbladnir instead of shipping two overlapping installers, because "the airgap bundler" and "the service installer" are the same job at different scopes — a lake install is N component installs in dependency order. Two crates would have meant two ComponentSpec types, two systemd renderers, and two places for the contract to drift. nornir's own server keeps a bespoke nornir install add/update/remove fast path, but it renders the same nornir-server.service unit as airgap component … nornir-server — one unit name, one engine.
Why znippy delta is a feature, not the default
The always-offline default is plain tar + zstd, which needs nothing exotic and always works. The znippy engine (content cache + delta + O(1) selective read) is the airgap-kubernetes speed story — the next version ships only the blobs that changed — but it is gated behind a feature because (a) the lean core must build with zero extra deps, and (b) a delta is only a win when you already have the old bundle on the target. delta_supported() is honest: without the feature, upgrade --delta prints a clear note and falls back to a full re-install rather than silently pretending. "No full decompress" (seek to one entry, stream it out, never touch the Windows MSI beside it) matters most exactly where airgap hurts: a slow USB stick and a target with little /tmp.
How we got here (the road, briefly)
The design was mined from six prior-art repos, each contributing one durable idea: dist (the 3-phase install, container dep-capture, VERSION-outside), airgap-kubernetes-main (pack-everything + cache/delta speed + the redfish/terraform/kvm unfold trio), airgap_llm (the prepare/verify workflow, ported to Rust pack/fetch), tunnr (Rust-as-PID1, the KVM boot argv, the RDP/container target breadth), msi-agent (the Windows MSI/WiX target), and bifrost (the pure-Rust deploy/rollback precedent). AIRGAP1–9 built the pure-Rust offline core first and left the heavy external integrations (Vault, Redfish/Terraform provisioning, the WiX-in-podman MSI build) as honest trait seams with rendered bodies rather than hollow fakes — a clean seam beats a fake, because on a sealed box a fake that silently no-ops is undetectable. The crate was then extracted out of nornir/crates into its own sibling repo (Skidbladnir, published as skidbladnir) so it can release on its own cadence without a nornir release-cascade, and so holger/tunnr can consume it directly.