diff --git a/flake.lock b/flake.lock index a775894..05ce418 100644 --- a/flake.lock +++ b/flake.lock @@ -7,11 +7,11 @@ ] }, "locked": { - "lastModified": 1778401693, - "narHash": "sha256-OVHdCqXXUF5UdGkH+FF2ZL06OLZjj2kvP2dIUmzVWoo=", + "lastModified": 1778606796, + "narHash": "sha256-P2krpSkFVYJ89bgsnAZ9RtQiGwiTW77sfSJp9SEDscM=", "owner": "nix-community", "repo": "home-manager", - "rev": "389b83002efc26f1145e89a6a8e6edc5a6435948", + "rev": "e1fd7350f4410972bcb8c42a697d8c924ffe642a", "type": "github" }, "original": { diff --git a/iso/.gitignore b/iso/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/iso/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/justfile b/justfile index 9fabf75..79f13e3 100644 --- a/justfile +++ b/justfile @@ -1,7 +1,6 @@ set positional-arguments host := `hostname` -key_services := "github gradescope" # Default: list available recipes. default: @@ -15,125 +14,40 @@ rebuild: update: nix flake update -# Clean up old generations and optimize store +# Clean up old generations and optimize store, then bake the result for next boot gc: sudo nix-collect-garbage --delete-older-than 14d sudo nix-store --optimise sudo nixos-rebuild boot --flake .#{{host}} -# Generate Ed25519 SSH keys for each configured service -gen-keys: - #!/usr/bin/env bash - set -euo pipefail +# Generate Ed25519 SSH keys for each named service (default: github gradescope). +# Override: just gen-keys gitlab bitbucket +gen-keys *services='github gradescope': + @./scripts/gen-keys.sh {{services}} - mkdir -p ~/.ssh - chmod 700 ~/.ssh +# Download the latest NixOS installer ISO into iso/. +# Override defaults: just fetch-iso --channel nixos-unstable --edition minimal +fetch-iso *args: + @./scripts/fetch-iso.sh "$@" - for service in {{key_services}}; do - file="$HOME/.ssh/id_$service" - if [ -f "$file" ]; then - echo "skipping: $file already exists" - continue - fi - echo "generating: $file" - ssh-keygen -t ed25519 -f "$file" -N "" -C "$service@$(hostname -s)" - done - -# Build a bootable NixOS installer USB (writes ISO + optional extras partition). -# Pass arguments through to scripts/make-usb.sh, e.g.: -# just make-usb --iso ~/Downloads/nixos.iso -# Re-executes itself under sudo if not already root. Quoting is preserved -# via `set positional-arguments` + "$@". +# Write a bootable NixOS installer USB. +# Example: just make-usb --iso iso/latest-nixos-graphical-x86_64-linux.iso +# The script self-elevates via sudo if needed. make-usb *args: - #!/usr/bin/env bash - set -euo pipefail - if [[ ! -x scripts/make-usb.sh ]]; then - chmod +x scripts/make-usb.sh - fi - if (( EUID == 0 )); then - exec ./scripts/make-usb.sh "$@" - else - exec sudo --preserve-env=PATH ./scripts/make-usb.sh "$@" - fi + @./scripts/make-usb.sh "$@" -# Register a new host: scaffold hosts// and add a flake.nix entry. -# Example: just register-host blackbear +# Scaffold hosts// and add a flake.nix entry. +# Example: just register-host blackbear register-host name: - @python3 scripts/register-host.py --repo-root . "{{name}}" + @./scripts/register-host.py --repo-root . "{{name}}" -# Capture the current machine's hardware config into hosts//hardware.nix. -# Run this on the *new* machine. Two contexts work: -# 1. Already-installed NixOS: just runs nixos-generate-config against /. -# 2. Booted from installer with target mounted at /mnt: detected -# automatically and --root /mnt is used. -# Example: just capture-hardware blackbear +# Capture the current machine's hardware into hosts//hardware.nix. +# Run this on the new machine. +# Example: just capture-hardware blackbear capture-hardware name: - #!/usr/bin/env bash - set -euo pipefail + @./scripts/capture-hardware.sh "{{name}}" - name="{{name}}" - target="hosts/$name/hardware.nix" - - if [[ ! -d "hosts/$name" ]]; then - echo "[-] hosts/$name does not exist. Run \`just register-host $name\` first." >&2 - exit 1 - fi - - if ! command -v nixos-generate-config >/dev/null 2>&1; then - echo "[-] nixos-generate-config not found." >&2 - echo " Run this command on a NixOS machine (or inside the installer)." >&2 - exit 1 - fi - - # Detect whether we're in the installer with a target mounted at /mnt. - extra_args=() - if mountpoint -q /mnt 2>/dev/null && [[ -d /mnt/etc ]]; then - echo "[+] Detected mounted target at /mnt — using --root /mnt" - extra_args+=(--root /mnt) - elif mountpoint -q /mnt 2>/dev/null; then - echo "[!] /mnt is mounted but doesn't look like a NixOS target (no /mnt/etc)." - echo " Continuing as if on a running system. Pass --no-filesystems by editing" - echo " hardware.nix afterwards if disks aren't permanent." - fi - - # Confirm before overwriting a non-placeholder hardware.nix. - if [[ -f "$target" ]] && ! grep -q 'PLACEHOLDER hardware.nix' "$target"; then - echo "[!] $target already exists and doesn't look like a placeholder." >&2 - read -r -p "Overwrite? (yes/[no]) " confirm - [[ "$confirm" == "yes" ]] || { echo "aborted."; exit 1; } - fi - - echo "[+] Generating hardware config..." - # The redirect runs in the user's shell BEFORE sudo, so the file is - # owned by you, not root. sudo is needed because nixos-generate-config - # reads /proc, /sys, and may probe mounted filesystems. - sudo nixos-generate-config --show-hardware-config "${extra_args[@]}" > "$target" - echo "[+] Wrote $target" - if command -v git >/dev/null && git -C . rev-parse >/dev/null 2>&1; then - echo "[+] git diff:" - git --no-pager diff -- "$target" || true - fi - -# Show which hosts are currently registered in flake.nix (parses the file directly). +# Show which hosts are currently registered in flake.nix. hosts: - #!/usr/bin/env python3 - import re, sys - src = open('flake.nix').read() - m = re.search(r'nixosConfigurations\s*=\s*\{', src) - if not m: - sys.exit(0) - # Brace-match to find the closing }. - depth, i, close = 0, m.end() - 1, None - while i < len(src): - c = src[i] - if c == '{': depth += 1 - elif c == '}': - depth -= 1 - if depth == 0: - close = i - break - i += 1 - block = src[m.end():close] if close else '' - for name in re.findall(r'^[ \t]*([A-Za-z_][\w-]*)\s*=\s*nixpkgs\.lib\.nixosSystem', block, re.M): - print(name) + @./scripts/list-hosts.py diff --git a/modules/core.nix b/modules/core.nix index 7161c70..963ad4f 100644 --- a/modules/core.nix +++ b/modules/core.nix @@ -11,5 +11,10 @@ curl git tree + util-linux + coreutils + gptfdisk + exfatprogs + parted ]; } diff --git a/scripts/capture-hardware.sh b/scripts/capture-hardware.sh new file mode 100755 index 0000000..a6fcef0 --- /dev/null +++ b/scripts/capture-hardware.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Generate hardware.nix for a registered host. +# +# Run this on the NEW machine (the one whose hardware you want captured), +# inside this repo. Two contexts are auto-detected: + +set -euo pipefail + +NAME="" +REPO_ROOT="." + +while (($#)); do + case "$1" in + --repo-root) REPO_ROOT="${2:?missing path}"; shift 2 ;; + -h|--help) + awk '/^#!/ {next} /^[^#]/ {exit} {sub(/^# ?/, ""); print}' "$0" + exit 0 ;; + -*) echo "[-] unknown flag: $1" >&2; exit 1 ;; + *) + if [[ -z "$NAME" ]]; then + NAME="$1"; shift + else + echo "[-] unexpected positional arg: $1" >&2; exit 1 + fi + ;; + esac +done + +if [[ -z "$NAME" ]]; then + echo "[-] usage: $0 " >&2 + exit 1 +fi + +cd "$REPO_ROOT" +target="hosts/${NAME}/hardware.nix" + +if [[ ! -d "hosts/${NAME}" ]]; then + echo "[-] hosts/${NAME} does not exist." >&2 + echo " Run \`just register-host ${NAME}\` first." >&2 + exit 1 +fi + +if ! command -v nixos-generate-config >/dev/null 2>&1; then + echo "[-] nixos-generate-config not found." >&2 + echo " Run this on a NixOS machine (running system or installer ISO)." >&2 + exit 2 +fi + +# Detect installer-mounted-at-/mnt vs running-system. +extra_args=() +if mountpoint -q /mnt 2>/dev/null && [[ -d /mnt/etc ]]; then + echo "[+] Detected mounted target at /mnt — using --root /mnt" + extra_args+=(--root /mnt) +elif mountpoint -q /mnt 2>/dev/null; then + echo "[!] /mnt is mounted but doesn't look like a NixOS target (no /mnt/etc)." + echo " Continuing as if on a running system. Edit hardware.nix afterwards" + echo " if disks aren't permanent." +fi + +# Confirm before overwriting a non-placeholder hardware.nix. +if [[ -f "$target" ]] && ! grep -q 'PLACEHOLDER hardware.nix' "$target"; then + echo "[!] $target already exists and doesn't look like a placeholder." >&2 + if [[ -t 0 ]]; then + read -r -p "Overwrite? (yes/[no]) " confirm + [[ "$confirm" == "yes" ]] || { echo "aborted."; exit 1; } + else + echo "[-] non-interactive; refusing to overwrite. Delete $target first." >&2 + exit 1 + fi +fi + +echo "[+] Generating hardware config..." +# The redirect runs in the user's shell BEFORE sudo, so the file is owned +# by the invoking user. sudo is needed because nixos-generate-config +# probes /proc, /sys, and may need to read mounted filesystems. +sudo nixos-generate-config --show-hardware-config "${extra_args[@]}" > "$target" +echo "[+] Wrote $target" + +if command -v git >/dev/null && git rev-parse >/dev/null 2>&1; then + echo "[+] git diff:" + git --no-pager diff -- "$target" || true +fi diff --git a/scripts/fetch-iso.sh b/scripts/fetch-iso.sh new file mode 100755 index 0000000..1bf0b04 --- /dev/null +++ b/scripts/fetch-iso.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# Download the latest NixOS installer ISO into iso/. +# +# Saves the ISO under its real upstream snapshot name (e.g. +# nixos-graphical-25.11.10684.8fd9daa3db09-x86_64-linux.iso) so different +# channel heads don't clobber each other. Maintains a stable +# `latest-nixos--.iso` symlink in iso/ pointing at the newest. + + +set -euo pipefail + +CHANNEL="nixos-25.11" +EDITION="graphical" +ARCH="x86_64-linux" +DEST_DIR="iso" + +while (($#)); do + case "$1" in + --channel) CHANNEL="${2:?missing value}"; shift 2 ;; + --edition) EDITION="${2:?missing value}"; shift 2 ;; + --arch) ARCH="${2:?missing value}"; shift 2 ;; + --dest) DEST_DIR="${2:?missing value}"; shift 2 ;; + -h|--help) + awk '/^#!/ {next} /^[^#]/ {exit} {sub(/^# ?/, ""); print}' "$0" + exit 0 ;; + *) echo "[-] unknown arg: $1" >&2; exit 1 ;; + esac +done + +BASE="https://channels.nixos.org/${CHANNEL}" +LATEST_NAME="latest-nixos-${EDITION}-${ARCH}.iso" + +mkdir -p "$DEST_DIR" + +# Fetch the small sha256 file first. Its format is ` ` +# so we can derive the actual snapshot filename from it. +echo "[+] Fetching ${BASE}/${LATEST_NAME}.sha256" +tmp_sum="$(mktemp)" +trap 'rm -f "$tmp_sum"' EXIT +curl --fail --location --silent --show-error --output "$tmp_sum" \ + "${BASE}/${LATEST_NAME}.sha256" \ + || { echo "[-] sha256 fetch failed" >&2; exit 2; } + +real_name="$(awk '{print $2}' "$tmp_sum")" +if [[ -z "$real_name" || "$real_name" != *.iso ]]; then + echo "[-] couldn't parse filename from upstream sha256:" >&2 + cat "$tmp_sum" >&2 + exit 2 +fi + +iso="${DEST_DIR}/${real_name}" +sum="${DEST_DIR}/${real_name}.sha256" + +if [[ -f "$iso" && -f "$sum" ]]; then + if ( cd "$DEST_DIR" && sha256sum --status -c "${real_name}.sha256" ); then + echo "[+] ${iso} already present and verified." + ln -sfn "${real_name}" "${DEST_DIR}/${LATEST_NAME}" + exit 0 + fi + echo "[!] ${iso} present but sha256 mismatch — re-downloading from scratch." + # curl --continue-at would resume from the corrupt bytes; nuke first. + rm -f "$iso" +fi + +mv "$tmp_sum" "$sum" +trap - EXIT + +echo "[+] Downloading ${BASE}/${LATEST_NAME}" +echo " -> ${iso}" +curl --fail --location --progress-bar --continue-at - \ + --output "$iso" "${BASE}/${LATEST_NAME}" \ + || { echo "[-] download failed" >&2; exit 2; } + +echo "[+] Verifying sha256..." +( cd "$DEST_DIR" && sha256sum -c "${real_name}.sha256" ) \ + || { echo "[-] sha256 verification failed" >&2; exit 3; } + +ln -sfn "${real_name}" "${DEST_DIR}/${LATEST_NAME}" +echo "[+] ${iso}" +echo "[+] symlink: ${DEST_DIR}/${LATEST_NAME} -> ${real_name}" diff --git a/scripts/gen-keys.sh b/scripts/gen-keys.sh new file mode 100755 index 0000000..3598999 --- /dev/null +++ b/scripts/gen-keys.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +# Generate Ed25519 SSH keys for the given service names. + +set -euo pipefail + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + awk '/^#!/ {next} /^[^#]/ {exit} {sub(/^# ?/, ""); print}' "$0" + exit 0 +fi + +if (( $# == 0 )); then + echo "[+] No services specified; nothing to do." + exit 0 +fi + +mkdir -p ~/.ssh +chmod 700 ~/.ssh + +host_short="$(hostname -s)" + +for service in "$@"; do + file="$HOME/.ssh/id_$service" + if [[ -f "$file" ]]; then + echo "[+] skipping: $file already exists" + continue + fi + echo "[+] generating: $file" + ssh-keygen -t ed25519 -f "$file" -N "" -C "$service@$host_short" +done diff --git a/scripts/list-hosts.py b/scripts/list-hosts.py new file mode 100644 index 0000000..6c8cb0e --- /dev/null +++ b/scripts/list-hosts.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +""" +Print the nixosConfigurations defined in flake.nix. + +Parses flake.nix directly (no `nix eval`, so it works without nix tooling +and is fast). Brace-matches to find the closing `}` of nixosConfigurations +so it handles nested attrsets correctly. +""" + +import re +import sys +from pathlib import Path + + +def list_hosts(flake_path: Path) -> list[str]: + src = flake_path.read_text() + m = re.search(r"nixosConfigurations\s*=\s*\{", src) + if not m: + return [] + + # Brace-match to find the matching closing }. + depth, i, close = 0, m.end() - 1, None + while i < len(src): + c = src[i] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + close = i + break + i += 1 + + block = src[m.end():close] if close is not None else "" + return re.findall( + r"^[ \t]*([A-Za-z_][\w-]*)\s*=\s*nixpkgs\.lib\.nixosSystem", + block, + re.M, + ) + + +def main() -> int: + flake = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("flake.nix") + if not flake.is_file(): + print(f"[-] not a file: {flake}", file=sys.stderr) + return 1 + for name in list_hosts(flake): + print(name) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/make-usb.sh b/scripts/make-usb.sh old mode 100644 new mode 100755 index 2a90c02..62faabd --- a/scripts/make-usb.sh +++ b/scripts/make-usb.sh @@ -1,16 +1,16 @@ #!/usr/bin/env bash -# make-usb.sh -# -# Write a NixOS installer ISO to a USB stick (raw dd) -# and optionally append a writable "extras" partition with files -# (this repo, SSH keys, etc.) that you want available during install. +# Write a NixOS installer ISO to a USB stick (raw dd). +# +# The script self-elevates via sudo if not already running as root, so +# you can run it as your normal user. +# +# After booting the resulting USB on the target machine, fetch this repo +# over the network with `git clone ` from the installer shell. set -euo pipefail -### Styling ### - if [[ -t 1 ]]; then GREEN=$'\e[32m'; YELLOW=$'\e[33m'; RED=$'\e[31m'; BLUE=$'\e[34m' BOLD=$'\e[1m'; RESET=$'\e[0m' @@ -23,23 +23,25 @@ err() { printf '%s[-]%s %s\n' "$RED" "$RESET" "$*" >&2; } info() { printf '%s[i]%s %s\n' "$BLUE" "$RESET" "$*"; } die() { err "$1"; exit "${2:-1}"; } + + ISO_PATH="" DEVICE="" -SKIP_EXTRAS=0 ALLOW_INTERNAL=0 ASSUME_YES=0 -REPO_ROOT="" + +# Save original args before the parser shifts them away; needed if we +# later re-exec under sudo. +ORIG_ARGS=("$@") while (($#)); do case "$1" in --iso) ISO_PATH="${2:?missing path}"; shift 2 ;; --device|--dev) DEVICE="${2:?missing path}"; shift 2 ;; - --no-extras) SKIP_EXTRAS=1; shift ;; --allow-internal) ALLOW_INTERNAL=1; shift ;; --yes|-y) ASSUME_YES=1; shift ;; - --repo-root) REPO_ROOT="${2:?missing path}"; shift 2 ;; -h|--help) - sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//' + awk '/^#!/ {next} /^[^#]/ {exit} {sub(/^# ?/, ""); print}' "$0" exit 0 ;; *) die "unknown arg: $1" 2 ;; esac @@ -47,10 +49,19 @@ done -### Preflight checks ### - -(( EUID == 0 )) || die "run as root: sudo $0 $*" 1 +# Self-elevate via sudo if not already root. We re-exec rather than tell +# the user to prefix the command, so `just make-usb` and `./scripts/make-usb.sh` +# both Just Work. +if (( EUID != 0 )); then + if ! command -v sudo >/dev/null 2>&1; then + die "must run as root and sudo is not installed" 1 + fi + # ORIG_ARGS may be empty; ${ORIG_ARGS[@]+"${ORIG_ARGS[@]}"} expands + # safely under set -u even when the array is unset. + exec sudo --preserve-env=PATH -- "$0" ${ORIG_ARGS[@]+"${ORIG_ARGS[@]}"} +fi +# Require an interactive tty for any prompt we still need. need_tty=0 [[ -z "$DEVICE" || -z "$ISO_PATH" ]] && need_tty=1 (( ASSUME_YES )) || need_tty=1 @@ -58,8 +69,8 @@ if (( need_tty )) && ! [[ -t 0 ]]; then die "stdin is not a tty; pass --iso, --device, and --yes for non-interactive use" 2 fi -# Required commands. We try to be helpful about missing ones on NixOS. -REQUIRED=(lsblk dd sync wipefs sgdisk partprobe blockdev mount umount mountpoint mkfs.exfat awk) +# Required commands. +REQUIRED=(lsblk dd sync wipefs blockdev mount umount awk) missing=() for cmd in "${REQUIRED[@]}"; do command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd") @@ -69,42 +80,16 @@ if ((${#missing[@]})); then cat >&2 <<'EOF' On NixOS / with Nix installed, re-run inside a shell that has them: - nix shell nixpkgs#util-linux nixpkgs#coreutils nixpkgs#gptfdisk \ - nixpkgs#exfatprogs nixpkgs#parted -c sudo ./scripts/make-usb.sh + nix shell nixpkgs#util-linux nixpkgs#coreutils -c sudo ./scripts/make-usb.sh On Debian/Ubuntu: - sudo apt install util-linux coreutils gdisk exfatprogs parted + sudo apt install util-linux coreutils EOF exit 3 fi -# Locate repo root if not provided (used as a copy candidate for extras). -if [[ -z "$REPO_ROOT" ]]; then - if REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null)"; then - : - else - REPO_ROOT="" - fi -fi - -### Cleanup trap ### - -TMP_MNT="" -cleanup() { - set +e - if [[ -n "$TMP_MNT" && -d "$TMP_MNT" ]]; then - mountpoint -q "$TMP_MNT" && umount "$TMP_MNT" - rmdir "$TMP_MNT" 2>/dev/null - fi -} -trap cleanup EXIT INT TERM - - - -### Helpers ### - human() { numfmt --to=iec --suffix=B "$1" 2>/dev/null || echo "$1"; } is_removable() { @@ -182,8 +167,6 @@ select_device() { -### Pick device ### - if [[ -z "$DEVICE" ]]; then select_device else @@ -205,8 +188,6 @@ log "Target: $DEVICE ($(human "$DEV_BYTES"))" -### Pick ISO ### - if [[ -z "$ISO_PATH" ]]; then while :; do read -r -e -p "Path to NixOS ISO: " ISO_PATH @@ -228,18 +209,20 @@ if command -v file >/dev/null 2>&1; then fi fi -# Sanity: ISO must fit on disk with room for an extras partition. -MIN_EXTRAS_MB=64 -if (( ISO_BYTES + MIN_EXTRAS_MB*1024*1024 > DEV_BYTES )); then - if (( ISO_BYTES > DEV_BYTES )); then - die "ISO is larger than the disk" 4 - fi - warn "Less than ${MIN_EXTRAS_MB}MiB free after ISO; extras partition will be skipped." - SKIP_EXTRAS=1 +# Sanity: ISO must fit on disk. +if (( ISO_BYTES > DEV_BYTES )); then + die "ISO is larger than the disk" 4 fi # Optional sha256 sidecar verification. -if [[ -f "$ISO_PATH.sha256" ]]; then +# Resolve through symlinks so `make-usb --iso iso/latest-...iso` finds +# the sidecar that lives next to the real file. +ISO_REAL="$(readlink -f "$ISO_PATH")" +if [[ -f "$ISO_REAL.sha256" ]]; then + log "Verifying $ISO_REAL.sha256..." + ( cd "$(dirname "$ISO_REAL")" && sha256sum -c "$(basename "$ISO_REAL").sha256" ) \ + || die "sha256 verification failed" 5 +elif [[ -f "$ISO_PATH.sha256" ]]; then log "Verifying $ISO_PATH.sha256..." ( cd "$(dirname "$ISO_PATH")" && sha256sum -c "$(basename "$ISO_PATH").sha256" ) \ || die "sha256 verification failed" 5 @@ -247,14 +230,11 @@ fi -### Final confirmation ### - cat </dev/null || umount -l "$part" 2>/dev/null || warn "could not umount $part" done < <(lsblk -lnpo NAME,MOUNTPOINT "$DEVICE" | awk '$2!=""') -### Wipe & write ### + + log "Wiping signatures..." wipefs -a "$DEVICE" >/dev/null log "Writing ISO with dd (this can take a few minutes)..." -dd if="$ISO_PATH" of="$DEVICE" bs=4M status=progress conv=fsync oflag=direct \ - || die "dd failed" 5 -sync -log "ISO written." - -partprobe "$DEVICE" 2>/dev/null || true -sleep 1 - - - -### Extras partition ### - -if (( SKIP_EXTRAS )); then - log "Skipping extras partition." -else - log "Preparing extras partition in trailing free space..." - - # The NixOS ISO uses an isohybrid GPT. The backup GPT header still sits at - # the *original ISO end*, not the actual disk end. `sgdisk -e` relocates it - # so we can add a partition in the freed trailing region. - # `sgdisk -n` with 0:0:0 creates a new partition starting at the first free - # sector and ending at the last free sector (i.e., filling the rest). - sgdisk -e "$DEVICE" >/dev/null - sgdisk -n 0:0:0 -t 0:0700 -c 0:"EXTRAS" "$DEVICE" >/dev/null - partprobe "$DEVICE" 2>/dev/null || true - sleep 1 - - # Find the new partition (highest-numbered one on the disk). - EXTRAS_PART="$(lsblk -lnpo NAME,TYPE "$DEVICE" \ - | awk '$2=="part"{print $1}' | tail -n1)" - [[ -b "$EXTRAS_PART" ]] || die "extras partition didn't appear" 5 - log "Extras partition: $EXTRAS_PART" - - log "Formatting $EXTRAS_PART as exFAT..." - mkfs.exfat -L EXTRAS "$EXTRAS_PART" >/dev/null 2>&1 \ - || die "mkfs.exfat failed" 5 - - TMP_MNT="$(mktemp -d)" - mount "$EXTRAS_PART" "$TMP_MNT" - - echo - log "What to copy onto the extras partition? (space-separated numbers; Enter for none)" - echo - echo " 1) This repo ${REPO_ROOT:-}" - echo " 2) Your public SSH keys ~/.ssh/*.pub" - echo " 3) An arbitrary file/dir (you'll be prompted for the path)" - echo " 4) A custom message/README (you type it inline)" - echo - - read -r -p "Selection: " sel || sel="" - for tok in $sel; do - case "$tok" in - 1) - if [[ -z "$REPO_ROOT" ]]; then - warn "no repo detected; pass --repo-root or run from a git checkout" - continue - fi - log "Copying repo -> /repo (git tracked files only)..." - # Use `git archive` if possible, it respects .gitignore and skips .git. - if git -C "$REPO_ROOT" rev-parse >/dev/null 2>&1; then - mkdir -p "$TMP_MNT/repo" - git -C "$REPO_ROOT" archive --format=tar HEAD \ - | tar -x -C "$TMP_MNT/repo" - else - mkdir -p "$TMP_MNT/repo" - cp -aT "$REPO_ROOT" "$TMP_MNT/repo" - fi - ;; - 2) - # Run as the invoking user, not root, so ~ resolves correctly. - user_home="$(getent passwd "${SUDO_USER:-$USER}" | cut -d: -f6)" - if compgen -G "$user_home/.ssh/*.pub" >/dev/null; then - mkdir -p "$TMP_MNT/ssh-keys" - cp "$user_home"/.ssh/*.pub "$TMP_MNT/ssh-keys/" - log "Copied $(ls "$user_home"/.ssh/*.pub | wc -l) public key(s)." - else - warn "no .pub keys found in $user_home/.ssh" - fi - ;; - 3) - read -r -e -p "Path to copy: " extra_path - extra_path="${extra_path/#\~/$HOME}" - if [[ -e "$extra_path" ]]; then - cp -a "$extra_path" "$TMP_MNT/" - log "Copied $extra_path" - else - warn "not found: $extra_path" - fi - ;; - 4) - info "Type your note. End with a line containing only EOF:" - : >"$TMP_MNT/NOTE.txt" - while IFS= read -r line; do - [[ "$line" == "EOF" ]] && break - printf '%s\n' "$line" >>"$TMP_MNT/NOTE.txt" - done - ;; - *) warn "unknown option: $tok" ;; - esac - done - - sync - umount "$TMP_MNT" - rmdir "$TMP_MNT" - TMP_MNT="" +if ! dd if="$ISO_PATH" of="$DEVICE" bs=4M status=progress conv=fsync oflag=direct; then + err "dd failed." + # Surface the most recent kernel complaints about this device so the + # user can distinguish bad-block / hardware failure ("Medium Error", + # "Hardware Error") from cable/port flakiness ("usb ... reset", + # "Communication failure") without having to dig through dmesg. + dev_short="$(basename "$DEVICE")" + if dmesg_out="$(dmesg 2>/dev/null | grep -E "(${dev_short}|usb [0-9]+-[0-9]+)" | tail -10)" \ + && [[ -n "$dmesg_out" ]]; then + warn "Recent kernel messages mentioning ${dev_short} or USB events:" + printf '%s\n' "$dmesg_out" | sed 's/^/ /' >&2 + warn "Look for 'Medium Error' / 'Hardware Error' (bad flash → replace stick)," + warn "or 'reset' / 'Communication failure' (cable/port → try a different one)." + else + warn "Run \`sudo dmesg | tail -30\` to see why the kernel rejected the write." + fi + exit 5 fi - - -### Done ### - -log "Final sync..." +log "Final sync (flushing kernel buffers; may take a moment)..." sync blockdev --flushbufs "$DEVICE" 2>/dev/null || true + + echo log "${BOLD}Done.${RESET} You can unplug $DEVICE now." echo info "Boot the new machine from this USB. Once at the installer shell:" -info " - If you copied the repo, it's on the EXTRAS partition (mountable as exFAT)." -info " - To install: partition the target disk, mount at /mnt, then" -info " nixos-install --flake /mnt/extras/repo#" +info " - Connect to the network (Ethernet, or 'sudo systemctl start wpa_supplicant' + 'wpa_cli', or 'nmtui' on the graphical ISO)." +info " - Clone your repo: git clone /tmp/notfiles" +info " - Partition the target disk, mount at /mnt, then:" +info " nixos-install --flake /tmp/notfiles#" echo diff --git a/scripts/register-host.py b/scripts/register-host.py index a02ace9..a36a5d0 100644 --- a/scripts/register-host.py +++ b/scripts/register-host.py @@ -13,7 +13,6 @@ from pathlib import Path from textwrap import dedent -### Color helpers ### def _supports_color() -> bool: return sys.stderr.isatty() @@ -42,8 +41,6 @@ def die(msg: str, code: int = 1) -> None: -### Validation ### - # RFC 1123 hostname: 1-63 chars, [a-z0-9-], no leading/trailing dash. HOST_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") @@ -53,8 +50,6 @@ def valid_hostname(name: str) -> bool: -### Templates ### - DEFAULT_NIX_TEMPLATE = dedent("""\ {{ pkgs, ... }}: {{ imports = [ @@ -105,7 +100,6 @@ PLACEHOLDER_HARDWARE = dedent("""\ -### Helpers ### def add_to_flake(flake_path: Path, name: str, *, template_host: str | None = None) -> None: """ @@ -119,13 +113,11 @@ def add_to_flake(flake_path: Path, name: str, *, template_host: str | None = Non """ src = flake_path.read_text() - # Find `nixosConfigurations = {`; be flexible about whitespace. m = re.search(r"nixosConfigurations\s*=\s*\{", src) if not m: die(f"could not find `nixosConfigurations = {{` in {flake_path}") open_pos = m.end() - 1 # index of the '{' - # Brace-match to find the closing '}' of this attrset. depth = 0 close_pos = None @@ -250,7 +242,7 @@ def add_to_flake(flake_path: Path, name: str, *, template_host: str | None = Non def host_in_flake(flake_path: Path, name: str) -> bool: - """Cheap textual check; does flake.nix already define this host?""" + """Cheap textual check, does flake.nix already define this host?""" src = flake_path.read_text() return bool( re.search(rf"(^|\W){re.escape(name)}\s*=\s*nixpkgs\.lib\.nixosSystem", src) @@ -269,6 +261,7 @@ def find_template_host(flake_path: Path) -> str | None: return m2.group(1) if m2 else None + def main() -> int: p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("name", help="hostname for the new host (lowercase, RFC 1123)") @@ -291,9 +284,9 @@ def main() -> int: die(f"host `{args.name}` already defined in {flake} — refusing to touch anything") template_host = find_template_host(flake) - log(f"repo: {repo}") - log(f"new host: {args.name}") - log(f"template host: {template_host or ''}") + log(f"repo: {repo}") + log(f"new host: {args.name}") + log(f"template host: {template_host or ''}") if args.dry_run: warn("dry-run: nothing written")