#!/bin/sh # # Bootstrap a NixOS machine onto this config: # # sh -c "$(curl -L i.hrry.sh)" # # Served publicly by Caddy from install-route.nix. It holds no secret: the # credential it uses is minted by GitHub's device flow at the moment it runs, # and is revocable from github.com without touching the machine. # # Targets a *booted* NixOS — a fresh install or an existing one. Not the # installer ISO: a clone made there lives on tmpfs and is gone at reboot, and # the interesting half of an ISO session is `nixos-install`, not this. # # Verified against the nixpkgs this flake pins, by evaluating the module set # rather than by booting: a stock NixOS system has `curl` and `sudo` on PATH # but not `git`, and the minimal installer ISO has `curl` and `git` but not # `gh`. Neither has flakes enabled. Hence: fetch both tools, and pass # --extra-experimental-features on every nix invocation. set -eu REPO="bluescorpian/dotfiles" DEST="${DOTFILES_DEST:-/home/shared/dotfiles}" # Neither a stock install nor the ISO has flakes on. Every call pays for them # explicitly rather than writing to the machine's nix.conf — the config being # cloned turns them on properly a few steps from now. nixf() { nix --extra-experimental-features nix-command \ --extra-experimental-features flakes "$@" } # The script must arrive as an *argument*, never on stdin. `curl | sh` cannot # work here, and it was the documented entrypoint until it was tried on a real # machine: # # - sh reads a piped script from stdin incrementally, as it executes # - this script must prompt — gh's device flow, sudo, the confirmations below # - so it used to reconnect stdin to the terminal first, the usual # `exec < /dev/tty`. That takes the pipe away from the shell mid-read: sh # loses the rest of itself and curl dies with "(23) Failed writing body" # # The two mechanisms are simply incompatible. Refusing with the correct command # beats half-running and leaving the machine in a state nobody can describe. if [ ! -t 0 ]; then printf 'error: this script cannot be piped into sh.\n\n' >&2 # shellcheck disable=SC2016 # the $( ) is literal text for the reader printf 'Run: sh -c "$(curl -L i.hrry.sh)"\n\n' >&2 printf 'It signs you in to GitHub and asks for confirmations, and a piped\n' >&2 printf 'script has no terminal left to ask on.\n' >&2 exit 1 fi say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } warn() { printf '\n\033[33m!! %s\033[0m\n' "$*"; } die() { printf '\n\033[31merror: %s\033[0m\n' "$*" >&2; exit 1; } # Root on the ISO, a wheel user on an installed system. as_root() { if [ "$(id -u)" = 0 ]; then "$@"; else sudo "$@"; fi; } # ask "prompt" [default] — a bare Enter takes $2, or "n" if unset. ask() { printf '%s ' "$1" read -r _reply if [ -z "$_reply" ]; then _reply=${2:-n}; fi case "$_reply" in [Yy]*) return 0 ;; *) return 1 ;; esac } command -v nix >/dev/null 2>&1 || die "no nix on PATH — is this a NixOS machine?" # ---------------------------------------------------------------- tools say "Fetching git and gh" tools=$(nixf build --no-link --print-out-paths nixpkgs#gh nixpkgs#git) \ || die "could not fetch gh and git — check the network" for t in $tools; do PATH="$t/bin:$PATH"; done export PATH # ---------------------------------------------------------------- auth if gh auth status >/dev/null 2>&1; then say "Already signed in to GitHub" else say "Signing in to GitHub" echo "gh prints a one-time code; approve it from a phone or another machine." # --web is the device flow. The alternative — pasting a personal access # token — is 40+ characters of unforgiving transcription and leaves a # long-lived credential in shell history. See docs/bootstrap.md. gh auth login --hostname github.com --git-protocol https --web \ || die "GitHub sign-in failed" fi gh auth setup-git # ---------------------------------------------------------------- clone if [ -e "$DEST/.git" ]; then say "Already cloned at $DEST" else say "Cloning $REPO into $DEST" as_root mkdir -p "$DEST" as_root chown "$(id -u):$(id -g)" "$DEST" # 0750, not 0755: on the home lab this checkout sits on a machine with a # second human account, and the repo describes that machine's exposure # surface. Harmless elsewhere. chmod 0750 "$DEST" gh repo clone "$REPO" "$DEST" || die "clone failed" fi # ---------------------------------------------------------------- flake PRIVATE_INPUT_REPO="bluescorpian/hrry.sh" ROOT_KEY=/root/.ssh/id_ed25519 # Nix resolves *every* flake input before it evaluates *any* output — proven, # not assumed: a throwaway flake with one unreachable input fails on that input # when asked for an unrelated attribute. One input here is a second private # repo over git+ssh, so a host that builds itself has to be able to read it # even though nothing in its own config uses it. `nixos-rebuild` evaluates as # root, so it is root's key that has to be on that repo. # # That would be three commands and a browser tab. It is none, because gh is # already signed in by the time we get here: a read-only deploy key is the # smallest grant that works, and this is strictly less access than the clone # above already gave this machine. # # The structural alternative is to stop making it an input at all and # `getFlake` it inside the one host that uses it, which makes the fetch lazy. # Rejected: pure evaluation would then need an explicit pinned `rev`, costing # `nix flake update` on a repo under active development. Making that repo # public would delete this function outright. grant_private_input_access() { say "Granting this machine read access to $PRIVATE_INPUT_REPO" as_root install -d -m 0700 /root/.ssh if ! as_root test -f "$ROOT_KEY"; then as_root ssh-keygen -q -t ed25519 -N "" \ -C "$(hostname) (nix flake input)" -f "$ROOT_KEY" fi # GitHub's host keys out of its metadata API over TLS, rather than # ssh-keyscan. Same keys without the trust-on-first-use hop — and root's # fetch is non-interactive, so an unknown host is a hang-free failure with a # confusing message rather than a prompt anyone gets to answer. if ! as_root ssh-keygen -F github.com -f /root/.ssh/known_hosts >/dev/null 2>&1 then gh api meta --jq '.ssh_keys[]' \ | sed 's|^|github.com |' \ | as_root tee -a /root/.ssh/known_hosts >/dev/null fi keyfile=$(mktemp) as_root cat "$ROOT_KEY.pub" > "$keyfile" ssh-keygen -lf "$keyfile" gh repo deploy-key add "$keyfile" \ --repo "$PRIVATE_INPUT_REPO" \ --title "$(hostname) (nix flake input, read-only)" \ || { rm -f "$keyfile"; die "could not add a deploy key to $PRIVATE_INPUT_REPO"; } rm -f "$keyfile" # gh's own caveat, and it bites silently: a key added this way is bound to # the gh authorisation that added it. De-authorise gh later and GitHub # deletes the key with it, and this machine stops being able to evaluate the # flake with no other symptom. Re-running this script puts it back; adding # the same key by hand under the repo's Settings → Deploy keys makes it # independent of gh entirely. warn "That key is bound to this gh authorisation — revoking gh deletes it." } err=$(mktemp) # As root, deliberately, and not through nixf(). `nixos-rebuild` evaluates as # root, and Nix fetches a git+ssh flake input with the *invoking user's* SSH # identity — client-side, not through the daemon. Reading as the login user # would test a credential that never gets used: the read fails, the grant # installs root's key, the retry fails again for the same reason, and the # script reports "still cannot read the flake" with the working key sitting # right there. Root is the identity that has to be able to read this. read_hosts() { as_root nix --extra-experimental-features nix-command \ --extra-experimental-features flakes \ eval --json "$DEST/nix#nixosConfigurations" \ --apply builtins.attrNames 2>"$err" } say "Reading the flake" if ! known=$(read_hosts); then if grep -qE 'git\+ssh|Permission denied \(publickey\)|Failed to fetch git' "$err" then grant_private_input_access if ! known=$(read_hosts); then cat "$err" >&2 rm -f "$err" die "still cannot read the flake" fi else cat "$err" >&2 rm -f "$err" die "could not read the flake" fi fi rm -f "$err" # ---------------------------------------------------------------- host host=$(hostname) # A fresh install answers "nixos" here, which is a config name in no flake. printf '\nBuild this machine as which host? [%s] ' "$host" read -r reply [ -n "$reply" ] && host="$reply" case "$known" in *"\"$host\""*) ;; *) warn "No '$host' in the flake yet — it has: $known" cat < $DEST/nix/system/$host/hardware-configuration.nix 2. add a '$host' entry to $DEST/nix/flake.nix 3. git -C $DEST add nix/system/$host nix/flake.nix (flakes only see tracked files; skipping this reads as an unrelated evaluation error) 4. sudo nixos-rebuild switch --flake $DEST/nix#$host EOF exit 0 ;; esac # ---------------------------------------------------------------- rebuild # A host added to the flake before it existed carries a placeholder # hardware-configuration.nix — real-looking Nix with filesystem UUIDs that # match no disk. Rebuilding against it produces a system that cannot boot. # # This used to print the three commands and exit. It does the work instead, # because the second of those commands cannot run on the machine it was # printed to: the graphical installer leaves `environment.systemPackages` # commented out, so a freshly installed NixOS has no git — and flakes only see # tracked files, so skipping the `git add` surfaces later as an evaluation # error that names neither git nor the file. This script has git on PATH for # the length of its run. It is the only thing here that does. hw="$DEST/nix/system/$host/hardware-configuration.nix" if [ ! -f "$hw" ] || grep -q '00000000-0000' "$hw"; then warn "$host has a placeholder hardware-configuration.nix" echo "Generating the real one from this machine's disks and kernel modules," echo "and staging it so the flake can see it." echo echo " -> $hw" if ! ask "Generate it now? [Y/n]" y; then say "Left for you. Replace that file, git add it, then rerun." exit 0 fi tmp=$(mktemp) if ! as_root nixos-generate-config --show-hardware-config > "$tmp"; then rm -f "$tmp" die "nixos-generate-config failed" fi # A config declaring no filesystems would build a system that cannot mount # its own root. Cheaper to catch here than at the boot that follows. if ! grep -q 'fileSystems' "$tmp"; then rm -f "$tmp" die "the generated config declares no filesystems — refusing to install it" fi cp "$tmp" "$hw" rm -f "$tmp" git -C "$DEST" add "nix/system/$host/hardware-configuration.nix" \ || die "could not stage the generated config" say "Wrote and staged it. Commit it once the rebuild succeeds." fi say "Ready to build $host" echo " sudo nixos-rebuild switch --flake $DEST/nix#$host" if ask "Run it now? [y/N]"; then as_root nixos-rebuild switch --flake "$DEST/nix#$host" say "Done. Set passwords for any account the config declares without one." else say "Left for you to run." fi