installer-iso-secrets-partition¶
Modules
A NixOS installer ISO that auto-joins a mesh VPN and can clone from a private forge — while the image itself contains no credentials at all. Every secret lives on a separate, labelled partition appended to the USB stick after the image has been written, and a boot-time oneshot finds it by filesystem label, mounts it read-only, and gets out of the way.
Both halves ship here: the NixOS module that consumes the partition at boot, and the flasher that creates it. Either half alone is useless.
The problem¶
You want an installer stick you can hand to anyone, boot on any machine, and
have it come up already on your mesh VPN and already able to git clone from
your private forge. That means it needs a VPN pre-auth key and an SSH identity
at boot.
The intuitive move is to bake them into the ISO — read the key file at eval
time, or environment.etc it in. Do not. Three things go wrong, all
permanent:
- The image becomes a credential. Everything an ISO carries lands in
/nix/store, which is world-readable on every machine that ever built or substituted it. Anyone who can read the store — or the ISO, or your binary cache — has your pre-auth key. - The artifact stops being shareable and cacheable. A key-free ISO is a
plain build product: push it to a cache, hand it to a colleague, keep it in
CI. A key-bearing one is a secret with a
.isoextension. - Rotation means re-flashing every stick. The key is welded to the image hash. Rotate it and every existing stick is dead; re-flashing a dozen sticks with a 1.5 GB image is an afternoon.
The design¶
build (pure, cacheable, key-free) flash (operator's workstation)
┌───────────────────────────────┐ ┌──────────────────────────────┐
│ .#installer-iso → *.iso │ ─dd──▶ │ p1 hybrid ISO9660 │
└───────────────────────────────┘ │ p2 EFI system partition │
│ p3 ext4, label INSTALLER-SEC│◀─ age -d
└──────────────────────────────┘ (never
│ hits the
boot │ store)
┌──────────────▼───────────────┐
│ installer-secrets-mount.service│
│ poll blkid -L INSTALLER-SEC │
│ mount ro,nosuid,nodev,… │
│ Before= the VPN units │
└──────────────┬───────────────┘
▼
tailscaled reads authKeyFile
ssh reads IdentityFile
The image is a pure function of your config. The secrets are a property of the
stick. Rotating a key is flash-installer --skip-build /dev/sdb — 3 seconds,
no rebuild. Losing a stick means revoking one pre-auth key, not re-issuing an
image.
How to use it¶
{ config, ... }:
{
imports = [ ./installer-iso-secrets-partition ];
modules.installerSecretsPartition = {
enable = true;
label = "INSTALLER-SEC";
mountPoint = "/run/installer-secrets";
# logical name -> filename on the partition
secretFiles = {
vpnAuthKey = "vpn-auth";
forgeKey = "forge-id_ed25519";
};
# everything that reads a credential must be ordered AFTER the mount
before = [ "tailscaled.service" ];
# ssh_config blocks whose identity lives on the partition
sshHosts = [
{
patterns = [ "forge" "forge.example.com" ];
hostName = "forge.example.com";
user = "git";
identityFile = config.modules.installerSecretsPartition.paths.forgeKey;
}
];
# a real password on the installer accounts (see trap 3)
password.hashedPassword = "$6$rounds=...$...";
flasher.ageIdentityFile = "keys/age-identity.txt";
};
services.tailscale = {
enable = true;
authKeyFile = config.modules.installerSecretsPartition.paths.vpnAuthKey;
};
}
Then, on your workstation:
nix build .#installer-iso # pure, cacheable, key-free
flash-installer /dev/sdb # dd + append + decrypt + populate
# on the booted installer:
systemctl status installer-secrets-mount
Expose the flasher from your flake rather than shipping it inside the ISO:
packages.x86_64-linux.flash-installer =
self.nixosConfigurations.installer.config
.modules.installerSecretsPartition.flasher.package;
The five traps¶
1. The label is not there when local-fs.target is¶
This is the one that actually bites. The obvious implementation is a oneshot
After=local-fs.target that does one blkid -L, and it works on your laptop.
On real hardware it races: USB enumeration, the SCSI disk probe and udev's
blkid scan of the new partition all finish some hundreds of milliseconds after
local-fs.target is reached, because that target only covers filesystems in
fstab — and this partition deliberately is not in fstab.
When the race is lost the unit reports success (it "found nothing"), the VPN
daemon starts, authKeyFile does not exist, and the machine boots fine and
silently never joins the mesh. You discover it by not finding the host.
So the unit polls: blkid -L <LABEL> up to pollAttempts times with
pollIntervalSeconds between tries — 20 × 0.5 s = a 10-second budget by
default, which covers slow USB3 hubs and still fails fast on a stick that
genuinely has no secrets partition. Three more properties matter:
before = [ ... ]lists every unit that reads a credential. systemd ordering, notwants: the mount is not a dependency, it is a precondition. Getting this list wrong reproduces the exact silent failure above.ro,nosuid,nodev,uid=0,gid=0,fmask=0177,dmask=0077— read-only so a booted installer cannot rewrite the stick,nosuid,nodevbecause this is removable media someone else may have handled, and theuid/maskoptions so that even on FAT (which has no unix permissions) the files land as root-only0600in a0700directory.optional = true(the default) exits 0 when the label is absent, so the identical image still boots as a plain rescue disk. This is why the whole scheme costs you nothing: one artifact, two roles.
Upstream has nothing like this. There is no NixOS option, anywhere, for "credentials that travel next to the image instead of inside it" — the installer profiles assume either an interactive operator or a fully baked image.
2. sgdisk --new on a dd'd hybrid ISO silently corrupts the table¶
GPT keeps a backup header and table at the last sector of the device.
When you dd a 1.5 GB hybrid ISO onto a 32 GB stick, that backup lands
wherever the image ended — a third of the way into the stick — and the primary
header still claims the device is 1.5 GB long. sgdisk --new will happily add
your partition and write the backup right back to that stale mid-device offset.
Nothing errors. The firmware, or the next tool that reads the disk, sees an
inconsistent pair and you get a stick that boots on your machine and not on the
next one.
The fix is one flag, run before any modification:
sgdisk --move-second-header /dev/sdb
sgdisk --new="3:-16M:0" --typecode=3:8300 --change-name=3:INSTALLER-SEC /dev/sdb
move-second-header appears zero times in nixpkgs. Verified against
nixpkgs 26.11 (rev e2587ca): grep -rn move-second-header over the entire
tree returns nothing, and the only four sgdisk call sites are
nixos/lib/make-disk-image.nix:306,324,344,364 — all building images from
scratch at a known size, where the backup header is correct by construction.
Nixpkgs never appends to a dd'd image, so it never had to learn this. You do.
A second, louder failure lives in the same step: an isohybrid image often leaves
an MBR ("dos") label, not GPT, whose partition 1 starts at sector 0 and
spans the image with the EFI partition nested inside it. A GPT partition may not
start at sector 0, so sgdisk --print exits 2 with Invalid partition
data! — and under set -o pipefail that kills your whole script at the worst
possible moment, right after the dd. So the flasher reads the actual label
type from sfdisk --list and appends with the matching tool: sgdisk for GPT,
sfdisk --append for MBR (which rewrites only the table entries and leaves
partitions 1/2 and the isohybrid boot code untouched).
Losing the GPT partition name on the MBR path costs nothing, because the
booted system looks the partition up by filesystem label (blkid -L), which
mkfs sets. The GPT partition name was never load-bearing.
3. A real installer password needs priority < 60¶
profiles/installation-device.nix gives both accounts an empty password
(initialHashedPassword = "" at lines 45 and 49 of
nixos/modules/profiles/installation-device.nix, nixpkgs 26.11), and the rest
of that profile forces its opinions with lib.mkImageMediaOverride, which is
mkOverride 60 (lib/modules.nix:1572). Setting a password at normal
priority either conflicts or loses, and — because an empty
initialHashedPassword is not an error — you find out by discovering your
"password-protected" installer lets anyone in.
So define below 60. This module uses mkOverride 49 (password.priority),
which also beats a mkForce (priority 50) coming from your own base modules,
and sets both fields together:
hashedPassword = mkOverride 49 "$6$…";
initialHashedPassword = mkOverride 49 null; # or the empty one still applies
The module asserts priority < 60 so this cannot regress silently.
Note the honest limit of this feature: a password hash on an installer image is world-readable in the store, like everything else in the image. It is a speed bump for the console, not a secret. That is precisely why the VPN key and the SSH identity are on the partition instead.
4. copytoram¶
copyToRam = true adds the copytoram kernel parameter, so the squashfs is
copied into RAM during boot and the stick can be pulled out mid-install.
That matters more than it sounds: an install can take twenty minutes, and the
alternative is a USB stick dangling out of a rack machine that someone will walk
past and knock. It also means you can flash the next stick while the first
machine is still installing.
Cost: boot takes longer by roughly image-size ÷ USB read speed, and you need RAM for the whole image. On a 1.5 GB minimal ISO with a 4 GB machine this is fine; on a 2 GB desktop ISO and 2 GB of RAM it will not boot.
5. The partition node does not exist yet either¶
Three separate things bite between "the partition table now has a third entry"
and "I can mkfs it":
- Suffix:
/dev/sdb→/dev/sdb3, but/dev/nvme0n1→/dev/nvme0n1p3, and likewisemmcblk*andloop*need thep. Concatenating blindly either formats the wrong node or nothing at all. - The kernel has not re-read the table:
partprobe <device>after every table change. - udev has not created the node:
udevadm settle --timeout=10, and then a bounded poll ([ -b "$node" ], 10 × 1 s) anyway, becausesettlereturns when the current queue drains and the partition-scan event may not have been queued yet. The flasher fails loudly if the node never appears rather than runningmkfsagainst a nonexistent path.
The same "settle is a hint, not a guarantee" reasoning is what forces the poll in trap 1. Removable-media timing is not deterministic anywhere in this recipe.
What upstream already does — do not duplicate it¶
nix.registry.nixpkgs and nixPath pinning is done for you.
nixos/modules/installer/cd-dvd/channel.nix:51 sets nix.registry.nixpkgs.to
to the cleaned nixpkgs source bundled in the image, and the same module unpacks
that source as the root channel. That module is imported transitively by
profiles/installation-device.nix, i.e. by every installation-cd-*.nix.
This recipe deliberately contains no registry or nixPath handling; adding
some means you are fighting a module you already imported.
Do not trust image.fileName for the artifact name. iso-image.nix builds
the file as "${config.image.baseName}.iso" (line 1042) while
image.filePath is "iso/${config.image.fileName}" (line 1034). Set only
fileName and the advertised path names a file that does not exist. Either
mkForce image.baseName, or — as the flasher does — glob for whatever single
*.iso the derivation produced and never guess.
Options¶
| Option | Default | Meaning |
|---|---|---|
enable |
false |
Turn the module on. |
label |
"INSTALLER-SEC" |
Filesystem label, found with blkid -L. ext4 caps labels at 16 bytes, FAT at 11 — asserted. |
mountPoint |
/run/installer-secrets |
Where it is mounted. Keep it under /run. |
unitName |
installer-secrets-mount |
Name of the oneshot (and of its script). Configurable so an existing deployment can adopt this module without renaming its unit. |
mountOptions |
ro,nosuid,nodev,uid=0,gid=0,fmask=0177,dmask=0077 |
Joined with commas. See trap 1. |
pollAttempts |
20 |
Label lookups before giving up. |
pollIntervalSeconds |
"0.5" |
Passed verbatim to sleep. Default budget = 10 s. |
optional |
true |
Exit 0 when the partition is absent, so the same image boots as a rescue disk. |
before |
[ "tailscaled.service" ] |
Units ordered after the mount — every credential consumer. |
after |
[ "local-fs.target" ] |
Ordering of the mount unit itself (a starting gun, not a guarantee). |
secretFiles |
{ } |
Logical name → filename on the partition. |
paths |
(read-only) | name → "<mountPoint>/<filename>", for consumers to reference. |
copyToRam |
true |
Add the copytoram kernel parameter. |
sshHosts |
[ ] |
ssh_config Host blocks pinned to an identity on the partition. |
strictHostKeyChecking |
"accept-new" |
For the generated blocks. Set yes + a known_hosts file if an active attacker on the install network is in scope. |
password.hashedPassword |
null |
Real hash for the installer accounts. |
password.users |
[ "root" "nixos" ] |
Accounts it applies to. |
password.priority |
49 |
Must be < 60 — asserted. See trap 3. |
flasher.enable |
false |
Put the flasher in environment.systemPackages. It belongs on the workstation, not in the ISO. |
flasher.package |
(read-only) | The generated flasher, pre-baked with this module's label/size/secret list. |
flasher.name |
"flash-installer" |
Executable name. |
flasher.isoAttr |
".#installer-iso" |
Flake attribute built when --iso is not given. |
flasher.partitionSizeMiB |
16 |
Size of the appended partition. |
flasher.filesystem |
"ext4" |
ext4 (real unix permissions) or vfat (readable elsewhere; relies on the mount masks). |
flasher.ageIdentityFile |
"" |
Default age identity (--identity). |
flasher.ageSecretsDir |
"secrets" |
Directory of <name>.age files (--age-dir). |
The flasher¶
Order of operations is chosen so that failures are cheap:
- Build (or accept
--iso), globbing the produced*.iso. - Pick and vet the device. Refuses
/dev/nvme*outright and refuses the device backing/. Asks for a typedyesunless--yes. - Decrypt the secrets into a
mktemp -d, before touching the disk. A missing.agefile, a wrong identity or an unplugged hardware token then fails while the stick is still intact. Doing this after theddmeans an expired token leaves you with a half-provisioned stick. dd,partprobe,settle.- Append the partition (trap 2), settle, poll for the node (trap 5),
mkfs. - Mount,
install -m 0400 -o 0 -g 0each secret,sync, unmount.
nix, sudo and age/rage are intentionally not in runtimeInputs:
they come from the operator's own PATH, so the flasher uses the same Nix daemon,
the same sudo policy and the same (possibly hardware-backed) age implementation
the operator already trusts. --plaintext-dir DIR skips decryption entirely if
your secrets come from somewhere else.
Caveats¶
- The partition is not encrypted. Its threat model is "the image is public,
the stick is not". Anyone holding the stick holds the keys — treat it like a
key on a lanyard, keep the secrets low-privilege (a single-use, expiring VPN
pre-auth key; a deploy key with read-only access to the repos an installer
needs), and revoke on loss. If you need the stick itself to be safe when lost,
put LUKS on that partition and unlock it from the console; the module's
polling/ordering logic is unchanged, only
mountOptionsand the flasher'smkfsstep move. strictHostKeyChecking = "accept-new"trusts the forge host key seen on the first connection. That is what makes an unattended installer usable and is a real (if narrow) TOFU window on an untrusted install network.- The module only orders itself before the VPN units; it does not configure
the VPN. That is deliberate — it works the same with tailscale, netbird,
nebula or a WireGuard unit, as long as you list the right units in
before. - The flasher shells out to
sfdisk/sgdisk/mkfsand needs root. It is an operator tool, not something to run from CI.
Source¶
modules/installer-iso-secrets-partition/default.nix
# installer-iso-secrets-partition
#
# A NixOS installer ISO whose credentials do NOT live in the image.
#
# The image is a pure, cacheable, shareable artifact. Every secret it needs at
# boot (mesh-VPN pre-auth key, private-forge SSH identity, ...) lives on a
# SEPARATE LABELLED PARTITION appended to the USB stick after the hybrid ISO has
# been dd'd onto it. A boot-time oneshot polls for that label, mounts it
# read-only at a tmpfs path, and orders itself BEFORE the units that consume the
# credentials. If the partition is absent the unit exits 0 and the very same
# image still boots as a plain rescue disk.
#
# The other half of the recipe is the flasher: `flasher.package` builds the ISO,
# dd's it, appends the labelled partition to whatever disk label the hybrid
# image left behind, formats it and copies the (age-decrypted) secrets straight
# onto it — never through the Nix store.
#
# See README.md for the five traps: the world-readable-image trap, the GPT
# backup-header trap, the mkImageMediaOverride password-priority trap, copytoram,
# and partition-node settling.
#
# Usage:
# imports = [ ./installer-iso-secrets-partition ];
# modules.installerSecretsPartition = {
# enable = true;
# label = "INSTALLER-SEC";
# secretFiles = {
# vpnAuthKey = "vpn-auth";
# forgeKey = "forge-id_ed25519";
# };
# before = [ "tailscaled.service" ];
# flasher.enable = true;
# };
# services.tailscale.authKeyFile =
# config.modules.installerSecretsPartition.paths.vpnAuthKey;
{
config,
lib,
pkgs,
...
}:
let
cfg = config.modules.installerSecretsPartition;
# "installer-secrets-mount" -> "installer-secrets". Only used to prefix the
# unit's journal lines so they are greppable by the same name as the unit.
logPrefix = lib.removeSuffix "-mount" cfg.unitName;
# The label is looked up with `blkid -L`, i.e. by FILESYSTEM label, not by GPT
# partition name. That is deliberate — see README ("MBR loses nothing").
mountScript = pkgs.writeShellScript cfg.unitName ''
set -euo pipefail
mkdir -p ${cfg.mountPoint}
if ${pkgs.util-linux}/bin/mountpoint -q ${cfg.mountPoint}; then
echo "${logPrefix}: already mounted"
exit 0
fi
for i in $(seq 1 ${toString cfg.pollAttempts}); do
DEV=$(${pkgs.util-linux}/bin/blkid -L ${cfg.label} 2>/dev/null || true)
[ -n "$DEV" ] && break
sleep ${cfg.pollIntervalSeconds}
done
if [ -z "$DEV" ]; then
echo "${logPrefix}: ${cfg.label} partition not found — running without baked-in secrets"
exit ${if cfg.optional then "0" else "1"}
fi
${pkgs.util-linux}/bin/mount \
-o ${lib.concatStringsSep "," cfg.mountOptions} \
"$DEV" ${cfg.mountPoint}
echo "${logPrefix}: mounted $DEV at ${cfg.mountPoint}"
'';
sshHostModule = lib.types.submodule {
options = {
patterns = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "`Host` patterns/aliases this block matches.";
example = [
"forge"
"forge.example.com"
];
};
hostName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "`HostName` to dial. Null omits the directive.";
};
user = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "`User` for this block. Null omits the directive.";
};
port = lib.mkOption {
type = lib.types.nullOr lib.types.port;
default = null;
description = "`Port` for this block. Null omits the directive.";
};
identityFile = lib.mkOption {
type = lib.types.str;
description = ''
Private key to authenticate with. Point this at a file on the secrets
partition, e.g. `config.modules.installerSecretsPartition.paths.forgeKey`,
so no key material ends up in the image.
'';
example = "/run/installer-secrets/forge-id_ed25519";
};
extraConfig = lib.mkOption {
type = lib.types.lines;
default = "";
description = "Extra ssh_config directives appended inside this block.";
};
};
};
renderSshHost =
h:
let
line = k: v: lib.optionalString (v != null) " ${k} ${toString v}\n";
in
"Host ${lib.concatStringsSep " " h.patterns}\n"
+ line "HostName" h.hostName
+ line "User" h.user
+ line "Port" h.port
+ " IdentityFile ${h.identityFile}\n"
+ " IdentitiesOnly yes\n"
+ " StrictHostKeyChecking ${cfg.strictHostKeyChecking}\n"
+ lib.optionalString (h.extraConfig != "") (
lib.concatMapStrings (l: " ${l}\n") (lib.splitString "\n" (lib.removeSuffix "\n" h.extraConfig))
);
secretNames = lib.attrValues cfg.secretFiles;
flasher = pkgs.writeShellApplication {
name = cfg.flasher.name;
runtimeInputs = with pkgs; [
coreutils
util-linux
gptfdisk
e2fsprogs
dosfstools
parted
systemd
gawk
gnused
];
# `nix`, `sudo` and `age`/`rage` are deliberately NOT runtimeInputs: they
# come from the operator's own environment, so the flasher uses the same nix
# daemon, the same sudo policy and the same (possibly hardware-backed) age
# implementation the operator already trusts.
text = ''
# ${cfg.flasher.name} — build the installer ISO, dd it to a removable
# device, then APPEND a labelled partition holding the out-of-band
# secrets. Nothing secret ever enters the Nix store or the image.
RED=$'\033[0;31m'
GRN=$'\033[0;32m'
YLW=$'\033[1;33m'
CYN=$'\033[0;36m'
NC=$'\033[0m'
info() { printf '%s[INFO]%s %s\n' "$CYN" "$NC" "$1"; }
ok() { printf '%s[OK]%s %s\n' "$GRN" "$NC" "$1"; }
warn() { printf '%s[WARN]%s %s\n' "$YLW" "$NC" "$1"; }
err() { printf '%s[ERROR]%s %s\n' "$RED" "$NC" "$1" >&2; }
step() { printf '\n%s[STEP]%s %s\n\n' "$CYN" "$NC" "$1"; }
# Run privileged. Not `SUDO=sudo; $SUDO cmd` — an unquoted variable in
# command position is exactly the pattern that breaks on empty values.
run() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
else
sudo "$@"
fi
}
FLAKE_ATTR=${lib.escapeShellArg cfg.flasher.isoAttr}
LABEL=${lib.escapeShellArg cfg.label}
SIZE_MIB=${toString cfg.flasher.partitionSizeMiB}
FSTYPE=${lib.escapeShellArg cfg.flasher.filesystem}
IDENTITY=${lib.escapeShellArg cfg.flasher.ageIdentityFile}
AGE_DIR=${lib.escapeShellArg cfg.flasher.ageSecretsDir}
MOUNT_UNIT=${lib.escapeShellArg cfg.unitName}
ISO=""
PLAIN_DIR=""
DEVICE=""
TMP_SECRETS=""
MNT=""
BUILD=true
WRITE=true
WITH_SECRETS=true
ASSUME_YES=false
declare -a SECRETS=(${lib.concatStringsSep " " (map lib.escapeShellArg secretNames)})
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS] [DEVICE]
Build the installer ISO, write it to a removable device, then append a
partition labelled "$LABEL" holding the secrets the booted installer
mounts out-of-band. Secrets are decrypted with your age identity and
copied straight onto that partition: they never enter the Nix store and
are not part of the ISO.
Options:
--iso PATH Use this ISO file instead of building one
--attr ATTR Flake attribute to build (default: $FLAKE_ATTR)
--label NAME Filesystem label for the secrets partition
(default: $LABEL; ext4 caps labels at 16 bytes,
FAT at 11)
--size MIB Size of the secrets partition (default: $SIZE_MIB)
--fs TYPE ext4 (default) or vfat
--identity FILE Age identity used to decrypt (default: $IDENTITY)
--age-dir DIR Directory holding <name>.age files (default: $AGE_DIR)
--secret NAME Secret to place on the partition; repeatable.
First use replaces the built-in default list.
--plaintext-dir DIR Copy every regular file in DIR onto the partition
verbatim instead of decrypting anything
--build-only Build the ISO and stop
--skip-build Do not build; use the last built artefact
--no-secrets Flash the ISO only, no secrets partition
--yes Do not ask before destroying DEVICE
--help This text
EOF
}
cleanup() {
if [ -n "$MNT" ] && mountpoint -q "$MNT" 2>/dev/null; then
run umount "$MNT" >/dev/null 2>&1 || true
fi
[ -n "$MNT" ] && rmdir "$MNT" 2>/dev/null
[ -n "$TMP_SECRETS" ] && rm -rf "$TMP_SECRETS"
return 0
}
trap cleanup EXIT
user_secrets=false
while [ $# -gt 0 ]; do
case "$1" in
--iso) ISO=''${2:-}; shift 2 ;;
--attr) FLAKE_ATTR=''${2:-}; shift 2 ;;
--label) LABEL=''${2:-}; shift 2 ;;
--size) SIZE_MIB=''${2:-}; shift 2 ;;
--fs) FSTYPE=''${2:-}; shift 2 ;;
--identity) IDENTITY=''${2:-}; shift 2 ;;
--age-dir) AGE_DIR=''${2:-}; shift 2 ;;
--plaintext-dir) PLAIN_DIR=''${2:-}; shift 2 ;;
--secret)
if [ "$user_secrets" = false ]; then
SECRETS=()
user_secrets=true
fi
SECRETS+=("''${2:-}")
shift 2
;;
--build-only) WRITE=false; WITH_SECRETS=false; shift ;;
--skip-build) BUILD=false; shift ;;
--no-secrets) WITH_SECRETS=false; shift ;;
--yes|-y) ASSUME_YES=true; shift ;;
--help|-h) usage; exit 0 ;;
/dev/*) DEVICE="$1"; shift ;;
*) err "unknown argument: $1"; usage; exit 1 ;;
esac
done
# /dev/sdb2, but /dev/nvme0n1p2, /dev/mmcblk0p2, /dev/loop0p2. Get this
# wrong and you format the wrong node — or none at all.
part_for() {
case "$1" in
/dev/nvme*|/dev/mmcblk*|/dev/loop*) printf '%sp%s\n' "$1" "$2" ;;
*) printf '%s%s\n' "$1" "$2" ;;
esac
}
settle() {
run partprobe "$1" >/dev/null 2>&1 || true
run udevadm settle --timeout=10 >/dev/null 2>&1 || sleep 2
}
# ---- build -------------------------------------------------------------
if [ -z "$ISO" ]; then
if [ "$BUILD" = true ]; then
step "Building $FLAKE_ATTR"
out=$(nix build "$FLAKE_ATTR" --no-link --print-out-paths)
else
step "Skipping build (--skip-build)"
out=$(nix path-info "$FLAKE_ATTR" 2>/dev/null || true)
fi
# The artefact filename tracks nixpkgs (iso-image.nix names the file
# from image.baseName, NOT image.fileName), so never guess it — take
# whatever single .iso the derivation produced.
if [ -n "$out" ]; then
ISO=$(find "$out/iso" -maxdepth 1 -name '*.iso' -print -quit 2>/dev/null || true)
fi
fi
if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then
err "no ISO found — build first, or pass --iso PATH"
exit 1
fi
ok "ISO: $ISO ($(numfmt --to=iec "$(stat --printf='%s' "$ISO")"))"
if [ "$WRITE" = false ]; then
info "Build complete. Flash it with: $(basename "$0") --skip-build /dev/sdX"
exit 0
fi
# ---- pick the device ---------------------------------------------------
step "Selecting target device"
if [ -z "$DEVICE" ]; then
info "Removable / USB block devices:"
lsblk -d -o NAME,SIZE,MODEL,TRAN,RM -p | awk 'NR==1 || $4=="usb" || $5=="1"'
read -rp "Device (e.g. /dev/sdb): " DEVICE
fi
[ -b "$DEVICE" ] || { err "$DEVICE is not a block device"; exit 1; }
case "$DEVICE" in
/dev/nvme*)
err "refusing to write to NVMe $DEVICE (almost certainly the system disk)"
exit 1
;;
esac
root_dev=$(findmnt -no SOURCE / 2>/dev/null | sed 's/[0-9]*$//; s/p$//')
if [ -n "$root_dev" ] && [ "$DEVICE" = "$root_dev" ]; then
err "refusing to write to $DEVICE — that is the root filesystem's device"
exit 1
fi
lsblk "$DEVICE" || true
warn "ALL DATA ON $DEVICE WILL BE DESTROYED"
if [ "$ASSUME_YES" != true ]; then
read -rp "Type 'yes' to continue: " confirm
[ "$confirm" = "yes" ] || { err "cancelled"; exit 1; }
fi
# ---- collect the secrets BEFORE touching the disk ----------------------
#
# Decrypt first so a missing key, a missing .age file or an unplugged
# hardware token fails while the stick is still intact.
if [ "$WITH_SECRETS" = true ]; then
step "Collecting secrets"
TMP_SECRETS=$(mktemp -d)
chmod 0700 "$TMP_SECRETS"
if [ -n "$PLAIN_DIR" ]; then
[ -d "$PLAIN_DIR" ] || { err "no such directory: $PLAIN_DIR"; exit 1; }
find "$PLAIN_DIR" -maxdepth 1 -type f -exec cp -- {} "$TMP_SECRETS/" \;
else
[ -f "$IDENTITY" ] || { err "age identity not found: $IDENTITY"; exit 1; }
AGE=$(command -v rage || command -v age || true)
[ -n "$AGE" ] || { err "neither 'rage' nor 'age' on PATH"; exit 1; }
for name in "''${SECRETS[@]}"; do
src="$AGE_DIR/$name.age"
[ -f "$src" ] || { err "missing $src"; exit 1; }
info "decrypting $name (touch your hardware token if it asks)"
"$AGE" -d -i "$IDENTITY" -o "$TMP_SECRETS/$name" "$src" \
|| { err "failed to decrypt $name with $IDENTITY"; exit 1; }
done
fi
chmod 0400 "$TMP_SECRETS"/* 2>/dev/null || true
ok "collected $(find "$TMP_SECRETS" -maxdepth 1 -type f | wc -l) secret(s)"
fi
# ---- dd ----------------------------------------------------------------
step "Writing ISO to $DEVICE"
for part in "$DEVICE"?*; do
[ -b "$part" ] || continue
run umount "$part" >/dev/null 2>&1 || true
done
run dd if="$ISO" of="$DEVICE" bs=4M status=progress oflag=sync conv=fsync
settle "$DEVICE"
ok "ISO written"
if [ "$WITH_SECRETS" != true ]; then
ok "done — no secrets partition requested"
exit 0
fi
# ---- append the secrets partition --------------------------------------
#
# A hybrid ISO9660 image dd'd onto a stick usually leaves an MBR ("dos")
# label whose partition 1 starts at sector 0 and spans the whole image,
# with the EFI system partition nested inside it. sgdisk rejects that
# outright — a GPT partition may not start at sector 0 — so `sgdisk
# --print` exits 2 with "Invalid partition data!", which under
# `set -o pipefail` kills the script. Detect the label the device really
# has and append with the matching tool. `sfdisk --append` rewrites only
# the table entries, leaving partitions 1/2 and the isohybrid boot code
# intact.
step "Appending the $LABEL partition"
label_type=$(run sfdisk --list "$DEVICE" 2>/dev/null \
| awk -F': *' '/^Disklabel type/ { print $2 }')
info "disk label on $DEVICE: ''${label_type:-unknown}"
next=$(run sfdisk --list "$DEVICE" 2>/dev/null \
| awk -v dev="$DEVICE" '
index($1, dev) == 1 {
n = $1; sub("^" dev "p?", "", n)
if (n ~ /^[0-9]+$/ && n + 0 > m) m = n + 0
}
END { print m + 1 }')
info "creating partition $next ($SIZE_MIB MiB, label $LABEL)"
if [ "$label_type" = "gpt" ]; then
# THE TRAP: dd of a hybrid image copies the GPT backup header to
# wherever the *image* ended — the middle of a larger stick. sgdisk
# will then write a table whose backup copy is in the wrong place:
# silent corruption that only shows up at boot. Relocate the backup
# header to the true end of the device FIRST.
run sgdisk --move-second-header "$DEVICE" >/dev/null 2>&1 || true
run sgdisk \
--new="$next:-''${SIZE_MIB}M:0" \
--typecode="$next:8300" \
--change-name="$next:$LABEL" \
"$DEVICE" >/dev/null
else
printf 'size=%s, type=83\n' "$(( SIZE_MIB * 2048 ))" \
| run sfdisk --append "$DEVICE" >/dev/null
fi
settle "$DEVICE"
SEC_PART=$(part_for "$DEVICE" "$next")
for _ in $(seq 1 10); do
[ -b "$SEC_PART" ] && break
sleep 1
done
[ -b "$SEC_PART" ] || { err "partition node $SEC_PART never appeared"; exit 1; }
info "formatting $SEC_PART as $FSTYPE, label $LABEL"
case "$FSTYPE" in
vfat) run mkfs.vfat -n "$LABEL" "$SEC_PART" >/dev/null ;;
*) run mkfs.ext4 -q -F -L "$LABEL" "$SEC_PART" >/dev/null ;;
esac
# ---- populate ----------------------------------------------------------
step "Populating $LABEL"
MNT=$(mktemp -d)
run mount "$SEC_PART" "$MNT"
for f in "$TMP_SECRETS"/*; do
[ -f "$f" ] || continue
run install -m 0400 -o 0 -g 0 "$f" "$MNT/$(basename "$f")"
info " $(basename "$f")"
done
run sync
run umount "$MNT"
rmdir "$MNT" 2>/dev/null || true
MNT=""
ok "stick ready. Boot the target and verify with: systemctl status $MOUNT_UNIT"
'';
};
in
{
options.modules.installerSecretsPartition = {
enable = lib.mkEnableOption "out-of-band secrets partition for an installer ISO";
label = lib.mkOption {
type = lib.types.str;
default = "INSTALLER-SEC";
description = ''
FILESYSTEM label of the secrets partition. Found with `blkid -L`, so the
GPT partition name is irrelevant and an MBR-labelled stick works just as
well. ext2/3/4 cap volume labels at 16 bytes and FAT at 11 — keep it
short and uppercase.
'';
};
mountPoint = lib.mkOption {
type = lib.types.path;
default = "/run/installer-secrets";
description = ''
Where the partition is mounted. Keep it under `/run` so nothing survives
a reboot and nothing can be written back to the stick.
'';
};
unitName = lib.mkOption {
type = lib.types.str;
default = "installer-secrets-mount";
description = ''
Name of the oneshot unit (and of the generated script). Configurable so
an existing deployment can adopt this module without renaming its unit —
a rename changes the generated system closure.
'';
};
mountOptions = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"ro"
"nosuid"
"nodev"
"uid=0"
"gid=0"
"fmask=0177"
"dmask=0077"
];
description = ''
Mount options, joined with commas. The defaults are the point of the
module: read-only so a booted installer cannot rewrite the stick,
`nosuid,nodev` because this is removable media, and
`uid=0,gid=0,fmask=0177,dmask=0077` so that on filesystems without unix
permissions (FAT) the files still land as root-only 0600/0700.
'';
};
pollAttempts = lib.mkOption {
type = lib.types.ints.positive;
default = 20;
description = ''
How many times to look for the label before giving up. With the default
interval this is a 10-second budget. See README: the label is NOT
present when `local-fs.target` is reached, so a single lookup races and
loses.
'';
};
pollIntervalSeconds = lib.mkOption {
type = lib.types.str;
default = "0.5";
description = "Delay between label lookups, passed verbatim to `sleep`.";
};
optional = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
When the partition is absent, exit 0 (the default) so the identical
image still boots as a plain rescue disk. Set false to make a missing
secrets partition a hard boot failure instead.
'';
};
before = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "tailscaled.service" ];
example = [
"tailscale-autoconnect.service"
"tailscaled.service"
];
description = ''
Units ordered AFTER the mount, i.e. everything that reads a credential
from the partition. Getting this list wrong is the failure mode the
whole design exists to avoid: the VPN daemon starts, finds no key file,
and the machine silently never joins the mesh.
'';
};
after = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "local-fs.target" ];
description = ''
Ordering dependency of the mount unit itself. `local-fs.target` is a
starting gun, not a guarantee — the poll inside the unit is what
actually waits for the device.
'';
};
secretFiles = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
example = {
vpnAuthKey = "vpn-auth";
forgeKey = "forge-id_ed25519";
};
description = ''
Logical name -> filename on the partition. Used to compute `paths` and
to give the flasher its default list of secrets to place.
'';
};
paths = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
readOnly = true;
default = lib.mapAttrs (_: f: "${cfg.mountPoint}/${f}") cfg.secretFiles;
defaultText = lib.literalExpression ''mapAttrs (_: f: "''${mountPoint}/''${f}") secretFiles'';
description = ''
Absolute paths of the declared secrets, for consumers to reference:
`services.tailscale.authKeyFile = cfg.paths.vpnAuthKey;`
'';
};
copyToRam = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Add the `copytoram` kernel parameter so the whole squashfs is copied
into RAM at boot and the stick can be pulled out mid-install. Costs
boot time and RAM proportional to the image.
'';
};
strictHostKeyChecking = lib.mkOption {
type = lib.types.enum [
"yes"
"no"
"accept-new"
"ask"
];
default = "accept-new";
description = ''
`StrictHostKeyChecking` for the generated `sshHosts` blocks. The default
trusts the host key seen on first connection, which is what makes an
unattended installer usable; set `yes` and ship a known_hosts file if
your threat model includes an active attacker on the install network.
'';
};
sshHosts = lib.mkOption {
type = lib.types.listOf sshHostModule;
default = [ ];
description = ''
ssh_config `Host` blocks written into `programs.ssh.extraConfig`, each
pinned to an `identityFile` that lives on the secrets partition. This is
what lets the installer clone from a private forge without any key
material in the image.
'';
example = lib.literalExpression ''
[
{
patterns = [ "forge" "forge.example.com" ];
hostName = "forge.example.com";
user = "git";
port = 22;
identityFile = "/run/installer-secrets/forge-id_ed25519";
}
]
'';
};
password = {
hashedPassword = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "$6$rounds=...$...";
description = ''
A real (yescrypt/sha512) password hash for the installer accounts.
Null leaves upstream's empty-password behaviour alone. This is not a
secret in the interesting sense — it is a hash on an installer — but
note it DOES end up world-readable in the store, unlike everything on
the secrets partition.
'';
};
users = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"root"
"nixos"
];
description = "Accounts the hash is applied to.";
};
priority = lib.mkOption {
type = lib.types.ints.positive;
default = 49;
description = ''
Definition priority. MUST be below 60: `lib.mkImageMediaOverride` is
`mkOverride 60` and the installation-device profile sets an empty
`initialHashedPassword` — anything at 60 or above loses to it (or
conflicts) and your installer silently keeps an empty password.
'';
};
};
flasher = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Install the flasher into `environment.systemPackages`. Note the
flasher belongs on the OPERATOR'S WORKSTATION, not inside the ISO;
most users take `flasher.package` and expose it from their flake
instead of enabling this.
'';
};
package = lib.mkOption {
type = lib.types.package;
readOnly = true;
default = flasher;
defaultText = lib.literalExpression "pkgs.writeShellApplication { ... }";
description = "The generated flasher, pre-baked with this module's label, size and secret list.";
};
name = lib.mkOption {
type = lib.types.str;
default = "flash-installer";
description = "Executable name of the flasher.";
};
isoAttr = lib.mkOption {
type = lib.types.str;
default = ".#installer-iso";
description = "Flake attribute the flasher builds when no `--iso` is given.";
};
partitionSizeMiB = lib.mkOption {
type = lib.types.ints.positive;
default = 16;
description = ''
Size of the appended partition. Deliberately tiny: it holds a handful
of keys, and a small partition carved from the tail of the stick is
the least likely to collide with anything the image left behind.
'';
};
filesystem = lib.mkOption {
type = lib.types.enum [
"ext4"
"vfat"
];
default = "ext4";
description = ''
Filesystem for the secrets partition. `ext4` keeps real unix
permissions; `vfat` is readable from a non-Linux machine but relies
entirely on the `uid`/`fmask`/`dmask` mount options for protection.
'';
};
ageIdentityFile = lib.mkOption {
type = lib.types.str;
default = "";
example = "keys/age-yubikey-identity.txt";
description = "Default age identity the flasher decrypts with (overridable with `--identity`).";
};
ageSecretsDir = lib.mkOption {
type = lib.types.str;
default = "secrets";
description = "Directory holding `<name>.age` files (overridable with `--age-dir`).";
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.flasher.filesystem != "ext4" || lib.stringLength cfg.label <= 16;
message = ''
modules.installerSecretsPartition.label "${cfg.label}" is ${toString (lib.stringLength cfg.label)}
bytes; ext2/3/4 volume labels are capped at 16. mkfs.ext4 truncates
silently, after which `blkid -L` never matches and the installer boots
with no secrets.
'';
}
{
assertion = cfg.flasher.filesystem != "vfat" || lib.stringLength cfg.label <= 11;
message = ''
modules.installerSecretsPartition.label "${cfg.label}" is ${toString (lib.stringLength cfg.label)}
bytes; FAT volume labels are capped at 11.
'';
}
{
assertion = cfg.password.hashedPassword == null || cfg.password.priority < 60;
message = ''
modules.installerSecretsPartition.password.priority must be < 60.
lib.mkImageMediaOverride is mkOverride 60 and the installation-device
profile sets an empty initialHashedPassword; at >= 60 your hash loses.
'';
}
];
systemd.services.${cfg.unitName} = {
description = "Mount installer secrets partition (label ${cfg.label})";
wantedBy = [ "multi-user.target" ];
before = cfg.before;
after = cfg.after;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = mountScript;
};
};
boot.kernelParams = lib.mkIf cfg.copyToRam [ "copytoram" ];
programs.ssh.extraConfig = lib.mkIf (cfg.sshHosts != [ ]) (
lib.concatStringsSep "\n" (map renderSshHost cfg.sshHosts)
);
users.users = lib.mkIf (cfg.password.hashedPassword != null) (
lib.genAttrs cfg.password.users (_: {
hashedPassword = lib.mkOverride cfg.password.priority cfg.password.hashedPassword;
initialHashedPassword = lib.mkOverride cfg.password.priority null;
})
);
environment.systemPackages = lib.mkIf cfg.flasher.enable [ cfg.flasher.package ];
};
}