#!/bin/sh # # Bootstrap a NixOS machine onto this config: # # curl -L i.hrry.sh | 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 "$@" } # `curl | sh` hands the script to sh on stdin, so anything that prompts — gh's # device flow, sudo, the confirmations below — would otherwise read the rest of # this file as its answer. Reconnect to the terminal before the first prompt. # The subshell probes it first: a bare `exec` that fails takes the script with # it, and "No such device or address" is a poor explanation of what went wrong. if [ ! -t 0 ]; then if (exec < /dev/tty) 2>/dev/null; then exec < /dev/tty else printf 'error: no terminal — signing in to GitHub needs one.\n' >&2 # shellcheck disable=SC2016 # the $( ) is literal text for the reader printf 'Try: sh -c "$(curl -L i.hrry.sh)"\n' >&2 exit 1 fi 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() { printf '%s ' "$1" read -r _reply 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) read_hosts() { nixf 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 hw="$DEST/nix/system/$host/hardware-configuration.nix" if [ ! -f "$hw" ] || grep -q '00000000-0000' "$hw"; then warn "$host has no real hardware-configuration.nix" cat < $hw git -C $DEST add nix/system/$host/hardware-configuration.nix sudo nixos-rebuild switch --flake $DEST/nix#$host EOF exit 0 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