Last active
July 24, 2026 14:45
-
-
Save sumodirjo/4baa45389a653ee0146abc3a97d80447 to your computer and use it in GitHub Desktop.
Boot Doctor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # boot-doctor.sh — UEFI / GRUB / dual-boot diagnostic and repair helper | |
| # | |
| # Written for an Intel NUC8i7BEH with: | |
| # - SATA SSD holding Windows + the ESP | |
| # - NVMe holding Ubuntu | |
| # - flaky CMOS battery (NVRAM boot entries do not survive power loss) | |
| # | |
| # Default mode is READ-ONLY. Nothing is modified unless you pass --fix, | |
| # and even then every action asks first. | |
| # | |
| # Deliberately NOT automated: creating or resizing partitions, formatting, | |
| # and Windows bootloader repair (bcdboot). Those are reported, not performed. | |
| # | |
| # This is phase 2. Phase 1 is boot-prep.sh, which mounts the target install | |
| # and drops you into a chroot. If you are already booted into Ubuntu normally, | |
| # skip phase 1 and run this directly. | |
| # | |
| # Usage: | |
| # sudo ./boot-doctor.sh # check only, read-only | |
| # sudo ./boot-doctor.sh --clean-esp # free space on a full ESP (backs up first) | |
| # sudo ./boot-doctor.sh --fix # check, then offer repairs | |
| # sudo ./boot-doctor.sh --help | |
| # | |
| # Suggested order when the ESP is short on space: | |
| # --clean-esp then --fix then (no flags, to verify) | |
| # | |
| set -uo pipefail | |
| # ---------------------------------------------------------------- constants -- | |
| ESP_GUID="c12a7328-f81f-11d2-ba4b-00a0c93ec93b" | |
| GRUB_DEFAULT="/etc/default/grub" | |
| GRUB_CUSTOM="/etc/grub.d/40_custom" | |
| GRUB_DIR="/boot/grub" | |
| FSTAB="/etc/fstab" | |
| INSPECT_MNT="/run/boot-doctor-esp" | |
| FIX_MODE=0 | |
| CLEAN_MODE=0 | |
| PROBLEMS=0 | |
| WARNINGS=0 | |
| # Populated during discovery | |
| declare -a ESP_DEVS=() | |
| declare -a ESP_MOUNTS=() | |
| MOUNTED_ESP="" # device backing /boot/efi, if mounted | |
| WINDOWS_ESP="" # device whose ESP holds bootmgfw.efi | |
| WINDOWS_ESP_MNT="" | |
| WINDOWS_ESP_UUID="" | |
| ROOT_DEV="" | |
| IN_CHROOT=0 | |
| EFI_RUNTIME=0 | |
| # ------------------------------------------------------------------- output -- | |
| if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then | |
| C_RED=$(tput setaf 1); C_GRN=$(tput setaf 2); C_YEL=$(tput setaf 3) | |
| C_BLU=$(tput setaf 4); C_BLD=$(tput bold); C_RST=$(tput sgr0) | |
| else | |
| C_RED=""; C_GRN=""; C_YEL=""; C_BLU=""; C_BLD=""; C_RST="" | |
| fi | |
| section() { printf '\n%s== %s ==%s\n' "$C_BLD$C_BLU" "$1" "$C_RST"; } | |
| ok() { printf ' %s[ OK ]%s %s\n' "$C_GRN" "$C_RST" "$1"; } | |
| warn() { printf ' %s[WARN]%s %s\n' "$C_YEL" "$C_RST" "$1"; WARNINGS=$((WARNINGS+1)); } | |
| bad() { printf ' %s[FAIL]%s %s\n' "$C_RED" "$C_RST" "$1"; PROBLEMS=$((PROBLEMS+1)); } | |
| info() { printf ' %s[info]%s %s\n' "$C_BLU" "$C_RST" "$1"; } | |
| note() { printf ' %s\n' "$1"; } | |
| ask() { | |
| # ask "prompt" -> returns 0 on yes | |
| local reply | |
| printf '\n %s>>%s %s [y/N] ' "$C_BLD$C_YEL" "$C_RST" "$1" | |
| read -r reply </dev/tty || return 1 | |
| [[ "$reply" =~ ^[Yy]$ ]] | |
| } | |
| run() { | |
| printf ' %s$ %s%s\n' "$C_BLD" "$*" "$C_RST" | |
| "$@" | |
| local rc=$? | |
| if [[ $rc -ne 0 ]]; then | |
| printf ' %s(exit %d)%s\n' "$C_RED" "$rc" "$C_RST" | |
| fi | |
| return $rc | |
| } | |
| die() { printf '%serror:%s %s\n' "$C_RED" "$C_RST" "$1" >&2; exit 1; } | |
| # --------------------------------------------------------------------- args -- | |
| usage() { | |
| sed -n '2,/^$/p' "$0" | sed 's/^# \?//' | |
| exit 0 | |
| } | |
| for arg in "$@"; do | |
| case "$arg" in | |
| --fix) FIX_MODE=1 ;; | |
| --clean-esp) CLEAN_MODE=1 ;; | |
| -h|--help) usage ;; | |
| *) die "unknown argument: $arg (try --help)" ;; | |
| esac | |
| done | |
| [[ $EUID -eq 0 ]] || die "must run as root (use sudo)" | |
| cleanup() { | |
| if mountpoint -q "$INSPECT_MNT" 2>/dev/null; then | |
| umount "$INSPECT_MNT" 2>/dev/null | |
| fi | |
| [[ -d "$INSPECT_MNT" ]] && rmdir "$INSPECT_MNT" 2>/dev/null | |
| return 0 | |
| } | |
| trap cleanup EXIT | |
| # ============================================================================== | |
| # 1. Environment | |
| # ============================================================================== | |
| check_environment() { | |
| section "Environment" | |
| if [[ -d /sys/firmware/efi ]]; then | |
| EFI_RUNTIME=1 | |
| ok "booted in UEFI mode (/sys/firmware/efi present)" | |
| else | |
| EFI_RUNTIME=0 | |
| bad "/sys/firmware/efi missing — this system booted in LEGACY/CSM mode" | |
| note "grub-install --target=x86_64-efi cannot write NVRAM entries from here." | |
| note "Reboot and select a boot entry prefixed 'UEFI:' in the F10 menu." | |
| fi | |
| if [[ $EFI_RUNTIME -eq 1 ]]; then | |
| if mountpoint -q /sys/firmware/efi/efivars 2>/dev/null; then | |
| if grep -qE ' /sys/firmware/efi/efivars .* rw[ ,]' /proc/mounts; then | |
| ok "efivars mounted read-write" | |
| else | |
| warn "efivars mounted read-only — efibootmgr writes will fail" | |
| note "fix: mount -o remount,rw /sys/firmware/efi/efivars" | |
| fi | |
| else | |
| warn "efivars not mounted (normal inside a chroot without --bind /sys)" | |
| fi | |
| fi | |
| # crude chroot detection | |
| if [[ "$(stat -c %d:%i / 2>/dev/null)" != "$(stat -c %d:%i /proc/1/root/. 2>/dev/null)" ]]; then | |
| IN_CHROOT=1 | |
| info "running inside a chroot" | |
| note "os-prober is unreliable here; re-run update-grub after a real boot." | |
| fi | |
| ROOT_DEV=$(findmnt -no SOURCE / 2>/dev/null) | |
| [[ -n "$ROOT_DEV" ]] && info "root filesystem: $ROOT_DEV" | |
| local sb | |
| if command -v mokutil >/dev/null 2>&1; then | |
| sb=$(mokutil --sb-state 2>/dev/null | head -1) | |
| if [[ "$sb" == *"enabled"* ]]; then | |
| warn "Secure Boot is ENABLED" | |
| note "The --removable fallback path is happier with it off." | |
| elif [[ -n "$sb" ]]; then | |
| ok "Secure Boot: ${sb#*: }" | |
| fi | |
| fi | |
| } | |
| # ============================================================================== | |
| # 1b. Disk inventory — which physical disk holds what | |
| # ============================================================================== | |
| disk_byid() { | |
| # Prefer a stable, human-meaningful identifier over sda/nvme0n1 | |
| local dev="$1" link best="" | |
| for link in /dev/disk/by-id/*; do | |
| [[ -e "$link" ]] || continue | |
| [[ "$(readlink -f "$link")" == "$dev" ]] || continue | |
| case "$(basename "$link")" in | |
| wwn-*|*-part*) continue ;; | |
| nvme-eui.*) continue ;; | |
| esac | |
| best=$(basename "$link") | |
| break | |
| done | |
| echo "$best" | |
| } | |
| disk_inventory() { | |
| section "Disk inventory" | |
| local n_disks=0 | |
| while IFS= read -r line; do | |
| eval "$line" | |
| [[ "${TYPE:-}" != "disk" ]] && continue | |
| n_disks=$((n_disks+1)) | |
| local dev="/dev/${NAME}" | |
| local byid tag ptype | |
| byid=$(disk_byid "$dev") | |
| ptype=$(blkid -p -s PTTYPE -o value "$dev" 2>/dev/null) | |
| tag="" | |
| [[ "${RM:-0}" == "1" ]] && tag=" ${C_YEL}[REMOVABLE — likely the live USB]${C_RST}" | |
| [[ "${TRAN:-}" == "usb" ]] && tag=" ${C_YEL}[USB — likely the live USB]${C_RST}" | |
| printf '\n %s%s%s %s %s (%s)%b\n' \ | |
| "$C_BLD" "$dev" "$C_RST" "${SIZE:-?}" "${MODEL:-unknown model}" \ | |
| "${TRAN:-?}${ptype:+, $ptype}" "$tag" | |
| [[ -n "$byid" ]] && printf ' stable id: /dev/disk/by-id/%s\n' "$byid" | |
| # partitions on this disk | |
| local p | |
| while IFS= read -r p; do | |
| eval "$p" | |
| [[ "${TYPE:-}" != "part" ]] && continue | |
| local pdev="/dev/${NAME}" | |
| local role="" | |
| if [[ "${PARTTYPE:-}" == "$ESP_GUID" || "${PARTTYPE:-}" == "0xef" ]]; then | |
| role="${C_GRN}ESP${C_RST}" | |
| # what EFI images does it actually hold? | |
| local pm loaders="" | |
| pm=$(mount_for_inspect "$pdev") | |
| if [[ -n "$pm" ]]; then | |
| [[ -f "$pm/EFI/Microsoft/Boot/bootmgfw.efi" ]] && loaders+="Windows " | |
| [[ -f "$pm/EFI/ubuntu/grubx64.efi" ]] && loaders+="GRUB " | |
| [[ -f "$pm/EFI/ubuntu/shimx64.efi" ]] && loaders+="shim " | |
| local fbk | |
| fbk=$(find "$pm/EFI" -maxdepth 2 -ipath '*BOOT/BOOTX64.EFI' 2>/dev/null | head -1) | |
| [[ -n "$fbk" ]] && loaders+="fallback " | |
| [[ "$pm" == "$INSPECT_MNT" ]] && umount "$INSPECT_MNT" 2>/dev/null | |
| fi | |
| role="$role → ${loaders:-<no loaders>}" | |
| elif [[ "${FSTYPE:-}" == "ntfs" ]]; then | |
| role="Windows data" | |
| elif [[ "${FSTYPE:-}" =~ ^(ext4|ext3|btrfs|xfs)$ ]]; then | |
| role="Linux fs" | |
| [[ "$pdev" == "$ROOT_DEV" ]] && role="${C_GRN}Linux ROOT (mounted at /)${C_RST}" | |
| elif [[ "${FSTYPE:-}" == "crypto_LUKS" ]]; then | |
| role="LUKS container" | |
| elif [[ "${FSTYPE:-}" == "LVM2_member" ]]; then | |
| role="LVM physical volume" | |
| elif [[ "${FSTYPE:-}" == "swap" ]]; then | |
| role="swap" | |
| fi | |
| printf ' %-16s %-7s %-6s %s\n' \ | |
| "$pdev" "${SIZE:-?}" "${FSTYPE:-—}" "$role" | |
| done < <(lsblk -Pno NAME,TYPE,SIZE,FSTYPE,PARTTYPE "$dev" 2>/dev/null) | |
| done < <(lsblk -Pdno NAME,TYPE,SIZE,MODEL,TRAN,RM 2>/dev/null) | |
| printf '\n' | |
| if [[ $n_disks -gt 1 ]]; then | |
| note "Kernel names (sda, sdb, nvme0n1) are assigned in probe order and are" | |
| note "NOT stable. A live USB usually takes 'sda', shifting your SATA SSD to" | |
| note "'sdb'. Always confirm against the model string or by-id path above" | |
| note "before passing a device to efibootmgr, grub-install, or mkfs." | |
| fi | |
| } | |
| # ============================================================================== | |
| # 2. Discover EFI System Partitions | |
| # ============================================================================== | |
| mount_for_inspect() { | |
| # mount_for_inspect <device> -> echoes a mountpoint, or empty on failure | |
| local dev="$1" existing | |
| existing=$(findmnt -no TARGET --source "$dev" 2>/dev/null | head -1) | |
| if [[ -n "$existing" ]]; then | |
| echo "$existing" | |
| return 0 | |
| fi | |
| mkdir -p "$INSPECT_MNT" 2>/dev/null | |
| if mount -o ro "$dev" "$INSPECT_MNT" 2>/dev/null; then | |
| echo "$INSPECT_MNT" | |
| return 0 | |
| fi | |
| return 1 | |
| } | |
| discover_esps() { | |
| section "EFI System Partitions" | |
| local name fstype parttype size mnt dev | |
| while IFS= read -r line; do | |
| eval "$line" | |
| [[ -z "${FSTYPE:-}" ]] && continue | |
| [[ "$FSTYPE" != "vfat" ]] && continue | |
| local is_esp=0 | |
| [[ "${PARTTYPE:-}" == "$ESP_GUID" ]] && is_esp=1 | |
| [[ "${PARTTYPE:-}" == "0xef" ]] && is_esp=1 | |
| [[ $is_esp -eq 0 ]] && continue | |
| dev="/dev/${NAME}" | |
| ESP_DEVS+=("$dev") | |
| done < <(lsblk -Pno NAME,FSTYPE,PARTTYPE,SIZE,MOUNTPOINT 2>/dev/null) | |
| if [[ ${#ESP_DEVS[@]} -eq 0 ]]; then | |
| bad "no EFI System Partition found on any disk" | |
| note "Nothing can boot in UEFI mode without one. You will need to create a" | |
| note "512MB FAT32 partition flagged 'esp'. This script will not do that for you." | |
| return | |
| fi | |
| for dev in "${ESP_DEVS[@]}"; do | |
| local disk uuid label | |
| disk=$(lsblk -no PKNAME "$dev" 2>/dev/null | head -1) | |
| size=$(lsblk -no SIZE "$dev" 2>/dev/null | head -1 | tr -d ' ') | |
| uuid=$(blkid -s UUID -o value "$dev" 2>/dev/null) | |
| info "$dev on /dev/${disk:-?} size=${size:-?} UUID=${uuid:-?}" | |
| mnt=$(mount_for_inspect "$dev") | |
| if [[ -z "$mnt" ]]; then | |
| warn " could not mount $dev for inspection" | |
| ESP_MOUNTS+=("") | |
| continue | |
| fi | |
| ESP_MOUNTS+=("$mnt") | |
| # free space | |
| local availk usedpct | |
| availk=$(df -k --output=avail "$mnt" 2>/dev/null | tail -1 | tr -d ' ') | |
| usedpct=$(df -k --output=pcent "$mnt" 2>/dev/null | tail -1 | tr -d ' %') | |
| if [[ -n "$availk" ]]; then | |
| local availm=$(( availk / 1024 )) | |
| if [[ $availm -lt 10 ]]; then | |
| bad " only ${availm}MB free (${usedpct}% used) — FAT32 corrupts files when it fills" | |
| note " This is a very common cause of a Windows loader that 'goes nowhere'." | |
| elif [[ $availm -lt 40 ]]; then | |
| warn " only ${availm}MB free (${usedpct}% used) — tight for shim+grub+fallback" | |
| else | |
| ok " ${availm}MB free (${usedpct}% used)" | |
| fi | |
| fi | |
| # vendor directories | |
| if [[ -d "$mnt/EFI" ]]; then | |
| local dirs | |
| dirs=$(find "$mnt/EFI" -maxdepth 1 -mindepth 1 -type d -printf '%f ' 2>/dev/null) | |
| info " /EFI contains: ${dirs:-<empty>}" | |
| else | |
| warn " no /EFI directory on this partition" | |
| fi | |
| # Windows loader | |
| local bmgr | |
| bmgr=$(find "$mnt/EFI" -maxdepth 3 -iname 'bootmgfw.efi' 2>/dev/null | head -1) | |
| if [[ -n "$bmgr" ]]; then | |
| WINDOWS_ESP="$dev" | |
| WINDOWS_ESP_MNT="$mnt" | |
| WINDOWS_ESP_UUID="$uuid" | |
| local bsz | |
| bsz=$(stat -c %s "$bmgr" 2>/dev/null || echo 0) | |
| if [[ $bsz -lt 102400 ]]; then | |
| bad " bootmgfw.efi present but only ${bsz} bytes — truncated/corrupt" | |
| note " Repair from a Windows install USB (Shift+F10 at the installer):" | |
| note " diskpart -> list vol -> select vol N -> assign letter=S -> exit" | |
| note " bcdboot C:\\Windows /s S: /f UEFI" | |
| else | |
| ok " Windows loader present ($(( bsz / 1024 ))KB)" | |
| fi | |
| fi | |
| # Ubuntu loaders | |
| local ub="$mnt/EFI/ubuntu" | |
| if [[ -d "$ub" ]]; then | |
| [[ -f "$ub/grubx64.efi" ]] && ok " EFI/ubuntu/grubx64.efi present" \ | |
| || warn " EFI/ubuntu/grubx64.efi MISSING" | |
| [[ -f "$ub/shimx64.efi" ]] && ok " EFI/ubuntu/shimx64.efi present" \ | |
| || info " EFI/ubuntu/shimx64.efi absent (fine if Secure Boot is off)" | |
| if [[ -f "$ub/grub.cfg" ]]; then | |
| local pfx | |
| pfx=$(grep -o 'search[^\n]*' "$ub/grub.cfg" 2>/dev/null | head -1) | |
| [[ -n "$pfx" ]] && note " stub cfg: $pfx" | |
| fi | |
| fi | |
| # Removable fallback — the important one for a dead CMOS battery | |
| local fb | |
| fb=$(find "$mnt/EFI" -maxdepth 2 -ipath '*BOOT/BOOTX64.EFI' 2>/dev/null | head -1) | |
| if [[ -n "$fb" ]]; then | |
| local fsz | |
| fsz=$(stat -c %s "$fb" 2>/dev/null || echo 0) | |
| ok " removable fallback BOOTX64.EFI present ($(( fsz / 1024 ))KB)" | |
| else | |
| warn " no EFI/BOOT/BOOTX64.EFI fallback on this ESP" | |
| note " Without it, a CMOS reset leaves this machine unbootable." | |
| fi | |
| if [[ "$mnt" == "$INSPECT_MNT" ]]; then | |
| umount "$INSPECT_MNT" 2>/dev/null | |
| ESP_MOUNTS[-1]="" | |
| fi | |
| done | |
| } | |
| # ============================================================================== | |
| # 3. /boot/efi mount + fstab | |
| # ============================================================================== | |
| check_boot_efi() { | |
| section "/boot/efi mount" | |
| if mountpoint -q /boot/efi; then | |
| MOUNTED_ESP=$(findmnt -no SOURCE /boot/efi) | |
| ok "/boot/efi mounted from $MOUNTED_ESP" | |
| local esp_disk root_disk | |
| esp_disk=$(lsblk -no PKNAME "$MOUNTED_ESP" 2>/dev/null | head -1) | |
| root_disk=$(lsblk -no PKNAME "$ROOT_DEV" 2>/dev/null | head -1) | |
| if [[ -n "$esp_disk" && -n "$root_disk" && "$esp_disk" != "$root_disk" ]]; then | |
| info "ESP is on /dev/$esp_disk but root is on /dev/$root_disk" | |
| note "This is a valid shared-ESP dual-boot layout. It also means the" | |
| note "root disk will never appear as its own UEFI boot device, and" | |
| note "removing /dev/$esp_disk would make this install unbootable." | |
| fi | |
| else | |
| bad "/boot/efi is not mounted" | |
| note "grub-install --efi-directory=/boot/efi will fail or write to the wrong place." | |
| fi | |
| if grep -qE '^[^#].*[[:space:]]/boot/efi[[:space:]]' "$FSTAB" 2>/dev/null; then | |
| ok "/boot/efi has an $FSTAB entry" | |
| local fst_uuid | |
| fst_uuid=$(grep -E '^[^#].*[[:space:]]/boot/efi[[:space:]]' "$FSTAB" | awk '{print $1}' | head -1) | |
| note " $fst_uuid" | |
| if [[ -n "$MOUNTED_ESP" && "$fst_uuid" == UUID=* ]]; then | |
| local want have | |
| want="${fst_uuid#UUID=}" | |
| have=$(blkid -s UUID -o value "$MOUNTED_ESP" 2>/dev/null) | |
| if [[ -n "$have" && "$want" != "$have" ]]; then | |
| bad "fstab UUID ($want) does not match mounted ESP ($have)" | |
| fi | |
| fi | |
| else | |
| bad "no /boot/efi entry in $FSTAB — it will not remount after reboot" | |
| fi | |
| } | |
| # ============================================================================== | |
| # 4. GRUB installation state | |
| # ============================================================================== | |
| check_grub_files() { | |
| section "GRUB installation" | |
| if [[ -d "$GRUB_DIR/x86_64-efi" ]]; then | |
| local n | |
| n=$(find "$GRUB_DIR/x86_64-efi" -name '*.mod' 2>/dev/null | wc -l) | |
| ok "$GRUB_DIR/x86_64-efi present ($n modules)" | |
| else | |
| bad "$GRUB_DIR/x86_64-efi MISSING — GRUB cannot read filesystems in EFI mode" | |
| note "This is what produces 'error: unknown filesystem' at the grub> prompt." | |
| note "Fix: grub-install --target=x86_64-efi --efi-directory=/boot/efi" | |
| fi | |
| if [[ -d "$GRUB_DIR/i386-pc" ]]; then | |
| warn "$GRUB_DIR/i386-pc present — leftover BIOS-mode install" | |
| note "Harmless, just dead weight. Safe to delete once EFI boot works." | |
| fi | |
| if [[ -f "$GRUB_DIR/grub.cfg" ]]; then | |
| ok "grub.cfg present (modified $(date -r "$GRUB_DIR/grub.cfg" '+%Y-%m-%d %H:%M'))" | |
| local nkern | |
| nkern=$(grep -c "^menuentry\|^\s*menuentry" "$GRUB_DIR/grub.cfg" 2>/dev/null) | |
| info " $nkern menu entries" | |
| if grep -qi 'chainloader.*bootmgfw\|Windows Boot Manager' "$GRUB_DIR/grub.cfg" 2>/dev/null; then | |
| ok " a Windows entry exists in the menu" | |
| else | |
| warn " no Windows entry in the GRUB menu" | |
| fi | |
| else | |
| bad "$GRUB_DIR/grub.cfg MISSING — you will land at a grub> prompt with no menu" | |
| note "Fix: update-grub" | |
| fi | |
| # packages | |
| if command -v dpkg-query >/dev/null 2>&1; then | |
| local st | |
| st=$(dpkg-query -W -f='${Status}' grub-efi-amd64 2>/dev/null) | |
| [[ "$st" == *"ok installed"* ]] && ok "grub-efi-amd64 installed" \ | |
| || bad "grub-efi-amd64 NOT installed" | |
| st=$(dpkg-query -W -f='${Status}' grub-pc 2>/dev/null) | |
| [[ "$st" == *"ok installed"* ]] && warn "grub-pc still installed (BIOS variant) — conflicts on upgrade" | |
| fi | |
| } | |
| # ============================================================================== | |
| # 5. NVRAM boot entries | |
| # ============================================================================== | |
| check_nvram() { | |
| section "Firmware boot entries (NVRAM)" | |
| if ! command -v efibootmgr >/dev/null 2>&1; then | |
| warn "efibootmgr not installed — skipping (apt install efibootmgr)" | |
| return | |
| fi | |
| if [[ $EFI_RUNTIME -eq 0 ]]; then | |
| warn "not in UEFI mode; NVRAM cannot be read" | |
| return | |
| fi | |
| local out | |
| out=$(efibootmgr -v 2>/dev/null) | |
| if [[ -z "$out" ]]; then | |
| warn "efibootmgr returned nothing (efivars not available?)" | |
| return | |
| fi | |
| printf '%s\n' "$out" | sed 's/^/ /' | |
| if grep -qi 'ubuntu' <<<"$out"; then | |
| ok "an 'ubuntu' NVRAM entry exists" | |
| else | |
| warn "no 'ubuntu' NVRAM entry" | |
| note "Expected with a failing CMOS battery. The removable fallback covers this." | |
| fi | |
| if grep -qi 'Windows Boot Manager' <<<"$out"; then | |
| ok "a 'Windows Boot Manager' NVRAM entry exists" | |
| if ! grep -i 'Windows Boot Manager' <<<"$out" | grep -qi 'bootmgfw.efi'; then | |
| bad " ...but its path does not reference bootmgfw.efi" | |
| note " The firmware will show the entry and then do nothing. Classic symptom." | |
| fi | |
| else | |
| warn "no 'Windows Boot Manager' NVRAM entry" | |
| fi | |
| } | |
| # ============================================================================== | |
| # 6. /etc/default/grub settings | |
| # ============================================================================== | |
| check_grub_defaults() { | |
| section "GRUB configuration" | |
| [[ -f "$GRUB_DEFAULT" ]] || { bad "$GRUB_DEFAULT missing"; return; } | |
| local v | |
| v=$(grep -E '^GRUB_DISABLE_OS_PROBER=' "$GRUB_DEFAULT" | tail -1 | cut -d= -f2 | tr -d '"') | |
| if [[ "$v" == "false" ]]; then | |
| ok "GRUB_DISABLE_OS_PROBER=false (Windows will be detected)" | |
| else | |
| warn "os-prober disabled — Windows will not appear in the GRUB menu" | |
| note "Since GRUB 2.06 this defaults to true." | |
| fi | |
| v=$(grep -E '^GRUB_GFXMODE=' "$GRUB_DEFAULT" | tail -1 | cut -d= -f2 | tr -d '"') | |
| [[ -n "$v" ]] && info "GRUB_GFXMODE=$v" || info "GRUB_GFXMODE unset (firmware default, 4K on this NUC)" | |
| command -v os-prober >/dev/null 2>&1 && ok "os-prober installed" \ | |
| || warn "os-prober not installed" | |
| } | |
| # ============================================================================== | |
| # 6c. Boot chain summary | |
| # ============================================================================== | |
| boot_chain() { | |
| section "Boot chain" | |
| local esp_disk="" esp_model="" root_disk="" root_model="" | |
| if [[ -n "$MOUNTED_ESP" ]]; then | |
| esp_disk=$(lsblk -no PKNAME "$MOUNTED_ESP" 2>/dev/null | head -1) | |
| esp_model=$(lsblk -dno MODEL "/dev/$esp_disk" 2>/dev/null | sed 's/ *$//') | |
| fi | |
| if [[ -n "$ROOT_DEV" ]]; then | |
| root_disk=$(lsblk -no PKNAME "$ROOT_DEV" 2>/dev/null | head -1) | |
| root_model=$(lsblk -dno MODEL "/dev/$root_disk" 2>/dev/null | sed 's/ *$//') | |
| fi | |
| printf ' firmware (NUC boot menu)\n' | |
| printf ' │\n' | |
| printf ' ▼ picks a device that has an ESP with a bootable image\n' | |
| printf ' %sESP%s %s on /dev/%s (%s)\n' \ | |
| "$C_BLD" "$C_RST" "${MOUNTED_ESP:-<not mounted>}" "${esp_disk:-?}" "${esp_model:-?}" | |
| printf ' │ EFI/ubuntu/shimx64.efi or EFI/BOOT/BOOTX64.EFI\n' | |
| printf ' ▼\n' | |
| printf ' %sGRUB%s reads /boot/grub/grub.cfg and the kernel from...\n' "$C_BLD" "$C_RST" | |
| printf ' │\n' | |
| printf ' ▼\n' | |
| printf ' %sroot%s %s on /dev/%s (%s)\n' \ | |
| "$C_BLD" "$C_RST" "${ROOT_DEV:-?}" "${root_disk:-?}" "${root_model:-?}" | |
| printf '\n' | |
| if [[ -n "$esp_disk" && -n "$root_disk" && "$esp_disk" != "$root_disk" ]]; then | |
| warn "the ESP and the root filesystem are on DIFFERENT disks" | |
| note "Consequences of this layout:" | |
| note " • /dev/$root_disk will never appear in the F10 boot menu — correct, not a fault." | |
| note " • You must boot the entry for /dev/$esp_disk to reach Ubuntu." | |
| note " • Removing or wiping /dev/$esp_disk makes this Ubuntu install unbootable." | |
| note " • A dedicated ESP on /dev/$root_disk would make it self-sufficient." | |
| elif [[ -n "$esp_disk" ]]; then | |
| ok "ESP and root are on the same disk (/dev/$esp_disk) — self-contained" | |
| fi | |
| } | |
| # ============================================================================== | |
| # 6b. ESP cleanup | |
| # ============================================================================== | |
| esp_backup() { | |
| # Tar the whole ESP somewhere on the root filesystem before deleting anything. | |
| local src="$1" dest="/root/esp-backup-$(date +%Y%m%d-%H%M%S).tar.gz" | |
| printf ' %s$ tar czf %s -C %s .%s\n' "$C_BLD" "$dest" "$src" "$C_RST" | |
| if tar czf "$dest" -C "$src" . 2>/dev/null; then | |
| ok "ESP backed up to $dest ($(du -h "$dest" | cut -f1))" | |
| note "Restore with: tar xzf $dest -C /boot/efi" | |
| return 0 | |
| fi | |
| bad "backup FAILED — refusing to delete anything" | |
| return 1 | |
| } | |
| esp_usage_report() { | |
| local mnt="$1" | |
| info "largest consumers on the ESP:" | |
| du -sk "$mnt"/EFI/* 2>/dev/null | sort -rn | head -12 | while read -r kb path; do | |
| printf ' %6s MB %s\n' "$(( kb / 1024 ))" "${path#"$mnt"/}" | |
| done | |
| du -sk "$mnt"/EFI/Microsoft/Boot/* 2>/dev/null | sort -rn | head -8 | while read -r kb path; do | |
| printf ' %6s MB %s\n' "$(( kb / 1024 ))" "${path#"$mnt"/}" | |
| done | |
| } | |
| esp_free_mb() { | |
| echo $(( $(df -k --output=avail "$1" 2>/dev/null | tail -1 | tr -d ' ') / 1024 )) | |
| } | |
| clean_esp() { | |
| section "ESP cleanup" | |
| if ! mountpoint -q /boot/efi; then | |
| bad "/boot/efi is not mounted — nothing to clean" | |
| return | |
| fi | |
| local mnt="/boot/efi" | |
| local before after | |
| before=$(esp_free_mb "$mnt") | |
| info "starting free space: ${before}MB" | |
| esp_usage_report "$mnt" | |
| note "" | |
| note "Everything below is opt-in. A full tarball of the ESP is taken first." | |
| esp_backup "$mnt" || return | |
| # -- 1. GRUB and installer backup files ----------------------------------- | |
| local junk | |
| junk=$(find "$mnt" -maxdepth 4 \( -iname '*.grb' -o -iname '*.bak' -o -iname '*.old' \ | |
| -o -iname '*.orig' -o -iname '*~' \) 2>/dev/null) | |
| if [[ -n "$junk" ]]; then | |
| printf '\n' | |
| printf '%s\n' "$junk" | sed 's/^/ /' | |
| if ask "Delete these backup/leftover files?"; then | |
| printf '%s\n' "$junk" | while read -r f; do rm -f "$f"; done | |
| ok "removed" | |
| fi | |
| else | |
| info "no stray backup files" | |
| fi | |
| # -- 2. Orphaned distro vendor directories -------------------------------- | |
| local keep_re='^(BOOT|Boot|boot|Microsoft|ubuntu)$' | |
| local d base | |
| for d in "$mnt"/EFI/*/; do | |
| [[ -d "$d" ]] || continue | |
| base=$(basename "$d") | |
| [[ "$base" =~ $keep_re ]] && continue | |
| local sz | |
| sz=$(du -sk "$d" 2>/dev/null | cut -f1) | |
| printf '\n' | |
| warn "vendor directory /EFI/$base — $(( sz / 1024 ))MB" | |
| note " contents: $(find "$d" -maxdepth 1 -mindepth 1 -printf '%f ' 2>/dev/null)" | |
| note " This is from another distro or a previous install. Only delete it" | |
| note " if you are sure nothing boots from it." | |
| if ask "Delete /EFI/$base?"; then | |
| rm -rf "$d" && ok "removed /EFI/$base" | |
| fi | |
| done | |
| # -- 3. Windows boot locale directories ----------------------------------- | |
| local msboot="$mnt/EFI/Microsoft/Boot" | |
| if [[ -d "$msboot" ]]; then | |
| local locales lsz | |
| locales=$(find "$msboot" -maxdepth 1 -mindepth 1 -type d \ | |
| \( -regextype posix-extended -regex '.*/[a-z]{2}-[A-Z]{2}$' \ | |
| -o -name 'qps-*' \) ! -name 'en-US' 2>/dev/null) | |
| if [[ -n "$locales" ]]; then | |
| lsz=$(printf '%s\n' "$locales" | xargs -r du -sk 2>/dev/null | awk '{s+=$1} END {print int(s/1024)}') | |
| printf '\n' | |
| info "non-English Windows boot locale directories: ~${lsz}MB total" | |
| printf '%s\n' "$locales" | sed "s|$msboot/| |" | |
| note "" | |
| note "These hold translated strings for the Windows boot menu and" | |
| note "recovery screens. Removing them leaves those screens in English." | |
| note "Windows itself is unaffected, and a Windows feature update will" | |
| note "restore them. Skip this if you rely on a non-English boot UI." | |
| if ask "Delete non-en-US locale directories (~${lsz}MB)?"; then | |
| printf '%s\n' "$locales" | while read -r l; do rm -rf "$l"; done | |
| ok "removed" | |
| fi | |
| fi | |
| fi | |
| # -- 4. Report-only items -------------------------------------------------- | |
| printf '\n' | |
| local fonts="$msboot/Fonts" | |
| if [[ -d "$fonts" ]]; then | |
| info "$(du -sh "$fonts" 2>/dev/null | cut -f1) in /EFI/Microsoft/Boot/Fonts — NOT offered for deletion" | |
| note "Used by the BitLocker unlock and recovery screens. Deleting it can" | |
| note "leave you staring at an unrenderable recovery prompt. Not worth it." | |
| fi | |
| if [[ -d "$mnt/EFI/Microsoft/Recovery" ]]; then | |
| info "/EFI/Microsoft/Recovery present — leave it alone" | |
| fi | |
| # -- result ---------------------------------------------------------------- | |
| after=$(esp_free_mb "$mnt") | |
| section "Cleanup result" | |
| printf ' free before: %sMB\n free after: %sMB (%s+%sMB%s)\n' \ | |
| "$before" "$after" "$C_GRN" "$(( after - before ))" "$C_RST" | |
| if [[ $after -lt 25 ]]; then | |
| warn "still under 25MB — GRUB install may still truncate files" | |
| note "The durable fix is a dedicated ESP on the NVMe. See the notes in" | |
| note "boot-prep.sh; partitioning is not automated here." | |
| else | |
| ok "enough headroom for shim + grub + the removable fallback" | |
| fi | |
| } | |
| # ============================================================================== | |
| # 7. Repairs | |
| # ============================================================================== | |
| do_fixes() { | |
| section "Repairs" | |
| if [[ $EFI_RUNTIME -eq 0 ]]; then | |
| bad "refusing to run repairs: not booted in UEFI mode" | |
| note "Reboot via a 'UEFI:' entry in the F10 menu first." | |
| return | |
| fi | |
| if ! mountpoint -q /boot/efi; then | |
| bad "refusing to run repairs: /boot/efi is not mounted" | |
| return | |
| fi | |
| # -- enable os-prober ------------------------------------------------------ | |
| local v | |
| v=$(grep -E '^GRUB_DISABLE_OS_PROBER=' "$GRUB_DEFAULT" | tail -1 | cut -d= -f2 | tr -d '"') | |
| if [[ "$v" != "false" ]]; then | |
| if ask "Enable os-prober so Windows appears in the GRUB menu?"; then | |
| cp -a "$GRUB_DEFAULT" "$GRUB_DEFAULT.bak.$(date +%s)" | |
| sed -i '/^GRUB_DISABLE_OS_PROBER=/d' "$GRUB_DEFAULT" | |
| echo 'GRUB_DISABLE_OS_PROBER=false' >> "$GRUB_DEFAULT" | |
| ok "set GRUB_DISABLE_OS_PROBER=false (backup saved)" | |
| fi | |
| fi | |
| # -- set 1080p ------------------------------------------------------------- | |
| if ! grep -qE '^GRUB_GFXMODE=' "$GRUB_DEFAULT"; then | |
| if ask "Set GRUB menu resolution to 1920x1080?"; then | |
| cp -a "$GRUB_DEFAULT" "$GRUB_DEFAULT.bak.$(date +%s)" | |
| printf 'GRUB_GFXMODE=1920x1080,1280x1024,auto\nGRUB_GFXPAYLOAD_LINUX=keep\n' >> "$GRUB_DEFAULT" | |
| ok "GRUB_GFXMODE set (falls back automatically if unsupported)" | |
| fi | |
| fi | |
| # -- reinstall GRUB to the ESP -------------------------------------------- | |
| if ask "Reinstall GRUB to /boot/efi (both the 'ubuntu' entry and the removable fallback)?"; then | |
| run grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=ubuntu | |
| run grub-install --target=x86_64-efi --efi-directory=/boot/efi --removable | |
| ok "grub-install done" | |
| note "The --removable copy overwrites EFI/BOOT/BOOTX64.EFI. That is intended:" | |
| note "it is what keeps this machine bootable when NVRAM is wiped." | |
| fi | |
| # -- regenerate menu ------------------------------------------------------- | |
| if ask "Regenerate the GRUB menu (update-grub)?"; then | |
| run update-grub | |
| if [[ $IN_CHROOT -eq 1 ]]; then | |
| note "You are in a chroot — os-prober often finds nothing here." | |
| note "Run 'sudo update-grub' again after booting normally." | |
| fi | |
| fi | |
| # -- static Windows chainload entry --------------------------------------- | |
| if [[ -n "$WINDOWS_ESP_UUID" ]]; then | |
| if grep -q 'bootmgfw.efi' "$GRUB_CUSTOM" 2>/dev/null; then | |
| info "40_custom already has a Windows chainload entry" | |
| elif ask "Add a permanent Windows chainload entry to 40_custom (survives os-prober failures)?"; then | |
| cp -a "$GRUB_CUSTOM" "$GRUB_CUSTOM.bak.$(date +%s)" 2>/dev/null | |
| cat >> "$GRUB_CUSTOM" <<EOF | |
| menuentry "Windows Boot Manager (manual)" --class windows { | |
| insmod part_gpt | |
| insmod fat | |
| insmod chain | |
| search --no-floppy --fs-uuid --set=root $WINDOWS_ESP_UUID | |
| chainloader /EFI/Microsoft/Boot/bootmgfw.efi | |
| } | |
| EOF | |
| chmod +x "$GRUB_CUSTOM" | |
| ok "entry added for ESP UUID $WINDOWS_ESP_UUID" | |
| run update-grub | |
| fi | |
| fi | |
| # -- recreate the Windows NVRAM entry ------------------------------------- | |
| if [[ -n "$WINDOWS_ESP" ]] && command -v efibootmgr >/dev/null 2>&1; then | |
| local wdisk wpart wmodel wbyid | |
| wdisk="/dev/$(lsblk -no PKNAME "$WINDOWS_ESP" | head -1)" | |
| wpart=$(cat "/sys/class/block/$(basename "$WINDOWS_ESP")/partition" 2>/dev/null) | |
| wmodel=$(lsblk -dno MODEL "$wdisk" 2>/dev/null | sed 's/ *$//') | |
| wbyid=$(disk_byid "$wdisk") | |
| if [[ -n "$wpart" ]] && ! efibootmgr 2>/dev/null | grep -qi 'Windows Boot Manager'; then | |
| printf '\n' | |
| info "target disk for the NVRAM entry:" | |
| note " device: $wdisk partition $wpart" | |
| note " model: ${wmodel:-unknown}" | |
| note " by-id: ${wbyid:-unavailable}" | |
| note " Verify this is your Windows SSD, not the live USB." | |
| if ask "Recreate the 'Windows Boot Manager' NVRAM entry on $wdisk (${wmodel:-?})?"; then | |
| run efibootmgr -c -d "$wdisk" -p "$wpart" -L "Windows Boot Manager" \ | |
| -l '\EFI\Microsoft\Boot\bootmgfw.efi' | |
| fi | |
| fi | |
| fi | |
| # -- clean up BIOS leftovers ---------------------------------------------- | |
| if [[ -d "$GRUB_DIR/i386-pc" ]]; then | |
| if ask "Delete leftover BIOS modules at $GRUB_DIR/i386-pc?"; then | |
| run rm -rf "$GRUB_DIR/i386-pc" | |
| fi | |
| fi | |
| } | |
| # ============================================================================== | |
| # Main | |
| # ============================================================================== | |
| printf '%sboot-doctor%s — UEFI/GRUB dual-boot check%s\n' \ | |
| "$C_BLD" "$C_RST$C_BLD" "$C_RST" | |
| printf 'mode: %s\n' "$([[ $FIX_MODE -eq 1 ]] && echo 'CHECK + FIX (each step confirmed)' || echo 'CHECK ONLY (read-only)')" | |
| check_environment | |
| disk_inventory | |
| discover_esps | |
| check_boot_efi | |
| check_grub_files | |
| check_nvram | |
| check_grub_defaults | |
| boot_chain | |
| section "Summary" | |
| printf ' %d failure(s), %d warning(s)\n' "$PROBLEMS" "$WARNINGS" | |
| [[ $CLEAN_MODE -eq 1 ]] && clean_esp | |
| if [[ $FIX_MODE -eq 1 ]]; then | |
| do_fixes | |
| section "Done" | |
| note "Re-run without --fix to verify, then reboot." | |
| note "BIOS (F2): UEFI on, CSM off, Secure Boot off." | |
| elif [[ $CLEAN_MODE -eq 0 ]] && [[ $PROBLEMS -gt 0 || $WARNINGS -gt 0 ]]; then | |
| printf '\n Re-run with %s--fix%s to be offered repairs for the above.\n' "$C_BLD" "$C_RST" | |
| printf ' If ESP free space is tight, run %s--clean-esp%s first.\n' "$C_BLD" "$C_RST" | |
| fi | |
| printf '\n' | |
| exit 0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # boot-prep.sh — phase 1: mount the target install and enter a chroot | |
| # | |
| # Run this from a live USB booted in UEFI mode. It discovers the Ubuntu root | |
| # filesystem and the EFI System Partition, unlocks LUKS / activates LVM if | |
| # needed, mounts everything in the right order, bind-mounts the kernel | |
| # filesystems (including efivars, so efibootmgr works), and drops you into a | |
| # chroot with boot-doctor.sh ready to run. | |
| # | |
| # Phase 2 is boot-doctor.sh, run from inside that chroot. | |
| # | |
| # Usage: | |
| # sudo ./boot-prep.sh # interactive discovery | |
| # sudo ./boot-prep.sh --root /dev/nvme0n1p2 --esp /dev/sda1 | |
| # sudo ./boot-prep.sh --umount # tear everything down | |
| # sudo ./boot-prep.sh --dry-run # show the plan, mount nothing | |
| # | |
| set -uo pipefail | |
| TARGET="/mnt/target" | |
| ESP_GUID="c12a7328-f81f-11d2-ba4b-00a0c93ec93b" | |
| PROBE_MNT="/run/boot-prep-probe" | |
| OPT_ROOT="" | |
| OPT_ESP="" | |
| DO_UMOUNT=0 | |
| DRY_RUN=0 | |
| MOUNT_NOTE="" | |
| MOUNT_ERR="" | |
| PROBE_PATH="" | |
| PROBE_OWNED=0 | |
| # ------------------------------------------------------------------- output -- | |
| if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then | |
| C_RED=$(tput setaf 1); C_GRN=$(tput setaf 2); C_YEL=$(tput setaf 3) | |
| C_BLU=$(tput setaf 4); C_BLD=$(tput bold); C_RST=$(tput sgr0) | |
| else | |
| C_RED=""; C_GRN=""; C_YEL=""; C_BLU=""; C_BLD=""; C_RST="" | |
| fi | |
| section() { printf '\n%s== %s ==%s\n' "$C_BLD$C_BLU" "$1" "$C_RST"; } | |
| ok() { printf ' %s[ OK ]%s %s\n' "$C_GRN" "$C_RST" "$1"; } | |
| warn() { printf ' %s[WARN]%s %s\n' "$C_YEL" "$C_RST" "$1"; } | |
| bad() { printf ' %s[FAIL]%s %s\n' "$C_RED" "$C_RST" "$1"; } | |
| info() { printf ' %s[info]%s %s\n' "$C_BLU" "$C_RST" "$1"; } | |
| note() { printf ' %s\n' "$1"; } | |
| die() { printf '%serror:%s %s\n' "$C_RED" "$C_RST" "$1" >&2; exit 1; } | |
| run() { | |
| printf ' %s$ %s%s\n' "$C_BLD" "$*" "$C_RST" | |
| [[ $DRY_RUN -eq 1 ]] && return 0 | |
| "$@" | |
| } | |
| ask() { | |
| local reply | |
| printf '\n %s>>%s %s [y/N] ' "$C_BLD$C_YEL" "$C_RST" "$1" | |
| read -r reply </dev/tty || return 1 | |
| [[ "$reply" =~ ^[Yy]$ ]] | |
| } | |
| usage() { sed -n '2,/^$/p' "$0" | sed 's/^# \?//'; exit 0; } | |
| # --------------------------------------------------------------------- args -- | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --root) OPT_ROOT="${2:-}"; shift 2 ;; | |
| --esp) OPT_ESP="${2:-}"; shift 2 ;; | |
| --umount) DO_UMOUNT=1; shift ;; | |
| --dry-run) DRY_RUN=1; shift ;; | |
| -h|--help) usage ;; | |
| *) die "unknown argument: $1 (try --help)" ;; | |
| esac | |
| done | |
| [[ $EUID -eq 0 ]] || die "must run as root (use sudo)" | |
| # ============================================================================== | |
| # Teardown | |
| # ============================================================================== | |
| teardown() { | |
| section "Unmounting $TARGET" | |
| local m | |
| # deepest first | |
| while IFS= read -r m; do | |
| run umount -l "$m" 2>/dev/null && ok "unmounted $m" | |
| done < <(findmnt -rno TARGET | grep "^$TARGET" | sort -r) | |
| if mountpoint -q "$TARGET" 2>/dev/null; then | |
| warn "$TARGET still mounted — something is holding it" | |
| note "check with: lsof +D $TARGET" | |
| else | |
| ok "all target mounts released" | |
| rmdir "$TARGET" 2>/dev/null | |
| fi | |
| exit 0 | |
| } | |
| [[ $DO_UMOUNT -eq 1 ]] && teardown | |
| # ============================================================================== | |
| # 1. Verify we are in UEFI mode | |
| # ============================================================================== | |
| section "Live environment" | |
| if [[ -d /sys/firmware/efi ]]; then | |
| ok "live USB booted in UEFI mode" | |
| else | |
| bad "live USB booted in LEGACY/CSM mode" | |
| note "grub-install --target=x86_64-efi will not be able to write NVRAM entries." | |
| note "Reboot, press F10, and pick the entry prefixed 'UEFI:' instead." | |
| ask "Continue anyway (repairs will be limited)?" || exit 1 | |
| fi | |
| for t in cryptsetup lvm blkid findmnt; do | |
| command -v "$t" >/dev/null 2>&1 || warn "$t not found — some discovery may fail" | |
| done | |
| # ============================================================================== | |
| # 2. Unlock encrypted / logical volumes | |
| # ============================================================================== | |
| section "Storage activation" | |
| luks_found=0 | |
| while IFS= read -r dev; do | |
| luks_found=1 | |
| if cryptsetup status "$(basename "$dev")_crypt" >/dev/null 2>&1; then | |
| ok "$dev already unlocked" | |
| continue | |
| fi | |
| warn "$dev is LUKS-encrypted" | |
| if ask "Unlock $dev now?"; then | |
| run cryptsetup luksOpen "$dev" "$(basename "$dev")_crypt" | |
| fi | |
| done < <(blkid -t TYPE=crypto_LUKS -o device 2>/dev/null) | |
| [[ $luks_found -eq 0 ]] && info "no LUKS volumes detected" | |
| if command -v vgchange >/dev/null 2>&1; then | |
| if vgs --noheadings 2>/dev/null | grep -q .; then | |
| run vgchange -ay >/dev/null 2>&1 | |
| ok "LVM volume groups activated" | |
| vgs --noheadings -o vg_name 2>/dev/null | sed 's/^/ vg: /' | |
| else | |
| info "no LVM volume groups detected" | |
| fi | |
| fi | |
| # ============================================================================== | |
| # 3. Find candidate root filesystems | |
| # ============================================================================== | |
| existing_mount() { | |
| # echo where <dev> is currently mounted, if anywhere | |
| findmnt -nro TARGET --source "$1" 2>/dev/null | head -1 | |
| } | |
| probe_umount() { | |
| mountpoint -q "$PROBE_MNT" 2>/dev/null && umount "$PROBE_MNT" 2>/dev/null | |
| return 0 | |
| } | |
| probe_get() { | |
| # probe_get <dev> | |
| # Sets PROBE_PATH to a readable mountpoint. Reuses an existing mount | |
| # (e.g. a udisks auto-mount from the live desktop) rather than failing. | |
| # Sets PROBE_OWNED=1 if we created the mount ourselves. | |
| local dev="$1" ex | |
| PROBE_PATH=""; PROBE_OWNED=0; MOUNT_NOTE=""; MOUNT_ERR="" | |
| ex=$(existing_mount "$dev") | |
| if [[ -n "$ex" ]]; then | |
| PROBE_PATH="$ex" | |
| MOUNT_NOTE="already mounted at $ex" | |
| return 0 | |
| fi | |
| probe_umount | |
| if mount -o ro "$dev" "$PROBE_MNT" 2>/dev/null; then | |
| PROBE_PATH="$PROBE_MNT"; PROBE_OWNED=1; return 0 | |
| fi | |
| # ext3/4 with a dirty journal refuses a plain read-only mount | |
| if mount -o ro,noload "$dev" "$PROBE_MNT" 2>/dev/null; then | |
| PROBE_PATH="$PROBE_MNT"; PROBE_OWNED=1 | |
| MOUNT_NOTE="dirty journal — mounted with noload"; return 0 | |
| fi | |
| if mount -o ro,rescue=all "$dev" "$PROBE_MNT" 2>/dev/null; then | |
| PROBE_PATH="$PROBE_MNT"; PROBE_OWNED=1 | |
| MOUNT_NOTE="btrfs rescue mount"; return 0 | |
| fi | |
| MOUNT_ERR=$(mount -o ro "$dev" "$PROBE_MNT" 2>&1 | tail -1) | |
| return 1 | |
| } | |
| probe_put() { | |
| [[ "${PROBE_OWNED:-0}" == "1" ]] && probe_umount | |
| PROBE_PATH=""; PROBE_OWNED=0 | |
| return 0 | |
| } | |
| # Undo live-desktop auto-mounts so the real mounts can proceed | |
| release_automounts() { | |
| local dev="$1" ex | |
| ex=$(existing_mount "$dev") | |
| [[ -z "$ex" ]] && return 0 | |
| [[ "$ex" == "$TARGET"* ]] && return 0 | |
| warn "$dev is already mounted at $ex" | |
| if [[ "$ex" == /run/media/* || "$ex" == /media/* ]]; then | |
| note " This is the live desktop's automatic mount (udisks)." | |
| fi | |
| # 1. the correct way to undo a udisks automount | |
| if command -v udisksctl >/dev/null 2>&1; then | |
| if udisksctl unmount -b "$dev" >/dev/null 2>&1; then | |
| ok " released via udisksctl" | |
| return 0 | |
| fi | |
| fi | |
| # 2. plain unmount | |
| if umount "$dev" 2>/dev/null; then | |
| ok " unmounted" | |
| return 0 | |
| fi | |
| # 3. relocate the existing mount instead of fighting it | |
| mount --make-private "$ex" 2>/dev/null | |
| if mount --move "$ex" "$TARGET" 2>/dev/null; then | |
| ok " moved $ex -> $TARGET" | |
| return 2 # signals: already at TARGET, do not mount again | |
| fi | |
| # 4. last resort | |
| warn " still busy — showing what is holding it:" | |
| if command -v fuser >/dev/null 2>&1; then | |
| fuser -vm "$ex" 2>&1 | sed 's/^/ /' | |
| elif command -v lsof >/dev/null 2>&1; then | |
| lsof +D "$ex" 2>/dev/null | head -10 | sed 's/^/ /' | |
| fi | |
| if ask " Force a lazy unmount of $ex?"; then | |
| umount -l "$ex" 2>/dev/null && { ok " lazy-unmounted"; return 0; } | |
| fi | |
| return 1 | |
| } | |
| trap 'probe_umount; rmdir "$PROBE_MNT" 2>/dev/null' EXIT | |
| section "Candidate root filesystems" | |
| declare -a ROOTS=() | |
| declare -a ROOT_DESC=() | |
| SCANNED=0 | |
| mkdir -p "$PROBE_MNT" | |
| while IFS= read -r dev; do | |
| [[ -b "$dev" ]] || continue | |
| SCANNED=$((SCANNED+1)) | |
| # blkid reads a cache that can be stale on a live session; fall back to lsblk | |
| fs=$(blkid -s TYPE -o value "$dev" 2>/dev/null) | |
| [[ -z "$fs" ]] && fs=$(lsblk -no FSTYPE "$dev" 2>/dev/null | head -1 | tr -d ' ') | |
| case "$fs" in | |
| ext2|ext3|ext4|btrfs|xfs|f2fs) | |
| ;; | |
| crypto_LUKS) | |
| printf ' %-22s %-12s skipped — locked, unlock it above first\n' "$dev" "$fs" | |
| continue ;; | |
| LVM2_member) | |
| printf ' %-22s %-12s skipped — LVM PV, the volumes inside are scanned separately\n' "$dev" "$fs" | |
| continue ;; | |
| vfat|ntfs|swap|"") | |
| printf ' %-22s %-12s skipped — not a Linux root candidate\n' "$dev" "${fs:-no filesystem}" | |
| continue ;; | |
| *) | |
| printf ' %-22s %-12s skipped — unsupported filesystem type\n' "$dev" "$fs" | |
| continue ;; | |
| esac | |
| probe_put | |
| if ! probe_get "$dev"; then | |
| printf ' %-22s %-12s %sMOUNT FAILED%s — %s\n' \ | |
| "$dev" "$fs" "$C_RED" "$C_RST" "${MOUNT_ERR:-unknown error}" | |
| note " try manually: mount -o ro,noload $dev /mnt && ls /mnt" | |
| note " if the journal is damaged: fsck -f $dev (unmounted only)" | |
| continue | |
| fi | |
| if [[ -f "$PROBE_PATH/etc/os-release" && -d "$PROBE_PATH/boot" ]]; then | |
| pretty=$(grep -m1 '^PRETTY_NAME=' "$PROBE_PATH/etc/os-release" 2>/dev/null | cut -d= -f2- | tr -d '"') | |
| kern=$(find "$PROBE_PATH/boot" -maxdepth 1 -name 'vmlinuz*' 2>/dev/null | wc -l) | |
| rdisk=$(lsblk -no PKNAME "$dev" 2>/dev/null | head -1) | |
| rmodel=$(lsblk -dno MODEL "/dev/$rdisk" 2>/dev/null | sed 's/ *$//') | |
| rtran=$(lsblk -dno TRAN "/dev/$rdisk" 2>/dev/null | tr -d ' ') | |
| ROOTS+=("$dev") | |
| ROOT_DESC+=("${pretty:-unknown} on /dev/${rdisk:-?} ${rmodel:-?} [${rtran:-?}] — $fs, $kern kernel(s)") | |
| ok "$dev on /dev/${rdisk:-?} — ${pretty:-unknown}" | |
| note " ${rmodel:-unknown model} [${rtran:-?}], $fs, $kern kernel(s)${MOUNT_NOTE:+ ($MOUNT_NOTE)}" | |
| if [[ $kern -eq 0 ]]; then | |
| warn " no kernel in /boot — separate /boot partition? GRUB will have nothing to load." | |
| fi | |
| elif [[ -d "$PROBE_PATH/etc" || -d "$PROBE_PATH/usr" ]]; then | |
| printf ' %-22s %-12s looks Linux-ish but has no /etc/os-release\n' "$dev" "$fs" | |
| else | |
| printf ' %-22s %-12s mounted, but not a root filesystem\n' "$dev" "$fs" | |
| fi | |
| probe_put | |
| done < <(lsblk -pnro NAME,TYPE 2>/dev/null | awk '$2=="part"||$2=="lvm"||$2=="crypt"||$2=="dm"||$2~/^raid/{print $1}') | |
| printf '\n' | |
| info "$SCANNED block device(s) examined, ${#ROOTS[@]} root filesystem(s) found" | |
| if [[ ${#ROOTS[@]} -eq 0 ]]; then | |
| bad "no Linux root filesystem found" | |
| printf '\n' | |
| note "Full block device layout for reference:" | |
| lsblk -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINT 2>/dev/null | sed 's/^/ /' | |
| printf '\n' | |
| note "Common causes, in rough order of likelihood:" | |
| note " 1. Dirty ext4 journal from hard resets. The script now retries with" | |
| note " 'noload', but a badly damaged journal needs: fsck -f <device>" | |
| note " 2. LUKS still locked — answer yes to the unlock prompt above." | |
| note " 3. LVM not activated — run 'vgchange -ay' and re-run this script." | |
| note " 4. The install genuinely failed and there is no root filesystem." | |
| exit 1 | |
| fi | |
| ROOT_DEV="$OPT_ROOT" | |
| if [[ -z "$ROOT_DEV" ]]; then | |
| printf '\n' | |
| for i in "${!ROOTS[@]}"; do | |
| printf ' %d) %-18s %s\n' "$((i+1))" "${ROOTS[$i]}" "${ROOT_DESC[$i]}" | |
| done | |
| printf '\n %s>>%s which root filesystem? [1-%d] ' "$C_BLD$C_YEL" "$C_RST" "${#ROOTS[@]}" | |
| read -r sel </dev/tty | |
| [[ "$sel" =~ ^[0-9]+$ ]] && [[ $sel -ge 1 && $sel -le ${#ROOTS[@]} ]] || die "invalid selection" | |
| ROOT_DEV="${ROOTS[$((sel-1))]}" | |
| fi | |
| [[ -b "$ROOT_DEV" ]] || die "$ROOT_DEV is not a block device" | |
| # ============================================================================== | |
| # 4. Find candidate ESPs | |
| # ============================================================================== | |
| section "Candidate EFI System Partitions" | |
| declare -a ESPS=() | |
| declare -a ESP_DESC=() | |
| while IFS= read -r line; do | |
| eval "$line" | |
| [[ "${FSTYPE:-}" == "vfat" ]] || continue | |
| [[ "${PARTTYPE:-}" == "$ESP_GUID" || "${PARTTYPE:-}" == "0xef" ]] || continue | |
| dev="/dev/${NAME}" | |
| pdisk=$(lsblk -no PKNAME "$dev" 2>/dev/null | head -1) | |
| pmodel=$(lsblk -dno MODEL "/dev/$pdisk" 2>/dev/null | sed 's/ *$//') | |
| ptran=$(lsblk -dno TRAN "/dev/$pdisk" 2>/dev/null | tr -d ' ') | |
| prm=$(lsblk -dno RM "/dev/$pdisk" 2>/dev/null | tr -d ' ') | |
| if [[ "$ptran" == "usb" || "$prm" == "1" ]]; then | |
| warn "$dev on /dev/$pdisk (${pmodel:-?}) — USB/REMOVABLE, this is your live installer" | |
| note " Not offered as a choice." | |
| continue | |
| fi | |
| probe_put | |
| if ! probe_get "$dev"; then | |
| printf ' %-22s %-12s %sPROBE FAILED%s — %s\n' \ | |
| "$dev" "vfat" "$C_RED" "$C_RST" "${MOUNT_ERR:-unknown error}" | |
| note " Listed anyway so you can still select it." | |
| ESPS+=("$dev") | |
| ESP_DESC+=("/dev/$pdisk ${pmodel:-?} [${ptran:-?}] — ${SIZE:-?}, could not probe contents") | |
| continue | |
| fi | |
| vendors=$(find "$PROBE_PATH/EFI" -maxdepth 1 -mindepth 1 -type d -printf '%f ' 2>/dev/null) | |
| availm=$(( $(df -k --output=avail "$PROBE_PATH" 2>/dev/null | tail -1 | tr -d ' ') / 1024 )) | |
| pct=$(df -k --output=pcent "$PROBE_PATH" 2>/dev/null | tail -1 | tr -d ' ') | |
| haswin="" | |
| [[ -f "$PROBE_PATH/EFI/Microsoft/Boot/bootmgfw.efi" ]] && haswin=", Windows loader present" | |
| ESPS+=("$dev") | |
| ESP_DESC+=("/dev/$pdisk ${pmodel:-?} [${ptran:-?}] — ${SIZE:-?}, ${availm}MB free (${pct} used)${haswin}") | |
| ok "$dev on /dev/$pdisk — ${pmodel:-unknown} [${ptran:-?}]" | |
| note " ${SIZE:-?}, ${availm}MB free (${pct} used)${haswin}" | |
| note " vendors: ${vendors:-<none>}" | |
| [[ -n "$MOUNT_NOTE" ]] && note " $MOUNT_NOTE" | |
| if [[ $availm -lt 25 ]]; then | |
| warn " low free space — run 'boot-doctor.sh --clean-esp' before installing GRUB" | |
| fi | |
| probe_put | |
| done < <(lsblk -Pno NAME,FSTYPE,PARTTYPE,SIZE 2>/dev/null) | |
| ESP_DEV="$OPT_ESP" | |
| if [[ -z "$ESP_DEV" ]]; then | |
| if [[ ${#ESPS[@]} -eq 0 ]]; then | |
| bad "no EFI System Partition found on any disk" | |
| note "You will need to create one (512MB, FAT32, flagged 'esp') before" | |
| note "UEFI boot is possible. This script will not partition for you." | |
| exit 1 | |
| fi | |
| printf '\n' | |
| for i in "${!ESPS[@]}"; do | |
| printf ' %d) %-18s %s\n' "$((i+1))" "${ESPS[$i]}" "${ESP_DESC[$i]}" | |
| done | |
| printf '\n' | |
| note "Pick the ESP that already holds the Windows loader, unless you have" | |
| note "deliberately created a dedicated one for Ubuntu." | |
| printf '\n %s>>%s which ESP should /boot/efi use? [1-%d] ' "$C_BLD$C_YEL" "$C_RST" "${#ESPS[@]}" | |
| read -r sel </dev/tty | |
| [[ "$sel" =~ ^[0-9]+$ ]] && [[ $sel -ge 1 && $sel -le ${#ESPS[@]} ]] || die "invalid selection" | |
| ESP_DEV="${ESPS[$((sel-1))]}" | |
| fi | |
| [[ -b "$ESP_DEV" ]] || die "$ESP_DEV is not a block device" | |
| # ============================================================================== | |
| # 5. Separate /boot? | |
| # ============================================================================== | |
| SEP_BOOT="" | |
| probe_umount | |
| if mount -o ro "$ROOT_DEV" "$PROBE_MNT" 2>/dev/null; then | |
| if [[ -z "$(ls -A "$PROBE_MNT/boot" 2>/dev/null)" ]]; then | |
| warn "$ROOT_DEV has an empty /boot — you likely have a separate boot partition" | |
| note "Re-run with the right device, or mount it manually after chroot." | |
| fi | |
| probe_umount | |
| fi | |
| # ============================================================================== | |
| # 6. Mount everything | |
| # ============================================================================== | |
| section "Mount plan" | |
| printf ' root %s -> %s\n' "$ROOT_DEV" "$TARGET" | |
| printf ' esp %s -> %s/boot/efi\n' "$ESP_DEV" "$TARGET" | |
| printf ' bind /dev /dev/pts /proc /sys /run\n' | |
| printf ' bind /sys/firmware/efi/efivars (if present)\n' | |
| if [[ $DRY_RUN -eq 1 ]]; then | |
| printf '\n dry run — nothing mounted\n\n' | |
| exit 0 | |
| fi | |
| ask "Proceed with these mounts?" || exit 1 | |
| section "Mounting" | |
| run mkdir -p "$TARGET" || die "cannot create $TARGET" | |
| if mountpoint -q "$TARGET"; then | |
| warn "$TARGET already mounted — run '$0 --umount' first" | |
| exit 1 | |
| fi | |
| # The live desktop auto-mounts partitions under /run/media. Release them. | |
| release_automounts "$ROOT_DEV" | |
| case $? in | |
| 0) run mount "$ROOT_DEV" "$TARGET" || die "failed to mount root" ;; | |
| 2) ok "root already relocated to $TARGET" ;; | |
| *) die "could not free $ROOT_DEV — close any file manager windows and retry" ;; | |
| esac | |
| ok "root mounted at $TARGET" | |
| run mkdir -p "$TARGET/boot/efi" | |
| release_automounts "$ESP_DEV" | |
| case $? in | |
| 0) run mount "$ESP_DEV" "$TARGET/boot/efi" || die "failed to mount ESP" ;; | |
| 2) ok "ESP already relocated" ;; | |
| *) die "could not free $ESP_DEV" ;; | |
| esac | |
| ok "ESP mounted at $TARGET/boot/efi" | |
| # Confirm we mounted what we intended | |
| info "verifying:" | |
| findmnt -no SOURCE,TARGET,FSTYPE "$TARGET" | sed 's/^/ /' | |
| findmnt -no SOURCE,TARGET,FSTYPE "$TARGET/boot/efi" | sed 's/^/ /' | |
| if [[ ! -d "$TARGET/etc" ]]; then | |
| die "$TARGET has no /etc — wrong partition mounted as root" | |
| fi | |
| if [[ ! -d "$TARGET/boot/efi/EFI" ]]; then | |
| warn "$TARGET/boot/efi has no /EFI directory — is this really the ESP?" | |
| fi | |
| for d in dev dev/pts proc sys run; do | |
| run mount --rbind "/$d" "$TARGET/$d" 2>/dev/null || warn "could not bind /$d" | |
| done | |
| ok "kernel filesystems bound" | |
| if [[ -d /sys/firmware/efi/efivars ]]; then | |
| run mount --bind /sys/firmware/efi/efivars "$TARGET/sys/firmware/efi/efivars" 2>/dev/null | |
| ok "efivars bound (efibootmgr will work in the chroot)" | |
| else | |
| warn "no efivars to bind — NVRAM writes will fail inside the chroot" | |
| fi | |
| # network for apt | |
| if [[ -e /etc/resolv.conf ]]; then | |
| cp -L /etc/resolv.conf "$TARGET/etc/resolv.conf" 2>/dev/null && ok "DNS copied for apt" | |
| fi | |
| # carry the fix script in | |
| SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| DOCTOR_SRC="" | |
| DOCTOR_DEST_REL="/root/boot-doctor.sh" | |
| # Look next to this script first, then a few other plausible places | |
| for cand in \ | |
| "$SELF_DIR/boot-doctor.sh" \ | |
| "$PWD/boot-doctor.sh" \ | |
| "${SUDO_USER:+/home/$SUDO_USER/boot-doctor.sh}" \ | |
| "/root/boot-doctor.sh" \ | |
| "/cdrom/boot-doctor.sh" | |
| do | |
| [[ -n "$cand" && -f "$cand" ]] && { DOCTOR_SRC="$cand"; break; } | |
| done | |
| if [[ -n "$DOCTOR_SRC" ]]; then | |
| mkdir -p "$TARGET/root" 2>/dev/null | |
| if cp "$DOCTOR_SRC" "$TARGET$DOCTOR_DEST_REL" 2>/dev/null \ | |
| && chmod +x "$TARGET$DOCTOR_DEST_REL" 2>/dev/null \ | |
| && [[ -x "$TARGET$DOCTOR_DEST_REL" ]]; then | |
| ok "boot-doctor.sh copied from $DOCTOR_SRC" | |
| note " inside the chroot it is at: $DOCTOR_DEST_REL" | |
| else | |
| DOCTOR_SRC="" | |
| bad "copy to $TARGET$DOCTOR_DEST_REL FAILED" | |
| note " Is the target filesystem full or mounted read-only?" | |
| note " Check with: df -h $TARGET and findmnt $TARGET" | |
| fi | |
| fi | |
| if [[ -z "$DOCTOR_SRC" ]]; then | |
| warn "boot-doctor.sh not available inside the chroot" | |
| note "Searched: $SELF_DIR, $PWD, /root, /cdrom" | |
| note "Copy it in manually from another terminal:" | |
| note " sudo cp /path/to/boot-doctor.sh $TARGET/root/" | |
| note "Or just run the repair commands by hand once inside." | |
| fi | |
| # ============================================================================== | |
| # 7. Hand off | |
| # ============================================================================== | |
| section "Ready" | |
| if [[ -n "$DOCTOR_SRC" ]]; then | |
| note "Entering chroot. Inside, run:" | |
| note "" | |
| note " $DOCTOR_DEST_REL # diagnose (read-only)" | |
| note " $DOCTOR_DEST_REL --clean-esp # free ESP space first if tight" | |
| note " $DOCTOR_DEST_REL --fix # apply repairs" | |
| else | |
| note "Entering chroot without boot-doctor.sh. Minimum manual sequence:" | |
| note "" | |
| note " grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=ubuntu" | |
| note " grub-install --target=x86_64-efi --efi-directory=/boot/efi --removable" | |
| note " update-grub" | |
| fi | |
| note "" | |
| note "Type 'exit' when done, then run: $0 --umount" | |
| printf '\n' | |
| trap - EXIT | |
| chroot "$TARGET" /bin/bash | |
| section "Chroot exited" | |
| note "Remember to unmount before rebooting: $0 --umount" | |
| printf '\n' |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment