#!/usr/bin/env bash # build.sh — Build personal Debian ISO with my prefs + per-variant config. # # Base: Debian 13.4 trixie stable netinst. Forky daily was rejected because # of recurring kernel/udeb-skew bugs (Debian #1106117) producing "no kernel # modules" failures during install. trixie is a coherent snapshot; we still # need `intel_iommu=off` for MBA 6,1 SSD detection but bake that into grub. # # Apple firmware boots from the ESP partition exposed by the El Torito EFI # image, NOT from the iso9660 namespace. Custom grub.cfg goes into the FAT # image inside the ISO via mtools (no root, no loop mount). # # Output: out/debian-s8ns--.iso # out/debian-s8ns--.iso.sha256 # out/debian-s8ns--.creds (mode 0600, install pwds) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="$SCRIPT_DIR/work" OUT_DIR="$SCRIPT_DIR/out" # Defaults VARIANT="" HOSTNAME_OPT="" SSH_PUBKEY="${SSH_PUBKEY:-$HOME/.ssh/id_ed25519.pub}" DISK="" USERNAME="${USERNAME:-user}" OUT_ISO="" TS_AUTH_KEY="" # Tri-state: "" means "use variant default"; "0"/"1" override variant. INTERACTIVE_LUKS_OPT="" INTERACTIVE_HOSTNAME_OPT="" USER_PW_PLAIN_OPT="" DEBIAN_VERSION="${DEBIAN_VERSION:-13.4.0}" # DVD-1 has GNOME + most packages bundled — supports offline install (no mirror). # netinst is too small for GNOME tasksel; was causing "Bad archive mirror" errors # on machines without ethernet at install time. BASE_URL="${BASE_URL:-https://cdimage.debian.org/debian-cd/current/amd64/iso-dvd}" ISO_NAME="debian-${DEBIAN_VERSION}-amd64-DVD-1.iso" usage() { cat <-host) --ssh-pubkey PATH SSH pubkey file (default: ~/.ssh/id_ed25519.pub; falls back to Forgejo API users/s8n/keys if missing) --disk DEV install target disk (default: variant-specific) --user NAME first sudoer (default: admin) --out FILE output ISO path (default: out/debian-s8ns-VARIANT-DATE.iso) --base-url URL override netinst URL (default: cdimage current) --ts-auth-key KEY Tailscale auth key (tskey-...) for unattended tailnet join --interactive-luks omit partman-crypto/passphrase preseed; d-i prompts at console (default: ON for server, OFF for laptop/vanilla) --interactive-hostname omit netcfg/get_hostname preseed; d-i prompts (overridable via kernel cmdline hostname=NAME) (default: ON for server, OFF for laptop/vanilla) --user-pw PLAIN bake this plain password (chage -d 0 forces rotate on first login). Default: random 16-char per-build (or "123" for server). -h | --help this EOF } while [[ $# -gt 0 ]]; do case "$1" in --variant) VARIANT="$2"; shift 2;; --hostname) HOSTNAME_OPT="$2"; shift 2;; --ssh-pubkey) SSH_PUBKEY="$2"; shift 2;; --disk) DISK="$2"; shift 2;; --user) USERNAME="$2"; shift 2;; --out) OUT_ISO="$2"; shift 2;; --base-url) BASE_URL="$2"; shift 2;; --ts-auth-key) TS_AUTH_KEY="$2"; shift 2;; --interactive-luks) INTERACTIVE_LUKS_OPT=1; shift;; --no-interactive-luks) INTERACTIVE_LUKS_OPT=0; shift;; --interactive-hostname) INTERACTIVE_HOSTNAME_OPT=1; shift;; --no-interactive-hostname) INTERACTIVE_HOSTNAME_OPT=0; shift;; --user-pw) USER_PW_PLAIN_OPT="$2"; shift 2;; -h|--help) usage; exit 0;; *) echo "ERR: unknown arg: $1" >&2; usage; exit 1;; esac done # Validate [[ -n "$VARIANT" ]] || { echo "ERR: --variant required" >&2; usage; exit 1; } VARIANT_FILE="$SCRIPT_DIR/variants/$VARIANT.cfg" [[ -f "$VARIANT_FILE" ]] || { echo "ERR: variant not found: $VARIANT_FILE" >&2; exit 1; } # Source variant config (sets VARIANT_NAME, VARIANT_VOLID, GRUB_PARAMS, DEFAULT_DISK, # TASKSEL_TASKS, PACKAGES_LIST, POST_INSTALL_SCRIPTS; optional: INTERACTIVE_LUKS, # INTERACTIVE_HOSTNAME, USER_PW_PLAIN_DEFAULT, PRESEED_PRIORITY) # shellcheck source=/dev/null source "$VARIANT_FILE" # Resolve interactive flags: CLI override > variant default > 0 INTERACTIVE_LUKS="${INTERACTIVE_LUKS_OPT:-${INTERACTIVE_LUKS:-0}}" INTERACTIVE_HOSTNAME="${INTERACTIVE_HOSTNAME_OPT:-${INTERACTIVE_HOSTNAME:-0}}" PRESEED_PRIORITY="${PRESEED_PRIORITY:-critical}" # Apply --hostname (HOSTNAME_OPT, NOT $HOSTNAME — bash exposes the system # hostname via $HOSTNAME so a -n test would always pass). HOSTNAME_FINAL="${HOSTNAME_OPT:-${VARIANT}-host}" [[ "$HOSTNAME_FINAL" =~ ^[a-z0-9][a-z0-9-]{0,62}$ ]] \ || { echo "ERR: invalid hostname '$HOSTNAME_FINAL' (lowercase alnum + hyphens, 1-63 chars)" >&2; exit 1; } [[ -n "$DISK" ]] || DISK="$DEFAULT_DISK" [[ -n "$OUT_ISO" ]] || OUT_ISO="$OUT_DIR/debian-s8ns-${VARIANT}-$(date +%Y%m%d).iso" CREDS_FILE="${OUT_ISO%.iso}.creds" # Resolve SSH pubkey (-s = file exists AND non-empty) PUBKEY_CONTENT="" if [[ -s "$SSH_PUBKEY" ]]; then PUBKEY_CONTENT="$(cat "$SSH_PUBKEY")" echo "[*] SSH pubkey: $SSH_PUBKEY" elif [[ -f "$HOME/.config/veilor-forgejo-pat.txt" ]]; then echo "[*] SSH pubkey file missing or empty, fetching from Forgejo (user s8n)..." PAT="$(cat "$HOME/.config/veilor-forgejo-pat.txt")" PUBKEY_CONTENT="$(curl -sH "Authorization: token $PAT" \ 'https://git.s8n.ru/api/v1/users/s8n/keys' \ | python3 -c 'import json,sys; [print(k["key"]) for k in json.load(sys.stdin)]')" fi [[ -n "$PUBKEY_CONTENT" ]] || { echo "ERR: no SSH pubkey resolved." >&2; exit 1; } # Tools for cmd in curl xorriso sha256sum python3 mkpasswd mcopy mdir; do command -v "$cmd" >/dev/null \ || { echo "ERR: missing $cmd. Fedora: dnf install whois mtools xorriso curl python3" >&2; exit 1; } done mkdir -p "$WORK_DIR" "$OUT_DIR" cd "$WORK_DIR" # === 1. Generate per-build credentials === # `head -c N /dev/urandom | base64 | tr -dc '...'` order matters: head reads # fixed bytes from urandom (no SIGPIPE risk), then base64 + tr clean up. # Going `tr | head` instead aborts under set -o pipefail because tr gets # SIGPIPE when head closes its end (exit 141). echo "[*] Generating per-build credentials..." # User-pw resolution: --user-pw > variant USER_PW_PLAIN_DEFAULT > random if [[ -n "$USER_PW_PLAIN_OPT" ]]; then USER_PW_PLAIN="$USER_PW_PLAIN_OPT" elif [[ -n "${USER_PW_PLAIN_DEFAULT:-}" ]]; then USER_PW_PLAIN="$USER_PW_PLAIN_DEFAULT" else USER_PW_PLAIN="$(head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 16)" fi USER_PW_CRYPTED="$(printf '%s' "$USER_PW_PLAIN" | mkpasswd -m yescrypt -s)" LUKS_INSTALL_PW="$(head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 24)" # LUKS_FINAL_PW is generated and rotated by late_command on the target system. # We don't store it in the ISO at all — only the throwaway LUKS_INSTALL_PW # touches the ISO image bytes, and that gets killed in late_command before reboot. # === 2. Download base ISO (cached) === if [[ ! -f "$ISO_NAME" ]]; then echo "[*] Downloading $ISO_NAME from $BASE_URL ..." curl -fL --remote-name-all "$BASE_URL/$ISO_NAME" "$BASE_URL/SHA256SUMS" else echo "[*] Using cached $ISO_NAME (delete work/$ISO_NAME to refresh)" curl -fLo SHA256SUMS "$BASE_URL/SHA256SUMS" fi # === 3. Verify checksum (anchored — no substring false positives) === echo "[*] Verifying SHA256..." sha256sum -c --ignore-missing SHA256SUMS 2>&1 | grep -q "^${ISO_NAME}: OK$" \ || { echo "ERR: SHA256 verification failed for $ISO_NAME." >&2; exit 1; } echo "[OK] checksum" # === 4. Render preseed.cfg from template === # sed-escape pubkey for delimiter `|` and replacement chars `&` `\` echo "[*] Rendering preseed.cfg for variant=$VARIANT..." ESCAPED_PUBKEY="$(printf '%s\n' "$PUBKEY_CONTENT" | sed -e 's/[\\&|]/\\&/g')" ESCAPED_HASH="$(printf '%s' "$USER_PW_CRYPTED" | sed -e 's/[\\&|]/\\&/g')" ESCAPED_TS_KEY="$(printf '%s' "$TS_AUTH_KEY" | sed -e 's/[\\&|]/\\&/g')" sed \ -e "s|@HOSTNAME@|$HOSTNAME_FINAL|g" \ -e "s|@USERNAME@|$USERNAME|g" \ -e "s|@DISK@|$DISK|g" \ -e "s|@SSH_PUBKEY@|$ESCAPED_PUBKEY|g" \ -e "s|@USER_PW_CRYPTED@|$ESCAPED_HASH|g" \ -e "s|@LUKS_INSTALL_PW@|$LUKS_INSTALL_PW|g" \ -e "s|@TASKSEL_TASKS@|$TASKSEL_TASKS|g" \ -e "s|@VARIANT@|$VARIANT|g" \ -e "s|@TS_AUTH_KEY@|$ESCAPED_TS_KEY|g" \ "$SCRIPT_DIR/shared/preseed.tpl" > preseed.cfg # Interactive overrides — strip preseed lines so d-i prompts the operator. # When INTERACTIVE_LUKS, also strip the late_command luks-rekey step (the # install passphrase user typed at the prompt is unknown to us — can't rekey). if [[ "$INTERACTIVE_LUKS" == "1" ]]; then echo "[*] INTERACTIVE_LUKS=1 — stripping LUKS preseed + rekey from late_command" # Match passphrase AND passphrase-again (both `partman-crypto/passphrase*`). sed -i \ -e '/^d-i partman-crypto\/passphrase/d' \ -e '/luks-rekey\.sh /d' \ preseed.cfg fi if [[ "$INTERACTIVE_HOSTNAME" == "1" ]]; then echo "[*] INTERACTIVE_HOSTNAME=1 — stripping hostname preseed (cmdline overrides)" sed -i \ -e '/^d-i netcfg\/get_hostname /d' \ -e '/^d-i netcfg\/hostname /d' \ preseed.cfg fi chmod 644 preseed.cfg # === 5. Render grub-overlay.cfg === echo "[*] Rendering grub-overlay.cfg..." sed \ -e "s|@GRUB_PARAMS@|$GRUB_PARAMS|g" \ -e "s|@VARIANT@|$VARIANT|g" \ -e "s|@VOLID@|$VARIANT_VOLID|g" \ -e "s|@PRIORITY@|$PRESEED_PRIORITY|g" \ "$SCRIPT_DIR/shared/grub-overlay.cfg.tpl" > grub-overlay.cfg # === 6. Bundle post-install payload (lands at /cdrom/postinstall/ in ISO) === echo "[*] Bundling post-install payload..." rm -rf payload && mkdir -p payload/scripts cp "$SCRIPT_DIR/shared/packages/$PACKAGES_LIST" payload/extra.list for s in "${POST_INSTALL_SCRIPTS[@]}"; do cp "$SCRIPT_DIR/shared/post-install/$s" "payload/scripts/$s" chmod +x "payload/scripts/$s" done # Tailscale auth-key passes via this file (chmod 600); 30-tailscale.sh reads it. if [[ -n "$TS_AUTH_KEY" ]]; then printf '%s\n' "$TS_AUTH_KEY" > payload/ts-auth-key chmod 600 payload/ts-auth-key fi # Driver script — runs in-target via late_command. Already inside chroot. cat > payload/run.sh <<'RUNSH' #!/bin/sh # run.sh — runs inside chroot (via `in-target sh -e`), with /proc /sys /dev # already bind-mounted by d-i. Loops scripts in lex order. set -eu LOG=/var/log/s8n-post-install.log exec >"$LOG" 2>&1 echo "[s8n] post-install start: $(date -u +%FT%TZ)" PAYLOAD=/root/s8n-postinstall if [ ! -d "$PAYLOAD/scripts" ]; then echo "[s8n] ERROR: $PAYLOAD/scripts missing" exit 1 fi for s in "$PAYLOAD"/scripts/*.sh; do echo "[s8n] running $(basename "$s")" /bin/sh -e "$s" || { echo "[s8n] ERROR $(basename "$s") exited $?"; exit 1; } done echo "[s8n] post-install done: $(date -u +%FT%TZ)" RUNSH chmod +x payload/run.sh # LUKS rekey runs OUTSIDE chroot (cryptsetup needs /target/dev access). # late_command invokes this with sh -e from the installer's environment. cat > payload/luks-rekey.sh <<'LUKSSH' #!/bin/sh # luks-rekey.sh — runs from late_command in the d-i environment (NOT in-target). # Adds a fresh random key, verifies it, kills the throwaway slot 0. # New passphrase written to /target/root/luks-pw.txt mode 0600. set -eu LOG=/var/log/s8n-luks-rekey.log exec >"$LOG" 2>&1 OLD_PW="$1" # passed in by late_command CRYPT_DEV=$(blkid -t TYPE=crypto_LUKS -o device | head -1) [ -n "$CRYPT_DEV" ] || { echo "[luks] ERROR: no LUKS device found"; exit 1; } echo "[luks] rotating keyslots on $CRYPT_DEV" NEW_PW=$(tr -dc 'A-Za-z0-9' /target/root/luks-pw.txt chmod 0600 /target/root/luks-pw.txt echo "[luks] rotation complete; new pw in /target/root/luks-pw.txt" LUKSSH chmod +x payload/luks-rekey.sh # === 7. Patch BIOS-mode boot config (isolinux/txt.cfg) for legacy USB boot === echo "[*] Patching legacy boot config..." rm -rf boot-configs && mkdir -p boot-configs xorriso -osirrox on -indev "$ISO_NAME" \ -extract /isolinux/txt.cfg boot-configs/txt.cfg \ -extract /boot/grub/grub.cfg boot-configs/grub-main.cfg \ -extract /boot/grub/efi.img boot-configs/efi.img \ 2>/dev/null || true chmod -R u+w boot-configs/ # Inject preseed args into legacy isolinux + main grub.cfg # (isolinux only fires on legacy/CSM boot; main grub.cfg fires on EFI when # the ESP grub.cfg chains to it. ESP grub.cfg gets fully replaced below.) # Scrub upstream `priority=critical` first; otherwise our injected priority # stacks with the existing one in submenus and the duplicate is sketchy. sed -i "s|priority=critical |priority=$PRESEED_PRIORITY |g" boot-configs/grub-main.cfg boot-configs/txt.cfg sed -i "s|--- quiet|auto=true priority=$PRESEED_PRIORITY file=/cdrom/preseed.cfg $GRUB_PARAMS --- quiet|g" \ boot-configs/grub-main.cfg sed -i '1i set default="0"\nset timeout=5\n' boot-configs/grub-main.cfg sed -i "s|append vga=788 initrd=/install.amd/initrd.gz|append auto=true priority=$PRESEED_PRIORITY file=/cdrom/preseed.cfg $GRUB_PARAMS vga=788 initrd=/install.amd/initrd.gz|g" \ boot-configs/txt.cfg # === 8. Patch ESP FAT image (this is what Apple firmware actually reads) === # Note: paths inside the FAT image are stored as lowercase (/efi/debian/grub.cfg). # mtools is mostly case-insensitive but verify against the stored case for safety. echo "[*] Patching ESP FAT image (mtools, no root)..." mcopy -i boot-configs/efi.img -o grub-overlay.cfg ::/efi/debian/grub.cfg # Verify the file exists and has our content (size match against our overlay). EXPECTED_SIZE=$(stat -c%s grub-overlay.cfg) ACTUAL_SIZE=$(mdir -i boot-configs/efi.img ::/efi/debian/grub.cfg 2>/dev/null \ | awk '/grub *cfg/ { print $3; exit }') [[ "$ACTUAL_SIZE" == "$EXPECTED_SIZE" ]] \ || { echo "ERR: ESP grub.cfg size mismatch (expected=$EXPECTED_SIZE got=$ACTUAL_SIZE)" >&2; exit 1; } echo "[OK] ESP grub.cfg replaced inside efi.img ($EXPECTED_SIZE bytes)" # === 9. Re-master ISO with overlays + payload === echo "[*] Packing $OUT_ISO ..." # In-place: cp source, then xorriso -dev. -boot_image any keep is REQUIRED to # preserve the El Torito boot catalog AND keep its pointer to the patched # /boot/grub/efi.img — without this, UEFI firmware reads the OLD pre-patch # FAT image even though the iso9660 file is updated. cp -f "$ISO_NAME" "$OUT_ISO" xorriso -dev "$OUT_ISO" \ -boot_image any keep \ -volid "$VARIANT_VOLID" \ -map preseed.cfg /preseed.cfg \ -map payload /postinstall \ -map boot-configs/grub-main.cfg /boot/grub/grub.cfg \ -map boot-configs/txt.cfg /isolinux/txt.cfg \ -map boot-configs/efi.img /boot/grub/efi.img \ -map grub-overlay.cfg /EFI/debian/grub.cfg \ -commit # === 10. Direct-write patched efi.img into the ISO's ESP partition location === # xorriso's -dev + -map updates the iso9660 namespace entry for /boot/grub/efi.img, # but the ACTUAL ESP partition (referenced by MBR partition #2 AND El Torito # catalog UEFI entry) lives at a fixed LBA in the ISO that doesn't move when # we replace the iso9660 file. After dd-flashing the ISO to USB, the kernel # exposes sda2 by reading bytes at the MBR-partition-2 LBA — which is unchanged. # Fix: direct-dd our patched FAT image into that LBA. ESP_LBA=$(xorriso -indev "$OUT_ISO" -report_el_torito plain 2>&1 \ | awk '/El Torito boot img/ && /UEFI/ {print $NF; exit}') [[ -n "$ESP_LBA" ]] || { echo "ERR: cannot find ESP LBA from El Torito catalog" >&2; exit 1; } echo "[*] Direct-writing efi.img to ISO at LBA $ESP_LBA..." dd if=boot-configs/efi.img of="$OUT_ISO" \ bs=2048 seek="$ESP_LBA" \ conv=notrunc status=none echo "[OK] ESP partition now contains patched grub.cfg overlay" # === 11. Strip APM driver descriptor (single yellow icon on Apple) === echo "[*] Stripping APM driver descriptor..." PT=$(mktemp) dd if="$OUT_ISO" of="$PT" bs=1 skip=446 count=66 status=none dd if=/dev/zero of="$OUT_ISO" bs=512 count=16 conv=notrunc status=none dd if="$PT" of="$OUT_ISO" bs=1 seek=446 count=66 conv=notrunc status=none rm -f "$PT" # === 12. Output credentials file (mode 0600) === case "$DISK" in /dev/nvme*|/dev/mmcblk*) LUKS_PART="${DISK}p3" ;; *) LUKS_PART="${DISK}3" ;; esac umask 077 cat > "$CREDS_FILE" < "$OUT_ISO.sha256" echo echo "[OK] Built: $OUT_ISO ($SIZE)" echo " SHA256: $(awk '{print $1}' "$OUT_ISO.sha256")" echo " Creds: $CREDS_FILE (mode 0600)" echo echo "Flash to USB:" echo " ./flash.sh /dev/sdX $OUT_ISO" echo echo "Variant = $VARIANT" echo "Hostname = $HOSTNAME_FINAL" echo "User = $USERNAME" echo "Disk = $DISK" echo "Grub params = $GRUB_PARAMS" echo "Tasks = $TASKSEL_TASKS" [[ -n "$TS_AUTH_KEY" ]] && echo "Tailscale = auth-key baked, will join on first boot" echo echo "User pw = $USER_PW_PLAIN (forced rotate first login)" if [[ "$INTERACTIVE_LUKS" == "1" ]]; then echo "LUKS pw = INTERACTIVE — d-i prompts at console (no rekey)" else echo "LUKS pw = rotated by installer; read /root/luks-pw.txt on first boot" fi if [[ "$INTERACTIVE_HOSTNAME" == "1" ]]; then echo "Hostname = INTERACTIVE (override via kernel cmdline 'hostname=NAME')" fi echo "Priority = $PRESEED_PRIORITY"