Add an unattended-install ISO builder

Boot the stick, pick one GRUB entry, walk away: it wipes the target disk, installs Ubuntu
26.04.1 with the NVIDIA driver and codecs, and runs setup.sh on first boot to install the
apps. build-iso.sh does the image surgery in a container, so the build machine needs nothing
but podman.

The wipe is pinned to one disk serial (the 990 PRO). The Data drive can't match it, and an
unmatched disk stops the install rather than guessing — so the stick can't eat another
machine either. The autoinstall entry is not the GRUB default, so an accidental boot lands in
the ordinary Ubuntu installer.

Verified by running the install in a VM with two virtual NVMe drives carrying the real
serials: unattended start to finish, target partitioned and installed, Data disk byte-for-byte
identical, and late-commands left /opt/main-desktop plus the enabled first-boot service in
place. Testing caught the autoinstall args landing after '---' (where they reach the installed
system instead of the installer, and do nothing), and the first-boot run adding root rather
than ckoch to the docker group, since runuser leaves $USER alone.

setup.sh gains an unattended mode for that first-boot run, waits out the dpkg lock that
Ubuntu's own boot-time upgrades hold, waits for snapd to finish seeding before removing the
Firefox snap, and skips the driver step on machines with no NVIDIA card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 10:01:09 -04:00
co-authored by Claude Opus 5
parent 8b26c5d693
commit 4739fb5e0d
7 changed files with 427 additions and 80 deletions
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env bash
# Build the unattended-install ISO: the stock Ubuntu desktop ISO, plus this repo, plus an
# autoinstall config that wipes ONE disk matched by serial. The image surgery runs in a
# container, so nothing needs installing on the machine you build from (Bazzite included).
#
# scripts/build-iso.sh # downloads the Ubuntu ISO if build/ lacks it
# ISO=/path/to/ubuntu-26.04.1-desktop-amd64.iso scripts/build-iso.sh
# PASSWORD_HASH='$6$...' scripts/build-iso.sh # skip the password prompt
#
# Output: build/main-desktop-<date>.iso
set -euo pipefail
UBUNTU_RELEASE=26.04
UBUNTU_POINT=26.04.1
ISO_NAME="ubuntu-${UBUNTU_POINT}-desktop-amd64.iso"
BASE_URL="https://releases.ubuntu.com/${UBUNTU_RELEASE}"
BUILDER_IMAGE=docker.io/library/ubuntu:26.04
# --- the machine this ISO is built for -------------------------------------------------
# Serial of the ONLY disk the install may touch: the 1 TB Samsung 990 PRO. The 970 EVO Plus
# ("Data", second Steam library, serial S6P7NS0X656465Z) never matches this.
TARGET_SERIAL='*S73VNU0Y101555E*'
TARGET_MODEL='Samsung SSD 990 PRO 1TB'
USERNAME=ckoch
REALNAME='Christopher Koch'
HOSTNAME=main-desktop
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD="$REPO/build"
OUT="${OUT:-$BUILD/main-desktop-$(date +%Y%m%d).iso}"
step() { printf '\n\033[1;34m== %s ==\033[0m\n' "$*"; }
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
for t in podman curl openssl; do command -v "$t" >/dev/null || die "need $t"; done
mkdir -p "$BUILD"
# --- 1. the login password (hashed into the ISO) ---------------------------------------
step "Login password for $USERNAME"
if [[ -z ${PASSWORD_HASH:-} ]]; then
echo "Hashed into the ISO, so keep the stick to yourself."
read -rsp " password: " p1; echo
read -rsp " again: " p2; echo
[[ -n $p1 && $p1 == "$p2" ]] || die "passwords empty or didn't match"
PASSWORD_HASH=$(openssl passwd -6 "$p1")
unset p1 p2
fi
[[ $PASSWORD_HASH == \$6\$* ]] || die "PASSWORD_HASH doesn't look like a SHA-512 crypt hash"
# --- 2. the Ubuntu ISO ------------------------------------------------------------------
step "Ubuntu $UBUNTU_POINT desktop ISO"
SRC_ISO="${ISO:-$BUILD/$ISO_NAME}"
if [[ ! -f $SRC_ISO ]]; then
echo "downloading $ISO_NAME (~6 GB, resumable)"
curl -fL -C - --retry 3 -o "$SRC_ISO" "$BASE_URL/$ISO_NAME"
fi
curl -fsSL -o "$BUILD/SHA256SUMS" "$BASE_URL/SHA256SUMS"
want=$(awk -v f="*$(basename "$SRC_ISO")" '$2 == f {print $1}' "$BUILD/SHA256SUMS")
if [[ -n $want ]]; then
echo "checking sha256…"
got=$(sha256sum "$SRC_ISO" | cut -d' ' -f1)
[[ $got == "$want" ]] || die "checksum mismatch on $SRC_ISO — delete it and rerun"
echo "ok: $got"
else
echo "warning: $(basename "$SRC_ISO") isn't listed in SHA256SUMS — skipping the check"
fi
# --- 3. what goes on the ISO ------------------------------------------------------------
step "Staging the payload"
PAYLOAD="$BUILD/payload"
rm -rf "$PAYLOAD"
mkdir -p "$PAYLOAD/nocloud" "$PAYLOAD/main-desktop/scripts" "$PAYLOAD/main-desktop/iso"
sed -e "s|@@PASSWORD_HASH@@|$PASSWORD_HASH|" \
-e "s|@@USERNAME@@|$USERNAME|g" \
-e "s|@@REALNAME@@|$REALNAME|" \
-e "s|@@HOSTNAME@@|$HOSTNAME|" \
-e "s|@@TARGET_SERIAL@@|$TARGET_SERIAL|" \
-e "s|@@TARGET_MODEL@@|$TARGET_MODEL|" \
"$REPO/iso/user-data.in" > "$PAYLOAD/nocloud/user-data"
: > "$PAYLOAD/nocloud/meta-data" # cloud-init wants this to exist, empty is fine
! grep -qE '@@[A-Z_]+@@' "$PAYLOAD/nocloud/user-data" \
|| die "a placeholder went unfilled in user-data: $(grep -oE '@@[A-Z_]+@@' "$PAYLOAD/nocloud/user-data" | sort -u | tr '\n' ' ')"
# Only the files the installed system needs — never build/ (it holds multi-GB ISOs).
cp "$REPO/README.md" "$REPO/flatpaks.txt" "$PAYLOAD/main-desktop/"
cp "$REPO/scripts/setup.sh" "$REPO/scripts/firstboot.sh" "$PAYLOAD/main-desktop/scripts/"
cp "$REPO/iso/main-desktop-firstboot.service" "$PAYLOAD/main-desktop/iso/"
du -sh "$PAYLOAD"
# --- 4. ISO surgery, in a container -----------------------------------------------------
step "Rebuilding the ISO"
cat > "$BUILD/_in-container.sh" <<'CONTAINER'
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq >/dev/null
apt-get install -y -qq xorriso >/dev/null
cd /work
SRC=$1 OUT=$2
# Reuse the original boot entry's kernel/initrd paths rather than hardcoding them.
rm -rf grub && mkdir grub
xorriso -osirrox on -indev "$SRC" -extract /boot/grub/grub.cfg grub/grub.cfg 2>/dev/null
kernel_line=$(grep -m1 -E '^[[:space:]]*linux[[:space:]]' grub/grub.cfg)
initrd_line=$(grep -m1 -E '^[[:space:]]*initrd[[:space:]]' grub/grub.cfg)
[ -n "$kernel_line" ] && [ -n "$initrd_line" ] || { echo "couldn't read boot entry from grub.cfg"; exit 1; }
# autoinstall = don't ask for confirmation; ds=nocloud points cloud-init at /nocloud.
# The ';' has to be escaped for GRUB's parser.
args='autoinstall ds=nocloud\;s=/cdrom/nocloud/'
# These args are for the INSTALLER, so they must go before the '---' separator — Ubuntu's
# stock line is "linux /casper/vmlinuz --- quiet splash", and anything after '---' is
# handed to the installed system instead, where autoinstall would do nothing.
# Done with string splitting, not sed, so the backslash GRUB needs survives.
if [ "${kernel_line#*---}" != "$kernel_line" ]; then
new_kernel="${kernel_line%%---*}$args ---${kernel_line#*---}"
else
new_kernel="$kernel_line $args"
fi
cp grub/grub.cfg grub/grub.cfg.new
cat >> grub/grub.cfg.new <<EOF
menuentry 'Install main-desktop UNATTENDED — ERASES the target disk' {
set gfxpayload=keep
$new_kernel
$initrd_line
}
EOF
chmod +w grub/grub.cfg.new
# 'replay' reproduces the original's boot equipment (El Torito, MBR, the appended EFI
# partition) exactly, instead of trying to rebuild it from scratch.
map_args=(-map /work/payload/nocloud /nocloud
-map /work/payload/main-desktop /main-desktop
-map /work/grub/grub.cfg.new /boot/grub/grub.cfg)
if xorriso -osirrox on -indev "$SRC" -lsl /boot/grub/loopback.cfg >/dev/null 2>&1; then
cp grub/grub.cfg.new grub/loopback.cfg.new
map_args+=(-map /work/grub/loopback.cfg.new /boot/grub/loopback.cfg)
fi
# The volume id is left alone on purpose: casper and GRUB can look the media up by label.
xorriso -indev "$SRC" -outdev "$OUT" \
-boot_image any replay \
-overwrite on \
"${map_args[@]}" \
-commit
CONTAINER
rm -f "$OUT" # xorriso won't write into an output image that already holds data
podman run --rm -v "$BUILD:/work:Z" -w /work "$BUILDER_IMAGE" \
bash /work/_in-container.sh "/work/$(basename "$SRC_ISO")" "/work/$(basename "$OUT")"
step "Done"
ls -lh "$OUT"
cat <<EOF
Write it to a USB stick (check the device name first with lsblk!):
sudo dd if=$OUT of=/dev/sdX bs=4M status=progress oflag=direct conv=fsync
Boot it, then pick the last GRUB entry:
"Install main-desktop UNATTENDED — ERASES the target disk"
It wipes only $TARGET_MODEL (serial $TARGET_SERIAL) and stops if that disk isn't there.
EOF
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Runs once, on the first boot after an install from the ISO. Runs setup.sh as the
# desktop user, then takes back the passwordless sudo the installer granted for it.
# Log: /var/log/main-desktop-firstboot.log (and the journal).
set -uo pipefail
LOG=/var/log/main-desktop-firstboot.log
SUDOERS=/etc/sudoers.d/99-main-desktop-firstboot
STAMP=/var/lib/main-desktop-firstboot.done
# Whatever the installer called the account it made — uid 1000 is the desktop user.
USER_NAME=$(id -nu 1000 2>/dev/null || true)
cleanup() {
rm -f "$SUDOERS" # never leave passwordless sudo lying around, pass or fail
systemctl disable main-desktop-firstboot.service >/dev/null 2>&1 || true
: > "$STAMP"
}
trap cleanup EXIT
exec > >(tee -a "$LOG") 2>&1
echo "=== main-desktop first boot: $(date -Is) ==="
if [[ -z $USER_NAME ]]; then
echo "no uid 1000 user found — run /opt/main-desktop/scripts/setup.sh by hand"
exit 1
fi
echo "running setup.sh as $USER_NAME"
if runuser -u "$USER_NAME" -- env HOME="/home/$USER_NAME" UNATTENDED=1 \
bash /opt/main-desktop/scripts/setup.sh; then
echo "=== setup.sh finished $(date -Is) ==="
echo "Log out and back in to pick up the docker and kvm groups."
else
echo "=== setup.sh FAILED (see above) $(date -Is) ==="
echo "Fix whatever it complained about, then re-run it by hand (it's safe to re-run):"
echo " /opt/main-desktop/scripts/setup.sh"
fi
+55 -23
View File
@@ -2,8 +2,14 @@
# Fresh Ubuntu 26.04 LTS -> my desktop, in one pass. Run as your user; it sudos as needed.
# Safe to re-run: each step checks before it changes anything. Reboot when it finishes,
# then run `sudo flatpak update` once so the Flatpak apps get the NVIDIA GL runtime.
#
# UNATTENDED=1 (or --unattended) asks nothing: used by the first-boot run after an
# install from the ISO, where the driver is already in place.
set -euo pipefail
UNATTENDED=${UNATTENDED:-}
[[ ${1:-} == --unattended ]] && UNATTENDED=1
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Second NVMe (970 EVO Plus, label "Data"): SteamLibrary + emulator files. Never formatted.
@@ -17,13 +23,20 @@ MARVIN_CREDS=/etc/samba/credentials-marvin
step() { printf '\n\033[1;34m== %s ==\033[0m\n' "$*"; }
warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
apt_install() { sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"; }
# Ubuntu's own unattended-upgrades run right after boot, so wait for the dpkg lock rather
# than failing the moment it's held — that's the difference between a first-boot run that
# works and one that dies in the first minute.
APT_OPTS=(-o DPkg::Lock::Timeout=600)
apt_install() { sudo DEBIAN_FRONTEND=noninteractive apt-get "${APT_OPTS[@]}" install -y "$@"; }
[[ $EUID -ne 0 ]] || die "run as your user, not with sudo"
. /etc/os-release
[[ $ID == ubuntu ]] || die "expected Ubuntu, found $ID"
[[ $VERSION_ID == 26.04 ]] || warn "written for 26.04; this is $VERSION_ID — carrying on"
CODENAME=${UBUNTU_CODENAME:-$VERSION_CODENAME}
# Who we actually are. Not $USER: the first-boot service reaches this through runuser, which
# leaves $USER as root, and group changes would land on the wrong account.
ME=$(id -un)
# Ask for the password once and keep sudo alive for the whole run (Flatpaks take a while).
sudo -v
@@ -33,7 +46,7 @@ trap 'kill "$keepalive" 2>/dev/null' EXIT
# ---------------------------------------------------------------------------------------
step "Apt repositories"
sudo apt-get update
sudo apt-get "${APT_OPTS[@]}" update
apt_install curl gpg ca-certificates software-properties-common
for c in universe multiverse restricted; do sudo add-apt-repository -y -n "$c"; done
sudo dpkg --add-architecture i386 # Steam + 32-bit game libraries
@@ -96,8 +109,8 @@ if [[ ! -f /etc/apt/sources.list.d/claude-desktop.list ]]; then
| sudo tee /etc/apt/sources.list.d/claude-desktop.list >/dev/null
fi
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y
sudo apt-get "${APT_OPTS[@]}" update
sudo DEBIAN_FRONTEND=noninteractive apt-get "${APT_OPTS[@]}" full-upgrade -y
# ---------------------------------------------------------------------------------------
step "Packages"
@@ -115,27 +128,32 @@ apt_install \
# ---------------------------------------------------------------------------------------
step "NVIDIA driver (open kernel modules — the only kind the RTX 5090 supports)"
apt_install ubuntu-drivers-common
installed_driver() {
{ dpkg-query -W -f='${Status} ${Package}\n' 'nvidia-driver-*' 2>/dev/null || true; } \
| awk '/^install ok installed / {print $4}' | sort -V | tail -1
}
if [[ $(installed_driver) != *-open ]]; then
ubuntu-drivers devices 2>/dev/null | grep -E 'model|driver' || true
# Picks Ubuntu's recommended branch plus its prebuilt, signed kernel modules (no DKMS).
sudo ubuntu-drivers install
if ! grep -qx '0x10de' /sys/bus/pci/devices/*/vendor 2>/dev/null; then
# No NVIDIA card here at all (a VM, say) — nothing to install, and nothing to fail over.
warn "no NVIDIA GPU on this machine — skipping the driver"
else
apt_install ubuntu-drivers-common
if [[ $(installed_driver) != *-open ]]; then
ubuntu-drivers devices 2>/dev/null | grep -E 'model|driver' || true
# Picks Ubuntu's recommended branch plus its prebuilt, signed kernel modules (no DKMS).
sudo ubuntu-drivers install
fi
driver=$(installed_driver)
[[ $driver == nvidia-driver-*-open ]] \
|| die "got '${driver:-no driver}', but the 5090 needs an -open driver — check 'ubuntu-drivers devices'"
branch=${driver#nvidia-driver-}; branch=${branch%-open}
apt_install "libnvidia-gl-$branch:i386" # 32-bit GL/Vulkan for Steam and Proton
echo "using $driver"
fi
driver=$(installed_driver)
[[ $driver == nvidia-driver-*-open ]] \
|| die "got '${driver:-no driver}', but the 5090 needs an -open driver — check 'ubuntu-drivers devices'"
branch=${driver#nvidia-driver-}; branch=${branch%-open}
apt_install "libnvidia-gl-$branch:i386" # 32-bit GL/Vulkan for Steam and Proton
echo "using $driver"
# ---------------------------------------------------------------------------------------
step "Docker, GPU containers, Claude's Cowork VM"
sudo usermod -aG docker "$USER" # docker without sudo (after reboot)
getent group kvm >/dev/null && sudo usermod -aG kvm "$USER" # Cowork needs /dev/kvm + vhost-vsock
sudo usermod -aG docker "$ME" # docker without sudo (after reboot)
getent group kvm >/dev/null && sudo usermod -aG kvm "$ME" # Cowork needs /dev/kvm + vhost-vsock
echo vhost_vsock | sudo tee /etc/modules-load.d/vhost_vsock.conf >/dev/null
if ! grep -q nvidia /etc/docker/daemon.json 2>/dev/null; then
sudo nvidia-ctk runtime configure --runtime=docker # enables `docker run --gpus all`
@@ -150,11 +168,18 @@ sudo flatpak install -y --noninteractive --system flathub "${apps[@]}"
# Firefox's read-only peek at landingPage, as set by hand on Bazzite
flatpak override --user org.mozilla.firefox --filesystem="$HOME/Documents/git/landingPage:ro"
# Firefox/Thunderbird come from Flathub instead, so restoring ~/.var/app brings the old
# profiles back untouched. Drop Ubuntu's snaps and the debs that pull them back in.
# Firefox/Thunderbird come from Flathub instead, so copying ~/.var/app over brings the old
# profiles with them. Drop Ubuntu's snaps and the debs that pull them back in.
# On a first boot snapd may still be seeding, and a remove during that fails — wait it out,
# and treat a failure here as a warning rather than losing the rest of the run.
sudo snap wait system seed.loaded 2>/dev/null || true
for s in firefox thunderbird; do
if snap list "$s" >/dev/null 2>&1; then sudo snap remove --purge "$s"; fi
if dpkg -s "$s" >/dev/null 2>&1; then sudo apt-get remove -y "$s"; fi
if snap list "$s" >/dev/null 2>&1; then
sudo snap remove --purge "$s" || warn "couldn't remove the $s snap — remove it by hand later"
fi
if dpkg -s "$s" >/dev/null 2>&1; then
sudo apt-get "${APT_OPTS[@]}" remove -y "$s" || warn "couldn't remove the $s deb"
fi
done
xdg-settings set default-web-browser org.mozilla.firefox.desktop 2>/dev/null || true
@@ -228,7 +253,7 @@ if ! grep -q "$DATA_UUID" /etc/fstab; then
fi
sudo blkid -U "$DATA_UUID" >/dev/null || warn "Data drive ($DATA_UUID) not found — fstab entry is nofail, so boot is fine"
if [[ ! -f $MARVIN_CREDS ]]; then
if [[ ! -f $MARVIN_CREDS && -z $UNATTENDED ]]; then
echo "server-marvin SMB share — press Enter to skip mounting it."
read -rp " username: " smb_user
if [[ -n $smb_user ]]; then
@@ -252,7 +277,13 @@ step "Small carry-overs from Bazzite"
echo 'options bluetooth disable_ertm=1' | sudo tee /etc/modprobe.d/bluetooth-xbox.conf >/dev/null
step "Done"
cat <<'EOF'
if [[ -n $UNATTENDED ]]; then
cat <<'EOF'
Log out and back in for the docker and kvm groups.
Then sign in to your apps, and add /mnt/data/SteamLibrary in Steam > Settings > Storage.
EOF
else
cat <<'EOF'
Reboot now — the NVIDIA driver and your docker/kvm groups only take effect after that.
Then, once:
@@ -262,3 +293,4 @@ Then, once:
Then sign in to things, and add /mnt/data/SteamLibrary in Steam > Settings > Storage.
EOF
fi