#!/usr/bin/env bash # Deploy a NixOS host from this flake. ALL arguments are mandatory (no defaults). # # ./deploy kexec headless kexec into a RAM installer, for a # read-only-root box (ZimaOS) where # nixos-anywhere can't ssh-copy-id. Ships our # SSH login key. Then run `install`. # ./deploy install first install (nixos-anywhere). Wipes the # OS disk. Ships the host's sops key. # ./deploy switch rebuild + activate on a running host. # ./deploy boot stage for next boot, don't activate now. # ./deploy test activate without adding a boot entry. # ./deploy image build an SD-card image (e.g. rpi mercury). # ./deploy flash build SD image, write to , and (if # ~/.config/homelab//age.txt exists) # drop the sops key on its boot partition. # # = a nixosConfigurations name (e.g. jupiter, vps). Its pre-generated # SSH host key must be at ~/.config/homelab//ssh_host_ed25519_key. # # Runs from a non-NixOS host too (nixos-rebuild / nixos-anywhere via `nix run`). # # Password prompts are auto-filled from the "HomeLab" Proton Pass vault when # `pass-cli` is installed and logged in; otherwise every command prompts exactly # as before. Items: # darman@ darman's sudo password (switch/boot/test) # root@ root's ssh password (kexec/install) # Override with HOMELAB_PASS_ITEM / HOMELAB_PASS_ROOT_ITEM / HOMELAB_PASS_VAULT. set -euo pipefail # Locate the repo root (flake dir) regardless of where this script lives on disk. SCRIPT_DIR="$(cd "$(dirname "$(realpath "$0")")" && pwd)" REPO="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || dirname "$SCRIPT_DIR")" cd "$REPO" export PATH="/nix/var/nix/profiles/default/bin:$PATH" die() { echo "error: $*" >&2; exit 1; } # The password field of a Proton Pass item ("--field password" prints the bare # value, one line), or empty if pass-cli is missing / logged out / has no such # item — every caller then falls back to the normal interactive prompt. proton_pass_password() { local title="$1" command -v pass-cli >/dev/null 2>&1 || return 0 pass-cli item view \ --vault-name "${HOMELAB_PASS_VAULT:-HomeLab}" \ --item-title "$title" \ --field password --output human 2>/dev/null | head -1 } # Path to an sshpass binary (system one, else built from nixpkgs). Empty if # neither is available. sshpass_bin() { command -v sshpass 2>/dev/null && return 0 nix build --no-link --print-out-paths nixpkgs#sshpass 2>/dev/null \ | sed 's|$|/bin/sshpass|' } cmd="${1:-}"; [ -n "$cmd" ] || die "usage: ./deploy ..." case "$cmd" in kexec) host="${2:-}"; [ -n "$host" ] || die "usage: ./deploy kexec " echo ">> building kexec installer + static tools" nix build .#nixosConfigurations.kexec.config.system.build.kexecInstallerTarball \ -o result-kexec tb="$(ls result-kexec/*.tar.gz | head -1)" # kexec/run rebuilds an initrd with `cpio` + `gzip` from PATH — ZimaOS lacks # both. Ship static ones: GNU cpio (reliable -o -H newc), busybox as gzip. cpio="$(nix build --no-link --print-out-paths nixpkgs#pkgsStatic.cpio)/bin/cpio" bbox="$(nix build --no-link --print-out-paths nixpkgs#pkgsStatic.busybox)/bin/busybox" # One password prompt: multiplex scp + ssh over a shared control connection. cm="/tmp/homelab-cm-%r@%h:%p" o=(-o ControlMaster=auto -o "ControlPath=$cm" -o ControlPersist=300 \ -o StrictHostKeyChecking=accept-new) # Root's password from Proton Pass, fed to ssh/scp via sshpass -e. Only the # first (master) connection authenticates; the rest ride the control socket. # SSHPASS is exported into the sshpass child only, never onto a command line. sp=() root_pw="$(proton_pass_password "${HOMELAB_PASS_ROOT_ITEM:-root@$host}" || true)" if [ -n "$root_pw" ]; then sshpass="$(sshpass_bin || true)" if [ -n "$sshpass" ]; then sp=(env "SSHPASS=$root_pw" "$sshpass" -e) # sshpass drives the password prompt; don't let a key/agent short-circuit # into an interactive one for a host that only accepts passwords. o+=(-o PreferredAuthentications=password -o PubkeyAuthentication=no) else echo ">> sshpass unavailable — falling back to the interactive prompt" >&2 fi fi if [ ${#sp[@]} -gt 0 ]; then echo ">> connecting to root@$host (password from Proton Pass)" else echo ">> connecting to root@$host (enter the root password once)" fi "${sp[@]}" ssh "${o[@]}" "root@$host" 'mkdir -p /tmp/bin' scp "${o[@]}" "$cpio" "root@$host:/tmp/bin/cpio" scp "${o[@]}" "$bbox" "root@$host:/tmp/bin/gzip" # busybox as gzip (argv0) unset root_pw echo ">> streaming installer + kexec-ing. SSH drops as the box jumps into the" echo " RAM installer. Disks are untouched." ssh "${o[@]}" "root@$host" \ 'chmod +x /tmp/bin/*; mkdir -p /tmp/k && tar -C /tmp/k -xzf - && PATH=/tmp/bin:$PATH /tmp/k/kexec/run' \ < "$tb" || true ssh "${o[@]}" -O exit "root@$host" 2>/dev/null || true # close control socket echo ">> box is kexec-ing. Wait ~1-2 min for the installer + network, then:" echo " ./deploy install $host" ;; install) config="${2:-}"; host="${3:-}" { [ -n "$config" ] && [ -n "$host" ]; } || die "usage: ./deploy install " hostkey="$HOME/.config/homelab/$config/ssh_host_ed25519_key" [ -f "$hostkey" ] || die "missing host key: $hostkey" [ -d "./hosts/$config" ] || die "no ./hosts/$config directory in the repo" # Stage the pre-generated SSH host key so sops can decrypt on boot #1. stage="$(mktemp -d)" trap 'rm -rf "$stage"' EXIT install -Dm600 "$hostkey" "$stage/etc/ssh/ssh_host_ed25519_key" install -Dm644 "$hostkey.pub" "$stage/etc/ssh/ssh_host_ed25519_key.pub" echo ">> nixos-anywhere .#$config onto root@$host (OS disk WILL be wiped)" anywhere=(--flake ".#$config" --extra-files "$stage" --generate-hardware-config nixos-generate-config "./hosts/$config/hardware-configuration.nix" --target-host "root@$host") # nixos-anywhere's --env-password reads root's ssh password from $SSHPASS # (it ships its own sshpass), so a vault hit skips the ssh-copy-id prompt. root_pw="$(proton_pass_password "${HOMELAB_PASS_ROOT_ITEM:-root@$host}" || true)" if [ -n "$root_pw" ]; then echo ">> root ssh password from Proton Pass" env "SSHPASS=$root_pw" nix run github:nix-community/nixos-anywhere -- \ --env-password "${anywhere[@]}" else nix run github:nix-community/nixos-anywhere -- "${anywhere[@]}" fi unset root_pw ;; switch|boot|test) config="${2:-}"; host="${3:-}" { [ -n "$config" ] && [ -n "$host" ]; } || die "usage: ./deploy $cmd " echo ">> nixos-rebuild $cmd .#$config on darman@$host" # --ask-sudo-password, not the deprecated --use-remote-sudo: common.nix sets # security.sudo.wheelNeedsPassword = true, and --use-remote-sudo only # prefixes with sudo without ever prompting. Asks for darman's password # (the darman_password hash in each host's sops file). rebuild=(nix run nixpkgs#nixos-rebuild -- "$cmd" --flake ".#$config" --target-host "darman@$host" --ask-sudo-password) item="${HOMELAB_PASS_ITEM:-darman@$config}" pw="$(proton_pass_password "$item" || true)" if [ -n "$pw" ] && command -v setsid >/dev/null 2>&1; then # nixos-rebuild prompts with getpass(), which reads /dev/tty and ignores a # piped stdin. setsid drops the controlling terminal, so getpass falls back # to stdin and takes the vault password (it warns about echo — harmless, # nothing is echoed since the password never reaches the terminal). echo ">> sudo password from Proton Pass ($item)" printf '%s\n' "$pw" | setsid -w "${rebuild[@]}" else "${rebuild[@]}" fi unset pw ;; image|flash) config="${2:-}"; [ -n "$config" ] || die "usage: ./deploy $cmd []" # Validate the device BEFORE building, so a bad `flash` fails fast. if [ "$cmd" = flash ]; then dev="${3:-}"; [ -n "$dev" ] || die "usage: ./deploy flash (e.g. /dev/sdX)" [ -b "$dev" ] || die "$dev is not a block device" fi echo ">> building SD image for .#$config (aarch64 needs qemu binfmt)" nix build ".#nixosConfigurations.$config.config.system.build.sdImage" -o result-sd img="$(ls result-sd/sd-image/*.img.zst | head -1)" echo ">> image: $img" [ "$cmd" = image ] && exit 0 echo ">> TARGET DEVICE — everything on it will be ERASED:" lsblk -o NAME,SIZE,MODEL,TRAN,MOUNTPOINTS "$dev" read -rp ">> type 'yes' to write $config to $dev: " ok [ "$ok" = yes ] || die "aborted" zstdcat "$img" | sudo dd of="$dev" bs=4M status=progress oflag=sync sync # If this config has a dedicated sops age key, drop it on the ROOT ext4 # partition at /var/lib/sops-nix/age.txt so sops decrypts on first boot. # (The Pi's vfat partition isn't mounted at runtime, so the key can't live # there.) Key stays off-repo, out of the nix store, and out of the image. keyfile="$HOME/.config/homelab/$config/age.txt" if [ -f "$keyfile" ]; then echo ">> installing sops age key onto the root partition" sudo partprobe "$dev" 2>/dev/null || sudo blockdev --rereadpt "$dev" 2>/dev/null || true sudo udevadm settle 2>/dev/null || true # largest ext4 partition = the NixOS root. rootpart="$(lsblk -blno PATH,FSTYPE,SIZE "$dev" | awk '$2=="ext4"{print $3, $1}' | sort -rn | head -1 | awk '{print $2}')" [ -n "$rootpart" ] || die "no ext4 root partition found on $dev — place $keyfile at /var/lib/sops-nix/age.txt manually" mnt="$(mktemp -d)" sudo mount "$rootpart" "$mnt" sudo install -Dm600 "$keyfile" "$mnt/var/lib/sops-nix/age.txt" sudo sync sudo umount "$mnt"; rmdir "$mnt" echo ">> age key installed (/var/lib/sops-nix/age.txt)" fi echo ">> done — insert the card into the Pi and boot." ;; *) die "unknown command '$cmd' (kexec|install|switch|boot|test)" ;; esac