Airgap (Skíðblaðnir) — guide
Class:
guide· audience: an operator sealing a workspace on a connected box and importing it on a network-isolated one. The what is inairgap-design.md; the why inairgap-reasoning.md; every keystroke inairgap-for-idiots.md.
The whole airgap surface is six CLI verbs under nornir airgap (each with an MCP twin) plus the registry-driven nornir airgap component:
| verb | does | crate call |
airgap pack |
staging tree → signed bundle + VERSION | skidbladnir::archive::TarZstd::pack |
airgap fetch |
verify a received bundle (zero network) | skidbladnir::fetch::verify_on_receipt |
airgap unfold |
print/drive a target-provision plan | skidbladnir::unfold::* |
airgap install |
install one component from a bundle | skidbladnir::product::SystemdInstaller |
airgap component add/upgrade/remove/status |
registry-driven component lifecycle | skidbladnir::product::{registry,SystemdInstaller} |
airgap start |
dependency-ordered bring-up + events | skidbladnir::config::LakeSpec::start_waves |
airgap events |
read the bring-up DAG back | nornir::warehouse::airgap_events |
f0 — the whole session: seal on a connected box → carry → import on the sealed box
# ── on the CONNECTED build host (internet OK) ─────────────────────────
# 1. Lay out a staging tree exactly as it should appear in the bundle.
mkdir -p stage/linux-x86_64 stage/manifests
cp target/release/holger stage/linux-x86_64/holger
cp target/release/nornir-server stage/linux-x86_64/nornir-server
# (component specs are written into manifests/<name>.json — see f4)
# 2. Pack it into a signed bundle (+ a VERSION file BESIDE it).
nornir airgap pack ./stage --out lake-20250505.tar.zst --version 20250505
# → lake-20250505.tar.zst + lake-20250505.tar.zst.manifest.json + VERSION
# prints: ✓ packed N entries → … (root sha256 <16hex>, VERSION beside)
# ── CARRY lake-*.tar.zst + its .manifest.json across the gap ──────────
# USB stick · one-time relay drop · age-encrypted courier.
# Note the printed root sha256 on the label for an out-of-band check.
# ── on the SEALED target (ZERO network) ──────────────────────────────
# 3. Verify on receipt — recomputes the manifest chain + every entry hash.
nornir airgap fetch lake-20250505.tar.zst --sha256 <root-or-file-sha>
# ✓ green outcome, or RED + non-zero exit → refuses to go further.
# 4. (optional) inspect the unfold plan for a fresh machine.
nornir airgap unfold kvm oden-test --disk lake.img # prints the qemu argv
# 5. Install components (root). Either the generic registry path…
sudo nornir airgap component add nornir-server --bundle lake-20250505.tar.zst
sudo nornir airgap component add holger --bundle lake-20250505.tar.zst
# …or a hand-written spec from inside the bundle:
sudo nornir airgap install lake-20250505.tar.zst --spec manifests/holger.json \
--software-dir /opt/lake --data-dir /var/lib --log-dir /var/log
# 6. Bring the lake up in dependency order + record the DAG to the warehouse.
nornir airgap start lake.json # lake.json = a LakeSpec (version+products+pki)
nornir airgap events # read the bring-up DAG back
The far side is fetch → install → start only — no network, no shell, no package manager. Everything a product needs arrived in the bundle.
f1 — pack: sealing the bundle
nornir airgap pack <staging> --out <bundle> [--version YYYYMMDD] (AirgapPackArgs, src/bin/nornir.rs:703). The staging tree's directory shape is the bundle's layout: linux-x86_64/<binary> for artifacts, manifests/<name>.json for each ComponentSpec. Pack tracks itself as a nornir jobs row (kind airgap, crates/nornir-jobs/src/jobs.rs:132) so a slow pack shows in the ledger, and writes the VERSION sidecar beside the bundle. Version defaults to today (%Y%m%d).
Capturing 100%-target-alike deps (when a product links against the target's userland) is a build-host step: construct a PodmanBuild with the target's base image and .step(...) capture lines; it renders a Containerfile and podman build|create|cps /out into the staging tree. This never runs on the target.
f2 — fetch: verifying on the far side
nornir airgap fetch <bundle> [--sha256 <hex>] [--json] (AirgapBundleArgs, src/bin/nornir.rs:715). The sidecar <bundle>.manifest.json must sit beside the bundle. The verdict is a CommandOutcome (green/red) built by airgap_events::verify_outcome; without --sha256 only the internal chain is attested (manifest root + every entry hash, both-ways set match), which already catches tampering and truncation. Supply the out-of-band --sha256 (from the label / secure channel) to also confirm the whole file is byte-identical. Any failure exits non-zero — gate your install script on it.
f3 — install: one component at a time
Two ways to install onto the target, both root-only on Linux:
- Generic / registry-driven —
nornir airgap component add <name> --bundle <bundle>(InstallComponentArgs,src/bin/nornir.rs:865). Resolves the canonicalComponentSpecforholger | tunnr | nornir-serverfrom the built-in registry, so no hand-written JSON.--dry-runprints the systemd unit it would install with no privilege.--software-dir/--data-dir/--log-dirdefault to/opt/lake·/var/lib·/var/log. - Spec-from-bundle —
nornir airgap install <bundle> --spec <path>(AirgapInstallArgs,src/bin/nornir.rs:748) reads aComponentSpecJSON (typicallymanifests/<name>.jsonfrom inside the bundle) and installs it.
Either path runs the same sequence: prepare version-dated dirs → verify the bundle again → stream just this binary out (extract_one) → atomic current symlink swap → write a data-path-aware config → install + enable the systemd unit over D-Bus → open firewall ports. Re-running is idempotent.
Upgrades — nornir airgap component upgrade <name> --bundle <new> (InstallUpgradeArgs, src/bin/nornir.rs:887) shares the same atomic path (a version-dir swap; the unit's ExecStart goes through current, so an upgrade is just the swap). --delta --from <old-bundle> requests a binary-delta apply via the znippy engine — build nornir --features airgap-znippy so product::delta_supported() is true and only the changed blobs ship; without it, a clear note and a full re-install. nornir airgap component remove <name> stops + removes the unit (idempotent); status [<name>] shows one unit or lists the whole registry.
f4 — start & events: ordered bring-up
nornir airgap start <lake.json> (AirgapStartArgs, src/bin/nornir.rs:767) reads a LakeSpec — { "version": "...", "products": [ ...ComponentSpec... ], "pki": "SelfGenerated" } — and derives the dependency-ordered waves from each product's after edges. It prints the waves and appends one airgap_events row per product to a JSONL sink under the warehouse, then ingests them into the Iceberg airgap_events table (non-fatal if the warehouse can't open). Set "pki":"SelfGenerated" for pure-Rust per-machine ed25519 certs (no Vault). nornir airgap destroy <lake.json> tears the lake down in reverse dependency order.
nornir airgap events [--warehouse <root>] [--json] (AirgapEventsArgs, src/bin/nornir.rs:777) reads the DAG back — one row per product×op×phase with its depends_on edges — as a uniform CommandOutcome (empty ⇒ red).
Worked example — a two-product lake
lake.json:
{
"version": "20250505",
"pki": "SelfGenerated",
"products": [
{ "name": "nornir-server", "description": "nornir server", "bundle_binary": "linux-x86_64/nornir-server",
"exec_args": [], "user": "nornir", "ports": [7878], "after": [],
"restart": "OnFailure", "env": [], "health": { "Tcp": 7878 } },
{ "name": "holger", "description": "holger service", "bundle_binary": "linux-x86_64/holger",
"exec_args": ["serve","--config","/etc/holger/holger.toml"], "user": "_holger", "ports": [7070],
"after": ["nornir-server"], "restart": "OnFailure", "env": [], "health": { "Tcp": 7070 } }
]
}
nornir airgap start lake.json derives wave 1 = nornir-server, wave 2 = holger (it declares after: ["nornir-server"]). The same specs are what nornir airgap component add resolves from the registry.
MCP parity
Every read/plan verb has an MCP twin (src/bin/nornir-mcp.rs): airgap_pack (:2551), airgap_verify (:2580), airgap_unfold_plan (:2607), airgap_events (:2630) — so an agent drives the same pipeline as the CLI.