#!/usr/bin/env bash # Bootstrap a machine from nothing: fetch the private configuration and # install (or rebuild) from it. # # This is the script served at https://ggbellotti.dev/nixos.sh. The script # itself is PUBLIC and contains no secret - it is a fetcher. What is # private is the two repositories it clones. # # curl -sSL https://ggbellotti.dev/nixos.sh | sh # # How it authenticates, in order: # # 1. the machine's own SSH key, if GitHub accepts it. This is the normal # case on a machine that has been installed and used, and it needs # nothing typed. # 2. a GitHub token, read from the terminal. The fallback for a machine # with no key yet. # # There is no custom ISO. A first install runs the official NixOS # graphical installer - partitioning, LUKS and the account are chosen # there, with a real dialog - and this script runs afterwards, on the # installed machine, to turn it into Kira. # # Two modes, decided by where it is running: # # installer a NixOS live ISO, with a target already mounted at /mnt. # It clones, then runs nixos-install. # installed an existing NixOS. It clones/updates and rebuilds. # # What it deliberately does NOT do: partition disks. On a machine that # dual-boots Windows, a script that formats is one typo away from # destroying the other system. Partitioning is a decision, and it stays # manual - see INSTALL.md. The exception is --disk, which exists to # exercise this script on a throwaway VM and refuses to run without a # typed confirmation. set -euo pipefail say() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; } die() { printf '\n\033[1;31mabort:\033[0m %s\n' "$*" >&2; exit 1; } owner="ggbellotti" config_repo="kira" media_repo="kira-media" host="${KIRA_HOST:-kira}" # The account name is DERIVED, not written down. # # This file is public - it is what ggbellotti.dev/nixos.sh serves - and a # login name in it is half of a username/password pair for anyone who # ever finds an SSH port open. There is no reason for it to be here: on # an installed machine the name is simply whoever is running the script, # and under sudo that is SUDO_USER rather than root. # # KIRA_USER overrides it, which is what the installer mode needs: there # the account does not exist yet, so nothing can be derived from it. user="${KIRA_USER:-${SUDO_USER:-${USER:-$(id -un)}}}" if [ "$user" = "root" ]; then die "run this as your own user, not as root - or set KIRA_USER" fi # Reading from the terminal, not from stdin. # # Two different ways stdin is already taken when this runs: # # `curl -sSL https://ggbellotti.dev/nixos.sh | sh` hands sh the SCRIPT # on stdin. A plain `read` there consumes the next lines of the script # itself - the prompt returns instantly with a line of shell in it, and # the rest of the install is silently gone. # # Driven over a pipe, a `read` that reaches EOF returns non-zero with an # empty variable. A loop that only tests the value then spins forever - # which is exactly what happened: kira-setup hammered # `ssh -T git@github.com` in a tight loop until the VM fell over. # # So: read the terminal when there is one, fall back to stdin when there # is not (ssh without -t, a test harness), and treat EOF as a refusal # rather than as an empty answer. ask() { # Probing with `[ -r /dev/tty ]` is wrong and looks right: over # `ssh host cmd` the device node exists and is readable by permission, # but opening it fails with ENXIO because there is no controlling # terminal. The test has to be an actual open. if { : < /dev/tty; } 2>/dev/null; then read -r "$1" < /dev/tty || return 1 else read -r "$1" || return 1 fi } # The same, without echoing - for the token. ask_secret() { if { : < /dev/tty; } 2>/dev/null; then read -r -s "$1" < /dev/tty || return 1 else read -r -s "$1" || return 1 fi } # --- Where are we? --------------------------------------------------------- # # The live ISO always has /iso mounted; an installed system never does. # That is a more reliable signal than the hostname, which the ISO also # sets to "nixos". if [ -d /iso ]; then mode="installer" else mode="installed" fi disk="" while [ $# -gt 0 ]; do case "$1" in --disk) disk="${2:-}"; shift 2 ;; --host) host="${2:-}"; shift 2 ;; *) die "unknown argument: $1" ;; esac done command -v git >/dev/null || die "git not found - run inside nix-shell -p git" # --- The credential -------------------------------------------------------- # # Three ways in, tried in order. The order is the point: each one asks # less of a person sitting at a text console with no browser on it. # # 1. The machine's own SSH key, if GitHub already knows it. Nothing to # type. This is the normal case on a machine that has been used. # 2. The GitHub CLI. `gh auth login --web` prints an eight-character # code and a URL - both short enough to read off the screen and type # on a PHONE. That matters: this script's first run happens on a # freshly installed machine, at a TTY, before there is a desktop or # a browser, and the alternative below is a 93-character token. # 3. A fine-grained token, typed. The last resort. # # `ssh -T git@github.com` exits 1 even on success (there is no shell to # give), so what is checked is the greeting, not the status. token="" have_ssh_key() { ssh -T -n -o BatchMode=yes -o StrictHostKeyChecking=accept-new \ git@github.com 2>&1 | grep -q 'successfully authenticated' } # `gh` is not installed on a bare NixOS, so it is run through nix-shell # when it is missing. The scope list is deliberate: `repo` to clone, and # `admin:public_key` so that kira-setup can register this machine's own # SSH key later WITHOUT a browser - which is the whole reason that step # was painful. gh_run() { if command -v gh >/dev/null; then gh "$@" else nix-shell -p gh --run "gh $*" fi } if [ -n "${KIRA_TOKEN:-}" ]; then token="$KIRA_TOKEN" elif have_ssh_key; then say "Authenticating with this machine's SSH key" else token="$(gh_run auth token 2>/dev/null || true)" if [ -z "$token" ]; then say "This machine has no GitHub credential yet" echo " The next step prints a short code and a URL. Open the URL on" echo " your phone, type the code, and come back here." printf '\n Press Enter to start (or s to type a token instead) ' answer="" ask answer || die "no answer" if [ "$answer" != "s" ]; then # < /dev/tty because this script is usually running as # `curl ... | sh`, where stdin is the script itself. if { : < /dev/tty; } 2>/dev/null; then gh_run auth login --hostname github.com --git-protocol https \ --web --scopes 'repo,admin:public_key' < /dev/tty || true else gh_run auth login --hostname github.com --git-protocol https \ --web --scopes 'repo,admin:public_key' || true fi token="$(gh_run auth token 2>/dev/null || true)" fi fi if [ -z "$token" ]; then printf 'GitHub token (fine-grained, read-only on %s/%s and %s/%s): ' \ "$owner" "$config_repo" "$owner" "$media_repo" ask_secret token || die "no answer" printf '\n' fi [ -n "$token" ] || die "no credential" fi clone() { local repo="$1" dest="$2" url rm -rf "$dest" if [ -n "$token" ]; then url="https://x-access-token:${token}@github.com/${owner}/${repo}.git" else url="git@github.com:${owner}/${repo}.git" fi git clone --depth 1 "$url" "$dest" 2>&1 | sed "s/${token:-__none__}/***/g" git -C "$dest" remote set-url origin "git@github.com:${owner}/${repo}.git" } # --- The images ------------------------------------------------------------ # # kira-media is a flake INPUT, so a missing directory does not degrade - # it breaks evaluation before anything is built. # # During an install it has to exist in TWO places, and this is the part # that is easy to get wrong: `nixos-install` evaluates the flake in the # INSTALLER's namespace, so the `path:` input resolves against # the live ISO's filesystem, not against /mnt. The copy under /mnt is the # one the installed system will use afterwards. fetch_media() { say "Fetching the images (wallpapers and avatar)" clone "$media_repo" "/home/${user}/${media_repo}" if [ "$mode" = "installer" ]; then mkdir -p "/mnt/home/${user}" cp -a "/home/${user}/${media_repo}" "/mnt/home/${user}/${media_repo}" fi } # --- Partitioning, only when explicitly asked ------------------------------ partition() { local d="$1" [ -b "$d" ] || die "$d is not a block device" say "This ERASES $d completely:" lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT "$d" || true answer="" printf '\nType ERASE to confirm: ' ask answer || die "no answer" [ "$answer" = "ERASE" ] || die "not confirmed" # GPT + a 1 GB ESP of its own. On the real machine the ESP is separate # from Windows's for a measured reason: that one is 200 MB, and a NixOS # generation is over 50 MB of kernel plus initrd. parted -s "$d" mklabel gpt parted -s "$d" mkpart ESP fat32 1MiB 1025MiB parted -s "$d" set 1 esp on parted -s "$d" mkpart root ext4 1025MiB 100% local esp root case "$d" in *nvme*|*mmcblk*) esp="${d}p1"; root="${d}p2" ;; *) esp="${d}1"; root="${d}2" ;; esac mkfs.fat -F32 -n BOOT "$esp" mkfs.ext4 -F -L nixos "$root" mount "$root" /mnt mkdir -p /mnt/boot mount "$esp" /mnt/boot } # --- Go -------------------------------------------------------------------- if [ "$mode" = "installer" ]; then [ -n "$disk" ] && partition "$disk" mountpoint -q /mnt || die "nothing mounted at /mnt - partition first (see INSTALL.md)" fetch_media # The repository holds more than the flake, so the clone lands in the # user's home and only nixos/ is copied to /etc/nixos - which is what # `nixos-rebuild` finds without --flake. say "Fetching the configuration" clone "$config_repo" "/mnt/home/${user}/${config_repo}" mkdir -p /mnt/etc rm -rf /mnt/etc/nixos cp -aT "/mnt/home/${user}/${config_repo}/nixos" /mnt/etc/nixos # hardware-configuration.nix is the one file that cannot come from the # repository: it carries the UUIDs of partitions that did not exist # until a minute ago. say "Generating hardware-configuration.nix" mkdir -p "/mnt/etc/nixos/hosts/${host}" # --show-hardware-config writes the whole thing to stdout, mounts # included, and touches nothing on disk. The earlier version of this # called nixos-generate-config twice - once with --no-filesystems and # once to sed the fileSystems block back in - which was two chances to # produce a file that evaluates but does not boot. nixos-generate-config --root /mnt --show-hardware-config \ > "/mnt/etc/nixos/hosts/${host}/hardware-configuration.nix" say "Installing (this takes a while)" nixos-install --flake "/mnt/etc/nixos#${host}" --no-root-password say "Done. Set the user password and reboot:" echo " nixos-enter --root /mnt -c 'passwd ${user}'" echo " reboot" else fetch_media say "Fetching the configuration into ~/${config_repo}" clone "$config_repo" "/home/${user}/${config_repo}" # --- hardware-configuration.nix ------------------------------------------ # # The one file that cannot be in the repository: it carries the UUIDs of # this machine's partitions, and for an encrypted install also the LUKS # device that modules/nixos/encryption.nix keys off. Whatever installed # NixOS - the graphical installer, or nixos-generate-config by hand - # left it in /etc/nixos, so it is copied from there. hw="/home/${user}/${config_repo}/nixos/hosts/${host}/hardware-configuration.nix" # A committed hardware-configuration.nix that belongs to a DIFFERENT # install of the same host name is worse than none at all: the build # succeeds, the bootloader is written, and the machine then hangs for # 90 seconds on "A start job is running for /dev/disk/by-uuid/..." # before dropping into an emergency shell that a locked root account # makes useless. # # That is not hypothetical - it happened on vm-uefi, which was # reinstalled onto LUKS while the repository still carried the file # from its earlier unencrypted install. So the file is checked against # the disk in front of us, not trusted. if [ -f "$hw" ]; then # The first by-uuid AFTER the `fileSystems."/"` line - not simply the # first in the file, and not the last: a generated config lists root # first, then /boot, then swap, and only root proves the disk is the # one this file was written for. root_uuid="$(awk '/fileSystems\."\/"/ { f = 1 } f && match($0, /by-uuid\/[0-9a-fA-F-]+/) { print substr($0, RSTART + 8, RLENGTH - 8); exit }' "$hw")" if [ -n "$root_uuid" ] && [ ! -e "/dev/disk/by-uuid/$root_uuid" ]; then say "The repository's hardware-configuration.nix is for another disk" echo " it names /dev/disk/by-uuid/$root_uuid, which is not here" mv "$hw" "$hw.stale" fi fi if [ ! -f "$hw" ]; then [ -f /etc/nixos/hardware-configuration.nix ] || die "no hardware-configuration.nix, neither in the repository nor in /etc/nixos" say "Taking hardware-configuration.nix from /etc/nixos" mkdir -p "$(dirname "$hw")" cp /etc/nixos/hardware-configuration.nix "$hw" # `git add` is not tidiness, it is what makes the file EXIST for Nix. # In a git working tree a flake sees only tracked files: an untracked # hardware-configuration.nix is invisible, and the build fails saying # the path does not exist - with the file plainly there on disk. git -C "/home/${user}/${config_repo}" add -f \ "nixos/hosts/${host}/hardware-configuration.nix" echo " (staged, not committed - commit and push it when convenient)" fi say "Rebuilding" sudo nixos-rebuild switch --flake "/home/${user}/${config_repo}/nixos#${host}" fi