#!/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-.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 </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 <