DriveStage/gui/assets/blkstage.sh

1383 lines
55 KiB
Bash
Executable File

#!/usr/bin/env bash
# ============================================================================
# blkstage.sh — DriveStage Engine (BlkStage)
# ----------------------------------------------------------------------------
# A Linux-native multi-payload USB toolkit. Inspired by Easy2Boot and RMPrepUSB
# (Steve Si, https://easy2boot.xyz); implemented from scratch for the ext4
# and UEFI era. No code derived from those projects.
#
# Layout created on the target drive:
# [ Drive: /dev/sdX or /dev/nvmeXn1 ]
# ├─ Partition 1: ESP (FAT32, ~512MB) -> /boot/efi (UEFI GRUB)
# ├─ Partition 2: BIOS Boot (unformatted, 1MB) (GRUB GPT)
# └─ Partition 3: Payloads (ext4, remainder) -> /payloads (ISOs/tars)
#
# Subcommands:
# setup <device> Partition + format + install GRUB + create dirs
# deploy <device> <file> Copy a .iso / .tar / .img payload (extract tarballs)
# scan <device> Extract tarballs + refresh auto-scan grub.cfg UUID
# list <device> Show registered payloads and sizes
# shell <device> Mount partitions and open a helper shell
# clean <device> Wipe partition table (DESTRUCTIVE)
#
# Usage:
# sudo ./blkstage.sh setup /dev/sdX
# sudo ./blkstage.sh deploy /dev/sdX ./ubuntu-24.04.iso
# sudo ./blkstage.sh scan /dev/sdX
#
# Requires: bash 4+, parted, sfdisk, mkfs.fat, mkfs.ext4, grub2/grub-efi,
# rsync, tar, blkid, findmnt
#
# Author: Jeremy Anderson <info@dcos.net>
# License: MIT
# ============================================================================
set -euo pipefail
shopt -s nullglob
shopt -s extglob
# ----------------------------------------------------------------------------
# Configuration (override via environment or /etc/drivestage.conf)
# ----------------------------------------------------------------------------
ESP_SIZE_MB="${ESP_SIZE_MB:-512}"
BIOS_BOOT_SIZE_MB="${BIOS_BOOT_SIZE_MB:-1}"
PAYLOAD_LABEL="${PAYLOAD_LABEL:-DRIVESTAGE}"
ESP_LABEL="${ESP_LABEL:-DRIVESTAGE-EFI}"
MOUNT_BASE="${MOUNT_BASE:-/mnt/drivestage}"
CONF_FILE="${CONF_FILE:-/etc/drivestage.conf}"
# Load optional config (with ownership/mode safety check)
if [[ -r "$CONF_FILE" ]]; then
_owner="$(stat -c %u "$CONF_FILE" 2>/dev/null || echo 1)"
_mode="$(stat -c %a "$CONF_FILE" 2>/dev/null || echo 777)"
if [[ "$_owner" -eq 0 && "$_mode" =~ ^6[04][0-9]$ ]]; then
# shellcheck disable=SC1090 # CONF_FILE is intentionally dynamic; validated above.
source "$CONF_FILE"
else
echo "[!] Skipping unsafe config file (owner=$_owner mode=$_mode): $CONF_FILE" >&2
fi
unset _owner _mode
fi
# ----------------------------------------------------------------------------
# Logging / pretty output
# ----------------------------------------------------------------------------
if [[ -t 1 ]]; then
C_RED=$'\033[0;31m'; C_GREEN=$'\033[0;32m'; C_YELLOW=$'\033[1;33m'
C_BLUE=$'\033[0;34m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'; C_NC=$'\033[0m'
else
C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE=""; C_BOLD=""; C_DIM=""; C_NC=""
fi
log_info() { printf '%s[+]%s %s\n' "$C_GREEN" "$C_NC" "$*"; }
log_warn() { printf '%s[!]%s %s\n' "$C_YELLOW" "$C_NC" "$*"; }
log_error() { printf '%s[-]%s %s\n' "$C_RED" "$C_NC" "$*" >&2; }
log_step() { printf '%s[*]%s %s\n' "$C_BLUE" "$C_NC" "$*"; }
log_dim() { printf '%s%s%s\n' "$C_DIM" "$*" "$C_NC"; }
die() { log_error "$*"; exit 1; }
abort(){ log_error "$*"; exit 130; }
# ----------------------------------------------------------------------------
# Pre-flight checks
# ----------------------------------------------------------------------------
check_root() {
[[ $EUID -eq 0 ]] || die "Must run as root (try: sudo $0 $*)."
}
check_commands() {
# Required utilities — all standard Linux tooling. Anything missing
# produces a single consolidated error with per-distro install hints.
local -a required=(
sfdisk parted mkfs.fat mkfs.ext4 blkid findmnt tar rsync
wipefs blockdev partprobe udevadm
)
local missing=()
local cmd
for cmd in "${required[@]}"; do
command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
done
# GRUB install binary has two names across distros — accept either.
# Step-down resolution in grub_install_cmd() picks the right one at use time.
command -v grub-install >/dev/null 2>&1 \
|| command -v grub2-install >/dev/null 2>&1 \
|| missing+=("grub-install (or grub2-install on Fedora)")
(( ${#missing[@]} )) || return 0
die "Missing required commands: ${missing[*]}\n" \
" Debian/Ubuntu: apt install parted dosfstools e2fsprogs grub-pc-bin grub-efi-amd64-bin util-linux rsync tar\n" \
" Fedora: dnf install parted dosfstools e2fsprogs grub2-pc grub2-efi-x64 grub2-tools util-linux rsync tar\n" \
" Arch: pacman -S parted dosfstools e2fsprogs grub efibootmgr util-linux rsync tar"
}
# Resolve the correct GRUB install binary name for this system.
# Step-down order: Debian/Ubuntu name first, then Fedora/RHEL, then a
# deterministic default that produces a clear error if neither is present.
grub_install_cmd() {
command -v grub-install >/dev/null 2>&1 && { printf 'grub-install'; return; }
command -v grub2-install >/dev/null 2>&1 && { printf 'grub2-install'; return; }
printf 'grub-install' # let it fail with a clear error
}
# ----------------------------------------------------------------------------
# Disk helpers
# ----------------------------------------------------------------------------
# Resolve a device argument to a canonical /dev/XXX block device path.
# Resolution order (step-down, Unix philosophy):
# 1. Accept /dev/* paths as-is.
# 2. Accept /disk/by-{id,uuid,path,partlabel} symlinks via readlink.
# 3. Accept bare names (sdb, nvme0n1) by prepending /dev/.
# 4. Verify the result is a block device; abort otherwise.
resolve_device() {
local dev="$1"
case "$dev" in
/dev/*) : ;; # already canonical
*/dev/*) : ;; # disk/by-id/... symlink
*) dev="/dev/$dev" ;; # bare name
esac
dev="$(readlink -f "$dev")"
[[ -b "$dev" ]] || die "Not a block device: $dev"
printf '%s' "$dev"
}
# Is this a whole disk (not a partition)?
is_whole_disk() {
local dev="$1"
# /dev/sda, /dev/nvme0n1, /dev/mmcblk0 are whole disks
# /dev/sda1, /dev/nvme0n1p2 are partitions
[[ "$dev" =~ ^/dev/(sd[a-z]+|nvme[0-9]+n[0-9]+|mmcblk[0-9]+|vd[a-z]+|loop[0-9]+)$ ]]
}
# Get the partition node naming scheme for a disk.
# /dev/sda -> /dev/sda1, /dev/sda2 (suffix is just the number)
# /dev/nvme0n1 -> /dev/nvme0n1p1 (suffix is p<number>)
# /dev/mmcblk0 -> /dev/mmcblk0p1 (suffix is p<number>)
part_prefix() {
local disk="$1"
case "$disk" in
/dev/nvme*|/dev/mmcblk*|/dev/loop*) printf '%sp' "$disk" ;;
*) printf '%s' "$disk" ;;
esac
}
# Total size of a block device in megabytes (integer).
disk_size_mb() {
local disk="$1"
local bytes
bytes="$(blockdev --getsize64 "$disk")"
echo $(( bytes / 1024 / 1024 ))
}
# List candidate removable disks for interactive selection.
list_removable_disks() {
local name path size_gb
log_step "Detecting removable/USB candidate disks:"
printf ' %-12s %-10s %-12s %s\n' "DEVICE" "SIZE" "TRANSPORT" "MODEL"
printf ' %-12s %-10s %-12s %s\n' "------" "----" "---------" "-----"
for path in /sys/block/*; do
name="$(basename "$path")"
case "$name" in
loop*|ram*|sr*) continue ;;
esac
# Read transport / model / size directly. `cat`/`ls` over /sys is
# safe here — entries are kernel-controlled and have known shapes.
local rm tran model size_bytes
rm="$(cat "$path/removable" 2>/dev/null || echo 0)"
tran="$(readlink -f "$path/device" 2>/dev/null | sed 's|.*/||' || echo '?')"
model="$(tr -d ' ' < "$path/device/model" 2>/dev/null || echo '?')"
size_bytes="$(blockdev --getsize64 "/dev/$name" 2>/dev/null || echo 0)"
size_gb="$(awk "BEGIN{printf \"%.1f\", $size_bytes/1024/1024/1024}")"
# Show if removable OR explicitly USB transport
if [[ "$rm" == 1 || "$tran" == *usb* || "$tran" == *mmc* ]]; then
printf ' %-12s %-10s %-12s %s\n' "/dev/$name" "${size_gb}GB" "${tran:-?}" "${model:-?}"
fi
done
}
# Interactively pick a disk if none provided.
pick_disk_interactive() {
list_removable_disks
echo
read -rp "Enter device to use (e.g. /dev/sdb): " dev
[[ -n "$dev" ]] || die "No device selected."
resolve_device "$dev"
}
# ----------------------------------------------------------------------------
# Safety guards
# ----------------------------------------------------------------------------
# Refuse to operate on the running OS disk.
# Detects plain partitions (/dev/sda3), LVM (/dev/mapper/vg-root),
# LUKS (/dev/mapper/cryptroot), mdadm (/dev/md0), and any device-mapper
# descendant by walking lsblk's parent (PKNAME) chain from each mounted
# SOURCE back to a whole-disk ancestor.
#
# Critical-mount check uses a single case statement (step-down) rather
# than a for-loop+if: pattern lists in case are the POSIX-native way to
# express "any of these prefixes match."
is_critical_mount() {
case "$1" in
/|/boot|/boot/efi|/usr|/var|/home|/etc|/efi|/opt|/srv) return 0 ;;
/boot/*|/usr/*|/var/*|/home/*|/etc/*|/efi/*|/opt/*|/srv/*) return 0 ;;
*) return 1 ;;
esac
}
guard_root_disk() {
local disk="$1"
local mnt dev src holder
while read -r mnt dev _; do
is_critical_mount "$mnt" || continue
# Walk the holder chain: dev -> PKNAME -> PKNAME -> ... -> whole disk.
src="$dev"
while [[ -n "$src" ]]; do
# Direct match: dev is $disk itself, or a partition of $disk
# (e.g. /dev/sda3 is a partition of /dev/sda).
case "$src" in
"$disk"|"$disk"?*) die "Refusing: '$disk' backs mounted filesystem '$mnt' (likely your OS disk)." ;;
esac
# Resolve device-mapper / LVM / LUKS / mdadm ancestry via lsblk PKNAME.
holder="$(lsblk -ndo PKNAME "$src" 2>/dev/null || true)"
[[ "$holder" == "$src" ]] && break # prevent infinite loop
src="$holder"
done
done < <(findmnt -nno TARGET,SOURCE)
}
confirm_destructive() {
local disk="$1"
local action="${2:-format}"
echo
log_warn "About to $action device: $disk"
log_warn "ALL DATA ON $disk WILL BE LOST."
local size_gb
size_gb="$(awk "BEGIN{printf \"%.1f\", $(blockdev --getsize64 "$disk")/1024/1024/1024}")"
printf ' Device : %s\n' "$disk"
printf ' Size : %s GB\n' "$size_gb"
printf ' Model : %s\n' "$(lsblk -ndo MODEL "$disk" 2>/dev/null || echo '?')"
echo
read -rp "Type the device name (e.g. $(basename "$disk")) to confirm: " confirm
[[ "$confirm" == "$(basename "$disk")" ]] || abort "Aborted (no match)."
}
# ----------------------------------------------------------------------------
# Partitioning
# ----------------------------------------------------------------------------
wipe_signatures() {
local disk="$1"
log_step "Wiping existing signatures on $disk..."
wipefs -af "$disk" 2>/dev/null || die "wipefs failed on $disk (disk may be read-only or in use)"
dd if=/dev/zero of="$disk" bs=1M count=1 status=none conv=notrunc 2>/dev/null || true
partprobe "$disk" 2>/dev/null || true
udevadm settle 2>/dev/null || true
}
# Create the 3-partition GPT layout using sfdisk.
# 1: ESP FAT32, EFI System Partition type (EF00)
# 2: BIOS Boot 1 MiB, BIOS boot type (EF02)
# 3: Payloads ext4, Linux filesystem type (8300)
create_partitions() {
local disk="$1"
local total_mb
total_mb="$(disk_size_mb "$disk")"
log_step "Creating GPT partition table on $disk (total ${total_mb} MiB)..."
sfdisk "$disk" <<EOF
label: gpt
unit: sectors
first-lba: 2048
# ESP — UEFI System Partition (FAT32)
start=2048, size=$(( ESP_SIZE_MB * 1024 * 1024 / 512 )), type=EF00, name=ESP
# BIOS Boot Partition (for GRUB i386-pc embedding, unformatted)
start=$(( 2048 + ESP_SIZE_MB * 1024 * 1024 / 512 )), size=$(( BIOS_BOOT_SIZE_MB * 1024 * 1024 / 512 )), type=EF02, name=BIOSBOOT
# Payload Partition (ext4, holds ISOs / rootfs tarballs / images)
start=$(( 2048 + (ESP_SIZE_MB + BIOS_BOOT_SIZE_MB) * 1024 * 1024 / 512 )), type=8300, name=PAYLOADS
EOF
# Re-read partition table
partprobe "$disk" 2>/dev/null || true
udevadm settle 2>/dev/null || true
sleep 1
log_info "Partition table created."
sfdisk -l "$disk" 2>/dev/null | sed 's/^/ /'
}
# Get the partition device node for slot N on a disk (waits for udev).
get_partition() {
local disk="$1" n="$2"
local prefix
prefix="$(part_prefix "$disk")"
local p="${prefix}${n}"
# Wait up to 30s for udev to materialize the node
local tries=0
while [[ ! -b "$p" && $tries -lt 300 ]]; do
sleep 0.1
tries=$((tries + 1))
done
[[ -b "$p" ]] || die "Partition $p not found after 30s. Check dmesg or run 'partprobe $disk' manually."
printf '%s' "$p"
}
format_partitions() {
local disk="$1"
local p1 p3
p1="$(get_partition "$disk" 1)"
p3="$(get_partition "$disk" 3)"
log_step "Formatting ESP ($p1) as FAT32..."
mkfs.fat -F 32 -n "$ESP_LABEL" "$p1"
log_step "Formatting payload partition ($p3) as ext4..."
# -F = force (in case of stale signature), -L = label, -m 1 = 1% reserved blocks
mkfs.ext4 -F -L "$PAYLOAD_LABEL" -m 1 -O ^64bit "$p3" 2>&1 | sed 's/^/ /'
log_info "Filesystems created."
}
# ----------------------------------------------------------------------------
# Mounting
# ----------------------------------------------------------------------------
mount_payload_partition() {
local disk="$1"
local p3 mnt
p3="$(get_partition "$disk" 3)"
mnt="${MOUNT_BASE}/payload"
mkdir -p "$mnt"
if findmnt -n "$p3" >/dev/null; then
log_dim " $p3 already mounted at $(findmnt -n -o TARGET "$p3")"
else
log_step "Mounting $p3 at $mnt..."
mount "$p3" "$mnt"
fi
printf '%s' "$mnt"
}
mount_esp_partition() {
local disk="$1" payload_mnt="$2"
local p1 mnt
p1="$(get_partition "$disk" 1)"
mnt="${payload_mnt}/boot/efi"
mkdir -p "$mnt"
if findmnt -n "$p1" >/dev/null; then
log_dim " $p1 already mounted at $(findmnt -n -o TARGET "$p1")"
else
log_step "Mounting $p1 at $mnt..."
mount "$p1" "$mnt"
fi
printf '%s' "$mnt"
}
mount_all() {
local disk="$1"
local payload_mnt esp_mnt
payload_mnt="$(mount_payload_partition "$disk")"
esp_mnt="$(mount_esp_partition "$disk" "$payload_mnt")"
printf '%s\n%s\n' "$payload_mnt" "$esp_mnt"
}
unmount_all() {
local disk="$1"
log_step "Unmounting partitions on $disk..."
# Primary: recursive unmount of anything under MOUNT_BASE (handles nested ESP).
# Step-down: try umount -R; if it fails or MOUNT_BASE is missing, fall through
# to per-partition cleanup. `|| true` keeps the line from aborting under
# `set -e` when umount -R encounters a busy mount.
if [[ -d "$MOUNT_BASE" ]]; then
umount -R "$MOUNT_BASE" 2>/dev/null || true
fi
# Step-down per-partition cleanup: deepest mount first.
# Resolves any stragglers that umount -R missed (rare, but seen on
# busy loop devices during test_loop_device.sh).
local p
for p in \
"$(get_partition "$disk" 1 2>/dev/null || true)" \
"$(get_partition "$disk" 3 2>/dev/null || true)"; do
[[ -n "$p" ]] || continue
findmnt -n -o TARGET --source "$p" 2>/dev/null \
| tac \
| while read -r m; do umount "$m" 2>/dev/null || true; done
done
sync
}
# ----------------------------------------------------------------------------
# Directory structure
# ----------------------------------------------------------------------------
create_directory_structure() {
local payload_mnt="$1"
log_step "Creating payload directory structure..."
local -a dirs=(
"$payload_mnt/payloads"
"$payload_mnt/payloads/isos"
"$payload_mnt/payloads/rootfs_tarballs"
"$payload_mnt/payloads/rootfs"
"$payload_mnt/payloads/images"
"$payload_mnt/payloads/overlay"
"$payload_mnt/boot/grub"
"$payload_mnt/boot/grub/conf.d"
"$payload_mnt/boot/grub/templates"
"$payload_mnt/boot/efi"
)
for d in "${dirs[@]}"; do mkdir -p "$d"; done
# README drop-zone
cat > "$payload_mnt/payloads/README.md" <<'EOF'
# DriveStage — Payload Drop Zone
**Just drop files in and boot. No scan command needed.**
GRUB auto-discovers payloads at boot time.
## payloads/isos/ ← Drop .iso files here
Live ISOs are auto-detected at boot. Distro-specific kernel arguments are
applied based on filename pattern matching (ubuntu, arch, debian, fedora,
manjaro, opensuse, kali, alpine, and 20+ more). Unrecognized ISOs fall back
to GRUB's loopback.cfg chainload.
Examples:
ubuntu-24.04-desktop-amd64.iso
archlinux-2026.08.01-x86_64.iso
debian-live-12.5.0-amd64-gnome.iso
fedora-workstation-live-x86_64-40.iso
## payloads/rootfs/ ← Drop pre-extracted rootfs directories here
Any subdirectory containing /boot/vmlinuz* or /boot/bzImage is auto-discovered.
Kernel + initrd are loaded directly from the ext4 filesystem (no loopback needed).
## payloads/rootfs_tarballs/ ← Drop .tar(.gz/.xz/.zst) here
Tarballs CANNOT be booted directly — GRUB has no untar capability at boot time.
Run `./blkstage.sh scan /dev/sdX` once on your host to extract them into
payloads/rootfs/<name>/, after which they're auto-discovered.
Or use `./blkstage.sh deploy /dev/sdX rootfs.tar.gz` which extracts on copy.
## payloads/images/ ← Drop .img/.raw files here (experimental)
GRUB cannot chainload raw disk images. These appear as menu entries with an
informational message. Convert to ISO or extract the kernel+initrd instead.
## payloads/overlay/ ← (reserved for future overlayfs persistence)
## Adding custom menu entries
Edit /boot/grub/custom.cfg on this partition. It's sourced at boot time
after the auto-scan, so your custom entries appear at the bottom of the menu.
## Workflow summary
1. Drop .iso files into payloads/isos/
2. Eject the USB
3. Boot from it
4. GRUB auto-scans and presents a menu
EOF
log_info "Directory structure ready."
}
# ----------------------------------------------------------------------------
# GRUB installation
# ----------------------------------------------------------------------------
install_grub() {
local disk="$1" payload_mnt="$2" esp_mnt="$3"
local grub_failed=0
local grub_bin
grub_bin="$(grub_install_cmd)"
log_step "Installing GRUB for UEFI (x86_64-efi)..."
"$grub_bin" \
--target=x86_64-efi \
--efi-directory="$esp_mnt" \
--boot-directory="${payload_mnt}/boot" \
--removable \
--recheck 2>&1 | sed 's/^/ /' || grub_failed=1
if [[ "$grub_failed" -ne 0 ]]; then
die "UEFI GRUB install failed. Aborting setup to prevent an unbootable drive."
fi
log_step "Installing GRUB for BIOS (i386-pc)..."
"$grub_bin" \
--target=i386-pc \
--boot-directory="${payload_mnt}/boot" \
--recheck \
"$disk" 2>&1 | sed 's/^/ /' || grub_failed=1
if [[ "$grub_failed" -ne 0 ]]; then
die "BIOS GRUB install failed. Aborting setup to prevent an unbootable drive."
fi
log_info "GRUB installed for both UEFI and BIOS targets."
}
# Generate the top-level grub.cfg with BOOT-TIME AUTO-SCAN logic.
# No host-side menu generation required — GRUB discovers payloads at boot.
generate_grub_main_cfg() {
local payload_mnt="$1"
local p3 p3_uuid
p3="$(get_partition "$CURRENT_DISK" 3)"
p3_uuid="$(blkid -s UUID -o value "$p3")"
local grub_cfg="${payload_mnt}/boot/grub/grub.cfg"
log_step "Generating auto-scan $grub_cfg..."
cat > "$grub_cfg" <<'GRUBEOF'
# ============================================================================
# grub.cfg — Boot-time Auto-Scan Version
# ----------------------------------------------------------------------------
# This config AUTO-DISCovers payloads at boot time. Just drop files into
# payloads/{isos,rootfs,images}/ on the ext4 partition and reboot — no
# host-side 'scan' command required.
#
# Recognized payload locations:
# /payloads/isos/*.iso -> distro-aware loopback boot
# /payloads/rootfs/*/ -> direct kernel + initrd boot
# /payloads/images/*.img -> experimental chainload note
#
# Generated by blkstage.sh
# ============================================================================
# --- Payload partition UUID (baked in at setup time) ---
GRUB_EOF_UUID_PLACEHOLDER
# --- Boot timeout / default ---
set timeout=30
set timeout_style=menu
set default=0
# --- Required GRUB modules ---
insmod part_gpt
insmod ext2
insmod fat
insmod loopback
insmod regexp
insmod all_video
insmod gfxterm
insmod normal
insmod test
insmod search
insmod search_fs_uuid
insmod search_label
insmod configfile
# --- Locate the payload partition by UUID ---
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
# ============================================================================
# Helper: emit the correct ISO menuentry based on filename pattern matching.
# Args: $1 = iso_path (full path), $2 = iso_name (basename)
# ============================================================================
function ds_iso_entry {
set iso_path="$1"
set iso_name="$2"
set distro="unknown"
# --- Distro detection via regexp (POSIX BRE, case-sensitive) ---
# Most real-world ISO filenames are lowercase, so we match lowercase.
if regexp --quiet --ignore-case 'ubuntu' "$iso_name"; then set distro="ubuntu"
elif regexp --quiet --ignore-case 'archlinux' "$iso_name"; then set distro="arch"
elif regexp --quiet --ignore-case 'arch-' "$iso_name"; then set distro="arch"
elif regexp --quiet --ignore-case 'debian.*live' "$iso_name"; then set distro="debian-live"
elif regexp --quiet --ignore-case 'debian-live' "$iso_name"; then set distro="debian-live"
elif regexp --quiet --ignore-case 'debian' "$iso_name"; then set distro="debian"
elif regexp --quiet --ignore-case 'manjaro' "$iso_name"; then set distro="manjaro"
elif regexp --quiet --ignore-case 'fedora' "$iso_name"; then set distro="fedora"
elif regexp --quiet --ignore-case 'centos-stream' "$iso_name"; then set distro="centos_stream"
elif regexp --quiet --ignore-case 'centos_stream' "$iso_name"; then set distro="centos_stream"
elif regexp --quiet --ignore-case 'centos' "$iso_name"; then set distro="centos"
elif regexp --quiet --ignore-case 'rocky' "$iso_name"; then set distro="rocky"
elif regexp --quiet --ignore-case 'alma' "$iso_name"; then set distro="alma"
elif regexp --quiet --ignore-case 'tumbleweed' "$iso_name"; then set distro="opensuse"
elif regexp --quiet --ignore-case 'leap' "$iso_name"; then set distro="opensuse"
elif regexp --quiet --ignore-case 'opensuse' "$iso_name"; then set distro="opensuse"
elif regexp --quiet --ignore-case 'kali' "$iso_name"; then set distro="kali"
elif regexp --quiet --ignore-case 'parrot' "$iso_name"; then set distro="parrot"
elif regexp --quiet --ignore-case 'tails' "$iso_name"; then set distro="tails"
elif regexp --quiet --ignore-case 'knoppix' "$iso_name"; then set distro="knoppix"
elif regexp --quiet --ignore-case 'systemrescue' "$iso_name"; then set distro="systemrescue"
elif regexp --quiet --ignore-case 'system-rescue' "$iso_name"; then set distro="systemrescue"
elif regexp --quiet --ignore-case 'alpine' "$iso_name"; then set distro="alpine"
elif regexp --quiet --ignore-case 'void' "$iso_name"; then set distro="void"
elif regexp --quiet --ignore-case 'gentoo' "$iso_name"; then set distro="gentoo"
elif regexp --quiet --ignore-case 'slax' "$iso_name"; then set distro="slax"
elif regexp --quiet --ignore-case 'tinycore' "$iso_name"; then set distro="tinycore"
elif regexp --quiet --ignore-case 'clonezilla' "$iso_name"; then set distro="clonezilla"
elif regexp --quiet --ignore-case 'gparted' "$iso_name"; then set distro="gparted"
elif regexp --quiet --ignore-case 'mint' "$iso_name"; then set distro="mint"
elif regexp --quiet --ignore-case 'elementary' "$iso_name"; then set distro="elementary"
elif regexp --quiet --ignore-case 'pop-os' "$iso_name"; then set distro="popos"
elif regexp --quiet --ignore-case 'pop_os' "$iso_name"; then set distro="popos"
fi
# --- Casper family (Ubuntu, Mint, elementary, Pop!_OS, Kali, Parrot, Tails) ---
if [ "$distro" = "ubuntu" ] || [ "$distro" = "mint" ] || [ "$distro" = "elementary" ] || [ "$distro" = "popos" ] || [ "$distro" = "kali" ] || [ "$distro" = "parrot" ] || [ "$distro" = "tails" ]; then
menuentry "$iso_name [casper]" "$iso_path" {
set iso_path="$1"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
loopback loop "$iso_path"
linux (loop)/casper/vmlinuz boot=casper iso-scan/filename=$iso_path quiet splash ---
initrd (loop)/casper/initrd
}
# --- Live-boot family (Debian Live, Clonezilla, GParted) ---
elif [ "$distro" = "debian-live" ] || [ "$distro" = "clonezilla" ] || [ "$distro" = "gparted" ]; then
menuentry "$iso_name [live-boot]" "$iso_path" {
set iso_path="$1"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
loopback loop "$iso_path"
linux (loop)/live/vmlinuz boot=live components findiso=$iso_path quiet splash
initrd (loop)/live/initrd.img
}
# --- Debian netinst / DVD (chainload via loopback.cfg) ---
elif [ "$distro" = "debian" ]; then
menuentry "$iso_name [debian chainload]" "$iso_path" {
set iso_path="$1"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
loopback loop "$iso_path"
if [ -f (loop)/boot/grub/loopback.cfg ]; then
configfile (loop)/boot/grub/loopback.cfg
else
echo "No loopback.cfg found in this Debian ISO."
echo "Manual menuentry may be needed in /boot/grub/custom.cfg"
sleep 5
fi
}
# --- Arch Linux ---
elif [ "$distro" = "arch" ]; then
menuentry "$iso_name [arch]" "$iso_path" {
set iso_path="$1"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
loopback loop "$iso_path"
linux (loop)/arch/boot/x86_64/vmlinuz-linux img_dev=/dev/disk/by-uuid/$ds_payload_uuid img_loop=$iso_path
initrd (loop)/arch/boot/x86_64/initramfs-linux.img
}
# --- Manjaro ---
elif [ "$distro" = "manjaro" ]; then
menuentry "$iso_name [manjaro]" "$iso_path" {
set iso_path="$1"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
loopback loop "$iso_path"
linux (loop)/boot/manjaro/x86_64/vmlinuz-x86_64 img_dev=/dev/disk/by-uuid/$ds_payload_uuid img_loop=$iso_path driver=free
initrd (loop)/boot/manjaro/x86_64/initramfs-x86_64.img
}
# --- Fedora ---
elif [ "$distro" = "fedora" ]; then
menuentry "$iso_name [fedora]" "$iso_path" {
set iso_path="$1"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
loopback loop "$iso_path"
linux (loop)/isolinux/vmlinuz root=live:CDLABEL=Fedora rd.live.image iso-scan/filename=$iso_path
initrd (loop)/isolinux/initrd.img
}
# --- RHEL family (CentOS Stream, Rocky, Alma) ---
elif [ "$distro" = "centos_stream" ] || [ "$distro" = "rocky" ] || [ "$distro" = "alma" ]; then
menuentry "$iso_name [$distro]" "$iso_path" {
set iso_path="$1"
set dname="$2"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
loopback loop "$iso_path"
linux (loop)/isolinux/vmlinuz root=live:CDLABEL=${dname}-BaseOS-x86_64 rd.live.image iso-scan/filename=$iso_path
initrd (loop)/isolinux/initrd.img
}
# --- CentOS (BIOS-era release) ---
elif [ "$distro" = "centos" ]; then
menuentry "$iso_name [centos]" "$iso_path" {
set iso_path="$1"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
loopback loop "$iso_path"
linux (loop)/isolinux/vmlinuz root=live:CDLABEL=CentOS-BaseOS-x86_64 rd.live.image iso-scan/filename=$iso_path
initrd (loop)/isolinux/initrd.img
}
# --- openSUSE ---
elif [ "$distro" = "opensuse" ]; then
menuentry "$iso_name [opensuse]" "$iso_path" {
set iso_path="$1"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
loopback loop "$iso_path"
linux (loop)/boot/x86_64/loader/linux isoboot=$iso_path
initrd (loop)/boot/x86_64/loader/initrd
}
# --- SystemRescue ---
elif [ "$distro" = "systemrescue" ]; then
menuentry "$iso_name [systemrescue]" "$iso_path" {
set iso_path="$1"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
loopback loop "$iso_path"
linux (loop)/sysresccd/boot/x86_64/vmlinuz img_label=$ds_payload_uuid img_loop=$iso_path archisobasedir=sysresccd
initrd (loop)/sysresccd/boot/x86_64/sysresccd.img
}
# --- Alpine ---
elif [ "$distro" = "alpine" ]; then
menuentry "$iso_name [alpine]" "$iso_path" {
set iso_path="$1"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
loopback loop "$iso_path"
linux (loop)/boot/vmlinuz-lts modules=loop,squashfs,sd-mod,usb-storage modloop=$iso_path/boot/modloop-lts alpine_dev=$iso_path iso-scan/filename=$iso_path
initrd (loop)/boot/initramfs-lts
}
# --- Void Linux ---
elif [ "$distro" = "void" ]; then
menuentry "$iso_name [void]" "$iso_path" {
set iso_path="$1"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
loopback loop "$iso_path"
linux (loop)/boot/vmlinuz iso-scan/filename=$iso_path root=live:CDLABEL=VOID_LIVE rd.live.image
initrd (loop)/boot/initrd
}
# --- Unknown: fall back to loopback.cfg chainload ---
else
menuentry "$iso_name [auto-chainload]" "$iso_path" {
set iso_path="$1"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
loopback loop "$iso_path"
if [ -f (loop)/boot/grub/loopback.cfg ]; then
configfile (loop)/boot/grub/loopback.cfg
elif [ -f (loop)/EFI/BOOT/grub.cfg ]; then
configfile (loop)/EFI/BOOT/grub.cfg
else
echo "No loopback.cfg found in $iso_path."
echo "This ISO needs a manual menuentry."
echo "Add one to /boot/grub/custom.cfg on the payload partition."
sleep 5
fi
}
fi
}
# ============================================================================
# Helper: emit a menuentry for a pre-extracted rootfs directory.
# Args: $1 = rootfs_dir (full path), $2 = name (basename)
# ============================================================================
function ds_rootfs_entry {
set rdir="$1"
set rname="$2"
# Detect kernel type and emit the right menuentry
if [ -f "$rdir/boot/vmlinuz-linux" ]; then
# Arch-style kernel
menuentry "$rname [arch-rootfs]" "$rdir" {
set rootfs_dir="$1"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
linux ${rootfs_dir}/boot/vmlinuz-linux root=UUID=$ds_payload_uuid rw systemd.post_install=${rootfs_dir}
initrd ${rootfs_dir}/boot/initramfs-linux.img
}
elif [ -f "$rdir/boot/vmlinuz" ]; then
# Generic vmlinuz
menuentry "$rname [rootfs]" "$rdir" {
set rootfs_dir="$1"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
linux ${rootfs_dir}/boot/vmlinuz root=UUID=$ds_payload_uuid rw init=${rootfs_dir}/sbin/init
initrd ${rootfs_dir}/boot/initrd.img
}
elif [ -f "$rdir/boot/bzImage" ]; then
# Bare-metal Linux bzImage
menuentry "$rname [linux-rootfs]" "$rdir" {
set rootfs_dir="$1"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
linux ${rootfs_dir}/boot/bzImage root=UUID=$ds_payload_uuid rw init=${rootfs_dir}/sbin/init
}
fi
}
# ============================================================================
# Boot-time auto-scan
# ============================================================================
# Clear screen and show banner
clear
echo ""
echo " ===================================================="
echo " DriveStage — Auto-Scan Payload Menu"
echo " Partition UUID: $ds_payload_uuid"
echo " ===================================================="
echo ""
# --- Scan ISOs (case-insensitive extension match) ---
for f in /payloads/isos/*.[iI][sS][oO]; do
if [ -f "$f" ]; then
# Try to extract basename via regexp; fall back to full path
set basename=""
regexp --set=basename=1 '\([^/]*\)$' "$f"
if [ -z "$basename" ]; then
set basename="$f"
fi
ds_iso_entry "$f" "$basename"
fi
done
# --- Scan pre-extracted rootfs directories ---
for d in /payloads/rootfs/*; do
if [ -d "$d" ]; then
set rname=""
regexp --set=rname=1 '\([^/]*\)$' "$d"
if [ -z "$rname" ]; then
set rname="$d"
fi
ds_rootfs_entry "$d" "$rname"
fi
done
# --- Scan raw disk images (experimental) ---
for f in /payloads/images/*.[iI][mM][gG]; do
if [ -f "$f" ]; then
set basename=""
regexp --set=basename=1 '\([^/]*\)$' "$f"
if [ -z "$basename" ]; then
set basename="$f"
fi
menuentry "$basename [raw image - experimental]" "$f" {
set img_path="$1"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
echo "Raw disk image booting is experimental."
echo "Path: $img_path"
echo ""
echo "GRUB cannot chainload raw .img files directly."
echo "Convert to ISO, or extract its kernel+initrd into payloads/rootfs/."
sleep 10
}
fi
done
for f in /payloads/images/*.[rR][aA][wW]; do
if [ -f "$f" ]; then
set basename=""
regexp --set=basename=1 '\([^/]*\)$' "$f"
if [ -z "$basename" ]; then
set basename="$f"
fi
menuentry "$basename [raw image - experimental]" "$f" {
set img_path="$1"
search --no-floppy --fs-uuid --set=root $ds_payload_uuid
echo "Raw disk image booting is experimental."
echo "Path: $img_path"
sleep 10
}
fi
done
# --- Source custom user entries if present ---
if [ -f /boot/grub/custom.cfg ]; then
source /boot/grub/custom.cfg
fi
# --- Utility entries ---
menuentry "Drop to GRUB command line" {
configfile (cmdpath)
}
menuentry "Reboot system" {
reboot
}
menuentry "Halt system" {
halt
}
GRUBEOF
# Substitute the UUID placeholder (done outside the heredoc to keep
# the rest of the GRUB script untouched by bash expansion).
sed -i "s|^GRUB_EOF_UUID_PLACEHOLDER$|set ds_payload_uuid=\"${p3_uuid}\"|" "$grub_cfg"
log_info "Auto-scan grub.cfg written. Drop ISOs into payloads/isos/ and reboot."
}
# ----------------------------------------------------------------------------
# Distro detection — bash-side pure function for testing
# ----------------------------------------------------------------------------
# This function mirrors the GRUB-side regexp cascade in ds_iso_entry (inside
# generate_grub_main_cfg's heredoc). It exists as a testable bash interface —
# the GRUB auto-scan uses its own inline matching since GRUB cannot call bash
# functions. Keep both in sync when adding distros.
# Returns "unknown" if no pattern matches.
detect_distro_profile() {
local filename="$1" lower
lower="$(printf '%s' "$filename" | tr '[:upper:]' '[:lower:]')"
case "$lower" in
*ubuntu*) printf 'ubuntu' ;;
*debian*live*|*debian*netinst*) printf 'debian' ;;
*debian*) printf 'debian' ;;
*archlinux*|*arch-*) printf 'arch' ;;
*manjaro*) printf 'manjaro' ;;
*fedora*) printf 'fedora' ;;
*centos*stream*) printf 'centos_stream' ;;
*centos*) printf 'centos' ;;
*rocky*) printf 'rocky' ;;
*alma*) printf 'alma' ;;
*opensuse*|*tumbleweed*|*leap*) printf 'opensuse' ;;
*kali*) printf 'kali' ;;
*parrot*) printf 'parrot' ;;
*tails*) printf 'tails' ;;
*knoppix*) printf 'knoppix' ;;
*systemrescue*|*system-rescue*) printf 'systemrescue' ;;
*alpine*) printf 'alpine' ;;
*void*) printf 'void' ;;
*gentoo*) printf 'gentoo' ;;
*slax*) printf 'slax' ;;
*tinycore*) printf 'tinycore' ;;
*clonezilla*) printf 'clonezilla' ;;
*gparted*) printf 'gparted' ;;
*mint*.iso) printf 'mint' ;;
*elementary*.iso) printf 'elementary' ;;
*pop_os*|*pop-os*) printf 'popos' ;;
*) printf 'unknown' ;;
esac
}
# ----------------------------------------------------------------------------
# Payload scanner / config refresher
# ----------------------------------------------------------------------------
# NOTE: With the boot-time auto-scan grub.cfg, this function no longer
# generates per-file menuentries. GRUB discovers payloads at boot time.
# This function now:
# 1. Extracts any unextracted rootfs tarballs (GRUB can't untar at boot)
# 2. Refreshes the main grub.cfg (mostly to re-bake the partition UUID
# in case the drive was re-partitioned)
# Run this after manually copying tarballs, or as a maintenance refresh.
scan_payloads() {
local payload_mnt="$1"
local count=0
# --- Extract unextracted rootfs tarballs ---------------------------------
# GRUB cannot untar at boot time, so we extract on the host.
# Once extracted, the resulting directory is auto-discovered by GRUB.
local tarball name target_dir
for tarball in "$payload_mnt"/payloads/rootfs_tarballs/*.tar \
"$payload_mnt"/payloads/rootfs_tarballs/*.tar.gz \
"$payload_mnt"/payloads/rootfs_tarballs/*.tar.xz \
"$payload_mnt"/payloads/rootfs_tarballs/*.tar.zst \
"$payload_mnt"/payloads/rootfs_tarballs/*.tgz; do
[[ -f "$tarball" ]] || continue
name="$(basename "$tarball")"
name="${name%.tar}"; name="${name%.tar.gz}"; name="${name%.tar.xz}"
name="${name%.tar.zst}"; name="${name%.tgz}"
target_dir="$payload_mnt/payloads/rootfs/$name"
local marker="$target_dir/.ds_extracted"
if [[ ! -f "$marker" ]]; then
log_step "Extracting $name -> payloads/rootfs/$name ..."
rm -rf "$target_dir"
mkdir -p "$target_dir"
tar -xf "$tarball" -C "$target_dir" --numeric-owner --checkpoint=100 --checkpoint-action=dot
touch "$marker"
echo
log_info " ROOT: $name (newly extracted)"
else
log_dim " ROOT: $name (already extracted)"
fi
count=$((count + 1))
done
# --- Refresh the main grub.cfg (re-bakes UUID) ---------------------------
generate_grub_main_cfg "$payload_mnt"
# --- Summarize what's on the drive ---------------------------------------
# Table-driven count via PAYLOAD_CATEGORIES (single source of truth).
local row subdir kind label flags
local -A count_of=()
for row in "${PAYLOAD_CATEGORIES[@]}"; do
IFS=':' read -r subdir kind label flags <<<"$row"
local base="$payload_mnt/payloads/$subdir"
local n=0
case "$kind" in
file)
# shellcheck disable=SC2206
local -a globs=($flags)
local g f
for g in "${globs[@]}"; do
for f in "$base"/$g; do
[[ -f "$f" ]] && n=$((n + 1))
done
done
;;
dir)
local d
for d in "$base"/*/; do
[[ -d "$d" ]] && n=$((n + 1))
done
;;
esac
count_of["$label"]=$n
done
log_info "Scan complete. GRUB will auto-discover at boot:"
log_info " ISOs : ${count_of[ISOs]} (in payloads/isos/)"
log_info " RootFS : ${count_of[Extracted RootFS]} (in payloads/rootfs/)"
log_info " Images : ${count_of[Raw Images]} (in payloads/images/ — experimental)"
log_info " Tarballs: $count (in payloads/rootfs_tarballs/ — extracted on host)"
log_dim " No further action needed. Just boot the USB."
}
# ----------------------------------------------------------------------------
# Payload deployment (copy a file from host -> USB)
# ----------------------------------------------------------------------------
deploy_payload() {
local disk="$1" src="$2"
local payload_mnt dest_dir dest_path
src="$(readlink -f "$src")"
[[ -f "$src" ]] || die "Source file not found: $src"
payload_mnt="$(mount_payload_partition "$disk")"
mount_esp_partition "$disk" "$payload_mnt" >/dev/null
local bn lower
bn="$(basename "$src")"
lower="$(printf '%s' "$bn" | tr '[:upper:]' '[:lower:]')"
case "$lower" in
*.iso)
dest_dir="$payload_mnt/payloads/isos"
;;
*.tar|*.tar.gz|*.tgz|*.tar.xz|*.tar.zst|*.tar.bz2)
dest_dir="$payload_mnt/payloads/rootfs_tarballs"
;;
*.img|*.raw)
dest_dir="$payload_mnt/payloads/images"
;;
*)
log_warn "Unknown file type for '$bn'. Defaulting to /payloads/."
dest_dir="$payload_mnt/payloads"
;;
esac
mkdir -p "$dest_dir"
dest_path="${dest_dir}/${bn}"
# Free-space check
local free_kb src_bytes
free_kb="$(df -P "$dest_dir" 2>/dev/null | awk 'NR==2{print $4}')"
src_bytes="$(stat -c %s "$src" 2>/dev/null || echo 0)"
if [[ -n "$free_kb" && "$src_bytes" -gt 0 ]]; then
local free_bytes=$((free_kb * 1024))
if (( src_bytes > free_bytes )); then
die "Insufficient free space on payload partition: need $((src_bytes / 1024 / 1024)) MB, have $((free_bytes / 1024 / 1024)) MB"
fi
fi
log_step "Copying $bn -> ${dest_path#"$payload_mnt"}..."
# rsync gives progress and resume capability
rsync -ah --progress "$src" "$dest_path"
sync
# For tarballs, extract immediately so GRUB can auto-discover the
# resulting directory at boot time. ISOs and images need no post-copy
# step — the boot-time auto-scan will pick them up.
case "$lower" in
*.tar|*.tar.gz|*.tgz|*.tar.xz|*.tar.zst|*.tar.bz2)
local name="$bn"
name="${name%.tar}"; name="${name%.tar.gz}"; name="${name%.tar.xz}"
name="${name%.tar.zst}"; name="${name%.tgz}"; name="${name%.tar.bz2}"
local target_dir="$payload_mnt/payloads/rootfs/$name"
local marker="$target_dir/.ds_extracted"
if [[ ! -f "$marker" ]]; then
log_step "Extracting tarball -> payloads/rootfs/$name ..."
rm -rf "$target_dir"
mkdir -p "$target_dir"
tar -xf "$dest_path" -C "$target_dir" --numeric-owner --checkpoint=100 --checkpoint-action=dot
touch "$marker"
echo
fi
;;
esac
log_info "Done. '$bn' deployed."
log_dim " No scan needed — GRUB auto-discovers payloads at boot time."
log_dim " Just eject the drive and boot from it."
}
# ----------------------------------------------------------------------------
# Listing
# ----------------------------------------------------------------------------
# Payload category table — single source of truth for both list_payloads
# and scan_payloads summary. Each row is: "<subdir>:<kind>:<label>:<flags>"
# <subdir> path under $payload_mnt/payloads/
# <kind> 'file' or 'dir'
# <label> human-readable heading
# <flags> glob extensions only used for 'file' kinds (e.g. '*.iso')
PAYLOAD_CATEGORIES=(
"isos:file:ISOs:*.iso"
"rootfs_tarballs:file:RootFS Tarballs:*"
"rootfs:dir:Extracted RootFS:"
"images:file:Raw Images:*.img *.raw"
)
list_payloads() {
local payload_mnt="$1"
log_step "Registered payloads on this drive:"
local found=0
local row subdir kind label flags
for row in "${PAYLOAD_CATEGORIES[@]}"; do
IFS=':' read -r subdir kind label flags <<<"$row"
local base="$payload_mnt/payloads/$subdir"
local -a matches=()
# Step-down discovery: build the candidate list, skip if empty.
case "$kind" in
file)
# shellcheck disable=SC2206 # intentional word-splitting on glob list
local -a globs=($flags)
local g f
for g in "${globs[@]}"; do
for f in "$base"/$g; do
[[ -f "$f" ]] && matches+=("$f")
done
done
;;
dir)
local d
for d in "$base"/*/; do
[[ -d "$d" ]] && matches+=("${d%/}")
done
;;
esac
(( ${#matches[@]} )) || continue
printf '\n %s%s:%s\n' "$C_BOLD" "$label" "$C_NC"
local m size
for m in "${matches[@]}"; do
if [[ "$kind" == dir ]]; then
size="$(du -sh "$m" | cut -f1)"
else
size="$(du -h "$m" | cut -f1)"
fi
printf ' %-50s %s\n' "$(basename "$m")" "$size"
((found++))
done
done
(( found == 0 )) && printf ' %s(empty)%s\n' "$C_DIM" "$C_NC"
echo
}
# ----------------------------------------------------------------------------
# Cleanup / wipe
# ----------------------------------------------------------------------------
clean_drive() {
local disk="$1"
if [[ "${ASSUME_YES:-0}" != "1" ]]; then
confirm_destructive "$disk" "wipe"
fi
unmount_all "$disk" 2>/dev/null || true
wipe_signatures "$disk"
log_info "Drive $disk is now blank. Run 'setup' to provision it."
}
# ----------------------------------------------------------------------------
# Helper shell
# ----------------------------------------------------------------------------
open_shell() {
local disk="$1"
local payload_mnt esp_mnt
payload_mnt="$(mount_payload_partition "$disk")"
esp_mnt="$(mount_esp_partition "$disk" "$payload_mnt")"
cat <<EOF
${C_BOLD}DriveStage helper shell${C_NC}
Disk : $disk
Payload mount : $payload_mnt
ESP mount : $esp_mnt
Useful commands:
ls \$PAYLOAD_MNT/payloads/
sudo ./blkstage.sh scan $disk
sudo ./blkstage.sh list $disk
Type 'exit' to leave. Partitions will NOT be auto-unmounted.
EOF
PAYLOAD_MNT="$payload_mnt" ESP_MNT="$esp_mnt" \
PS1='drivestage> ' bash --noprofile --norc
}
# ----------------------------------------------------------------------------
# Usage / main
# ----------------------------------------------------------------------------
usage() {
cat <<EOF
${C_BOLD}blkstage.sh${C_NC} — DriveStage Engine (BlkStage)
${C_BOLD}USAGE:${C_NC}
sudo $0 <command> <device> [options]
$0 -V, --version Print version and exit
${C_BOLD}COMMANDS:${C_NC}
setup <device> [payload-file]
Partition, format, install GRUB (UEFI+BIOS), create directory
structure, write the auto-scan grub.cfg. If a payload file is
given, deploy it after setup. After this, just drop files into
payloads/ and boot — no further commands needed.
deploy <device> <payload-file>
Copy a .iso / .tar(.gz/.xz/.zst) / .img to the appropriate
payloads/ subdirectory. Tarballs are extracted on copy.
ISOs/images need no post-copy step — GRUB auto-discovers at boot.
scan <device>
Maintenance refresh: extract any unextracted tarballs and
regenerate the auto-scan grub.cfg (re-bakes the partition UUID).
NOT required after dropping ISOs — only run this if you added
tarballs manually or repartitioned the drive.
list <device>
Show currently registered payloads and their sizes.
shell <device>
Mount the payload + ESP partitions and drop into a helper shell.
clean <device>
DANGER: wipe the partition table and all signatures on <device>.
${C_BOLD}ENVIRONMENT (override as needed):${C_NC}
ESP_SIZE_MB ESP partition size in MiB (default: 512)
BIOS_BOOT_SIZE_MB BIOS boot partition size in MiB (default: 1)
PAYLOAD_LABEL ext4 filesystem label (default: DRIVESTAGE)
ESP_LABEL FAT32 ESP label (default: DRIVESTAGE-EFI)
MOUNT_BASE Mount prefix (default: /mnt/drivestage)
CONF_FILE Optional config file to source (default: /etc/drivestage.conf)
ASSUME_YES Set to 1 to skip confirmation prompts
${C_BOLD}EXAMPLES:${C_NC}
# Provision a fresh USB drive at /dev/sdb (one-time)
sudo $0 setup /dev/sdb
# Provision + deploy an initial ISO in one go
sudo $0 setup /dev/sdb ./ubuntu-24.04.iso
# Add more ISOs later — TWO ways:
# (a) Just drag-and-drop .iso files into /mnt/drivestage/payload/payloads/isos/
# (or wherever the drive is mounted). No script needed. Boot and go.
# (b) Or use deploy for progress bar + tarball auto-extraction:
sudo $0 deploy /dev/sdb ./archlinux-2026.08.01-x86_64.iso
# List what's on the drive
sudo $0 list /dev/sdb
# Maintenance: extract tarballs + refresh grub.cfg UUID (rarely needed)
sudo $0 scan /dev/sdb
${C_BOLD}WORKFLOW (drop-and-boot, inspired by Easy2Boot):${C_NC}
1. sudo $0 setup /dev/sdb # one-time provisioning
2. Copy .iso files into payloads/isos/ # drag-and-drop, no script
3. Eject the USB and boot from it # GRUB auto-discovers payloads
4. Pick an entry from the menu # done!
${C_BOLD}NOTES:${C_NC}
- The script refuses to operate on the disk hosting / or /boot.
- Destructive operations require interactive confirmation (or ASSUME_YES=1).
- BOOT-TIME AUTO-SCAN: GRUB scans payloads/{isos,rootfs,images}/ at boot
and generates menu entries on the fly. No host-side menu generation.
- Distros recognized at boot via filename pattern matching:
Ubuntu, Debian, Arch, Manjaro, Fedora, CentOS Stream, Rocky, Alma,
openSUSE, Kali, Parrot, Tails, Knoppix, SystemRescue, Alpine, Void,
Gentoo, Slax, TinyCore, Clonezilla, GParted, Mint, elementary, Pop!_OS.
Unrecognized ISOs fall back to GRUB's loopback.cfg chainload.
- Tarballs (.tar/.tar.gz/.tar.xz/.tar.zst) require one-time host-side
extraction (GRUB can't untar at boot). Use 'deploy' or 'scan'.
EOF
}
# Globals set during setup
CURRENT_DISK=""
main() {
local cmd="${1:-}" dev="${2:-}"
[[ -n "$cmd" ]] || { usage; exit 1; }
case "$cmd" in
-h|--help|help) usage; exit 0 ;;
-V|--version) echo "drivestage 0.4.0"; exit 0 ;;
esac
check_root "$cmd"
check_commands
# Prevent concurrent invocations from racing on mount/unmount/partition operations
exec 9>/var/lock/drivestage.lock
if ! flock -n 9; then
die "Another drivestage instance is running. Wait for it to finish or remove /var/lock/drivestage.lock"
fi
# 'setup' may take an optional payload file as $3
local payload_arg="${3:-}"
# Resolve device for all commands except 'help'
if [[ -z "$dev" && "$cmd" != "help" ]]; then
dev="$(pick_disk_interactive)"
fi
local disk=""
if [[ -n "$dev" ]]; then
disk="$(resolve_device "$dev")"
is_whole_disk "$disk" || die "$disk is a partition, not a whole disk."
guard_root_disk "$disk"
fi
CURRENT_DISK="$disk"
# Cleanup trap: unmount our mountpoints on exit/interrupt.
# shellcheck disable=SC2154 # rc is assigned inside the trap eval.
trap 'rc=$?; [[ -n "$CURRENT_DISK" ]] && unmount_all "$CURRENT_DISK" 2>/dev/null || true; exit $rc' EXIT INT TERM
case "$cmd" in
setup)
if [[ "${ASSUME_YES:-0}" != "1" ]]; then
confirm_destructive "$disk" "partition and format"
fi
# Unmount any stale mounts from a previous run before wiping.
unmount_all "$disk" 2>/dev/null || true
wipe_signatures "$disk"
create_partitions "$disk"
format_partitions "$disk"
local mounts payload_mnt esp_mnt
mounts="$(mount_all "$disk")"
payload_mnt="$(printf '%s' "$mounts" | sed -n '1p')"
esp_mnt="$(printf '%s' "$mounts" | sed -n '2p')"
create_directory_structure "$payload_mnt"
install_grub "$disk" "$payload_mnt" "$esp_mnt"
scan_payloads "$payload_mnt"
list_payloads "$payload_mnt"
if [[ -n "$payload_arg" ]]; then
log_step "Deploying initial payload: $payload_arg"
deploy_payload "$disk" "$payload_arg"
fi
log_info "Setup complete. Eject $disk safely before booting from it."
log_dim "Tip: to add more payloads later, copy files to ${payload_mnt}/payloads/ and run: $0 scan $disk"
;;
deploy)
[[ -n "$payload_arg" ]] || die "deploy requires a payload file argument."
deploy_payload "$disk" "$payload_arg"
;;
scan)
local payload_mnt
payload_mnt="$(mount_payload_partition "$disk")"
scan_payloads "$payload_mnt"
list_payloads "$payload_mnt"
;;
list)
local payload_mnt
payload_mnt="$(mount_payload_partition "$disk")"
list_payloads "$payload_mnt"
;;
shell)
open_shell "$disk"
;;
clean)
clean_drive "$disk"
;;
*)
die "Unknown command: $cmd (try: $0 --help)"
;;
esac
}
main "$@"