#!/usr/bin/env bash # Independent manual upgrade. Never invoked by install.sh. set -euo pipefail export LC_ALL=C usage() { echo 'Usage: bash upgrade-debian12-13.sh --check|--upgrade|--resume|--verify [--yes --recovery-confirmed] [--repair-bios-boot]'; } fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; } # Only the verified single-partition, 512-byte-sector GPT layout is repairable. bios_gap_plan() { awk ' /^label: / {if($2!="gpt")bad=1; label++} /^sector-size: / {if($2!=512)bad=1; sector++} /^first-lba: / {if($2!=34)bad=1; first++} /^\/dev\// { count++; line=$0 if(line !~ /start=[ ]*2048,/ || line !~ /type=0FC63DAF-8483-4772-8E79-3D69D8477DE4/)bad=1 } END {if(bad || label!=1 || sector!=1 || first!=1 || count!=1)exit 1; print "34 2014"}' "$1" } check_bios_boot() { [[ ! -d /sys/firmware/efi ]] || return 0 local root_device parent disk dump before after sectors root_device=$(grub-probe --target=device /boot) || return 1 [[ "$root_device" =~ ^/dev/(vd[a-z]|sd[a-z]|nvme[0-9]+n[0-9]+p)[0-9]+$ ]] || { fail 'Review unsupported boot device layout'; return 1; } parent=$(lsblk -ndo PKNAME "$root_device") [[ "$parent" =~ ^(vd[a-z]|sd[a-z]|nvme[0-9]+n[0-9]+)$ ]] || { fail 'A single physical boot disk is required'; return 1; } disk=/dev/$parent dump=$(sfdisk --dump "$disk") || return 1 [[ "$dump" == *'label: gpt'* ]] || return 0 if grep -qi 'type=21686148-6449-6E6F-744E-656564454649' <<< "$dump"; then if [[ -f "$state/bios-gpt-before.sfdisk" && ! -f "$state/bios-gpt-after.sfdisk" && "$action" != --check ]]; then cmp -s <(grep '^/dev/' "$state/bios-gpt-before.sfdisk") <(grep '^/dev/' <<< "$dump" | grep -vi 'type=21686148-6449-6E6F-744E-656564454649') || { fail 'Original partition entry changed during interrupted repair'; return 1; } grub-install --target=i386-pc "$disk" update-grub printf '%s\n' "$dump" > "$state/bios-gpt-after.sfdisk" fi return 0 fi if [[ "$action" == --check || "$repair_bios" != true ]]; then fail 'BIOS/GPT has no BIOS Boot partition; review --repair-bios-boot with recovery access'; return 1 fi [[ -s "$state/before.tar" ]] || { fail 'Original upgrade backup required'; return 1; } before=$state/bios-gpt-before.sfdisk if [[ -e "$before" ]]; then cmp -s "$before" <(printf '%s\n' "$dump") || { fail 'Boot layout changed after backup'; return 1; } else printf '%s\n' "$dump" > "$before"; fi bios_gap_plan "$before" >/dev/null || { fail 'No supported empty BIOS boot gap; no partition changes'; return 1; } sectors=$(blockdev --getsz "$disk") [[ "$sectors" =~ ^[0-9]+$ && "$sectors" -gt 4096 ]] || return 1 [[ -e "$state/bios-first-1m.bin" ]] || dd if="$disk" of="$state/bios-first-1m.bin" bs=512 count=2048 status=none [[ -e "$state/bios-last-33.bin" ]] || dd if="$disk" of="$state/bios-last-33.bin" bs=512 skip=$((sectors-33)) count=33 status=none [[ $(stat -c %s "$state/bios-first-1m.bin") == 1048576 && $(stat -c %s "$state/bios-last-33.bin") == 16896 ]] || return 1 printf 'start=34, size=2014, type=21686148-6449-6E6F-744E-656564454649, name="BIOS boot"\n' | sfdisk --append --no-reread --no-tell-kernel --wipe never --wipe-partitions never "$disk" sfdisk --verify "$disk" after=$(sfdisk --dump "$disk") # Preserve every byte of the original partition entry (UUID, offset and size). cmp -s <(grep '^/dev/' "$before") <(grep '^/dev/' <<< "$after" | grep -vi 'type=21686148-6449-6E6F-744E-656564454649') || { fail 'Original partition entry changed; inspect saved GPT before proceeding'; return 1; } partx -a "$disk" || partx -u "$disk" grub-install --target=i386-pc "$disk" update-grub printf '%s\n' "$after" > "$state/bios-gpt-after.sfdisk" } confirm_upgrade() { local expected=$1 prompt=$2 answer if "$assume_yes"; then return 0; fi printf '%s Type %s: ' "$prompt" "$expected" >/dev/tty read -r answer /dev/null || return 1 awk -v deb822="$([[ "$file" == *.sources || "$file" == *.sources.target ]] && echo 1 || echo 0)" ' function flush( i,disabled){ disabled=0 for(i=1;i<=count;i++)if(tolower(lines[i]) ~ /^enabled:[ \t]*no[ \t]*$/)disabled=1 for(i=1;i<=count;i++){ if(!disabled && tolower(lines[i]) ~ /^suites:/)gsub(/bookworm/,"trixie",lines[i]) print lines[i] } count=0;delete lines } !deb822 { line=$0 if(match(line,/^[ \t]*deb(-src)?[ \t]+(\[[^]]+\][ \t]+)?[^ \t]+[ \t]+/)){ prefix=substr(line,1,RLENGTH);tail=substr(line,RLENGTH+1) sub(/^bookworm/,"trixie",tail);line=prefix tail } print line;next } /^[ \t]*$/ {flush();print;next} {lines[++count]=$0} END{if(deb822)flush()}' "$file" } package_checks() { local report preference report=$(dpkg --audit) || { fail 'dpkg audit failed'; return 1; } [[ -z "$report" ]] || { fail 'Repair the original unfinished dpkg operation first'; return 1; } report=$(apt-mark showhold) || { fail 'Cannot read held packages'; return 1; } [[ -z "$report" ]] || { fail 'Review held packages first'; return 1; } shopt -s nullglob for preference in /etc/apt/preferences /etc/apt/preferences.d/*; do [[ ! -f "$preference" ]] || ! grep -Eq '^[[:space:]]*[^#[:space:]]' "$preference" || { fail 'Review APT pinning first'; return 1; } done } check_source_set() { local state=$1 original=() current=() file mapfile -t original < "$state/source-files" [[ ${#original[@]} -gt 0 ]] || { fail 'Empty original source plan'; return 1; } shopt -s nullglob for file in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do [[ ! -L "$file" ]] || { fail 'APT source became a symlink'; return 1; } [[ ! -e "$file" ]] || current+=("$file") done [[ "${original[*]}" == "${current[*]}" ]] || { fail 'APT source file set changed since review'; return 1; } } switch_sources() { local state=$1 file next while IFS= read -r file; do [[ -f "$file" && ! -L "$file" ]] && { cmp -s "$file" "$state/sources$file" || cmp -s "$file" "$state/sources$file.target"; } || { fail 'APT source changed after review'; return 1; } validate_source "$state/sources$file.target" trixie >/dev/null || return 1 done < "$state/source-files" while IFS= read -r file; do if cmp -s "$file" "$state/sources$file.target"; then continue; fi next=$(mktemp "$(dirname "$file")/.platform-source-XXXXXXXX") if ! cat "$state/sources$file.target" > "$next" || ! chmod --reference="$file" "$next" || ! chown --reference="$file" "$next" || ! mv -fT -- "$next" "$file"; then rm -f -- "$next"; return 1 fi done < "$state/source-files" } scan_sources() { local suite=$1 file report all_reports='' shopt -s nullglob source_files=() for file in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do [[ ! -L "$file" ]] || { fail 'Symlink APT source'; return 1; } [[ -e "$file" ]] || continue [[ "$file" != *$'\n'* && ! -L "$file" && -f "$file" ]] || { fail 'APT source is not a regular file'; return 1; } report=$(validate_source "$file" "$suite") || { fail "Review unknown/mixed/third-party source syntax: $file"; return 1; } source_files+=("$file") all_reports+=" $report" done [[ "$all_reports" == *base* && "$all_reports" == *security* ]] || { fail 'Explicit Debian base and security sources required'; return 1; } } reboot_pending() { [[ -e /run/reboot-required ]] || { [[ -e /vmlinuz ]] && [[ $(basename "$(readlink -f /vmlinuz)") != "vmlinuz-$(uname -r)" ]]; } } save_phase() { local temporary temporary=$(mktemp "$state/.phase-XXXXXXXX") printf '%s\n' "$1" > "$temporary" mv -fT -- "$temporary" "$state/phase" } main() { local action=${1:---help} case "$action" in --help|-h) usage; return 0;; --check|--upgrade|--resume|--verify) :;; *) usage; return 1;; esac shift local assume_yes=false recovery_confirmed=false repair_bios=false while (($#)); do case "$1" in --yes) assume_yes=true;; --recovery-confirmed) recovery_confirmed=true;; --repair-bios-boot) repair_bios=true;; *) fail "Unknown argument: $1"; return 1;; esac shift done ! "$assume_yes" || "$recovery_confirmed" || { fail '--yes requires --recovery-confirmed'; return 1; } [[ $EUID == 0 && -d /run/systemd/system && ! -e /.dockerenv ]] || { fail 'Run on a native systemd host as root'; return 1; } grep -Eq '^ID="?debian"?$' /etc/os-release || { fail 'Debian required'; return 1; } local state=/var/lib/platform-debian-upgrade phase='' boot_id answer file boot_id=$(cat /proc/sys/kernel/random/boot_id) [[ ! -L "$state" ]] || { fail 'State must not be a symlink'; return 1; } if [[ -e "$state" ]]; then [[ $(stat -c %u "$state") == 0 && $(stat -c %a "$state") == 700 ]] || { fail 'State must be root-owned mode 0700'; return 1; } [[ ! -L "$state/phase" ]] || { fail 'Invalid phase file'; return 1; } [[ ! -f "$state/phase" ]] || phase=$(cat "$state/phase") fi package_checks if [[ "$action" != --check ]]; then [[ "$action" == --upgrade || -d "$state" ]] || { fail 'No original upgrade state'; return 1; } "$assume_yes" || [[ -r /dev/tty && -w /dev/tty ]] || { fail 'A real controlling terminal is required'; return 1; } umask 077 install -d -m 0700 "$state" [[ ! -L "$state/lock" && ( ! -e "$state/lock" || ( -f "$state/lock" && $(stat -c %h "$state/lock") == 1 ) ) ]] || { fail 'Invalid lock'; return 1; } exec 9>>"$state/lock"; flock -n 9 || { fail 'Another upgrade is running'; return 1; } phase='' [[ ! -f "$state/phase" ]] || phase=$(cat "$state/phase") fi if [[ "$action" == --verify ]]; then [[ "$phase" == awaiting_reboot || "$phase" == verified ]] || { fail 'No completed upgrade record'; return 1; } grep -Eq '^VERSION_ID="?13"?$' /etc/os-release [[ "$boot_id" != "$(cat "$state/upgrade-boot")" ]] && ! reboot_pending || { fail 'Reboot into the installed target kernel first'; return 1; } [[ $(uname -r) != "$(cat "$state/bookworm-kernel")" && -s "/boot/vmlinuz-$(uname -r)" ]] || { fail 'Boot the installed target kernel'; return 1; } if [[ -s "$state/identities.sha256" ]]; then sha256sum --check "$state/identities.sha256"; fi save_phase verified echo 'Debian 13 local verification passed. Confirm a fresh SSH connection and existing workloads.' return 0 fi if [[ "$action" == --check || -z "$phase" ]]; then grep -Eq '^VERSION_ID="?12"?$' /etc/os-release || { fail 'Start this entry on Debian 12'; return 1; } scan_sources bookworm [[ $(df -Pk / | awk 'NR==2{print $4}') -ge 5242880 && $(df -Pk /boot | awk 'NR==2{print $4}') -ge 262144 ]] || { fail 'Need 5 GiB free on / and 256 MiB on /boot'; return 1; } if [[ -d /sys/firmware/efi ]]; then mountpoint -q /boot/efi || { fail 'Mount and review the EFI System Partition first'; return 1; }; fi if [[ "$action" == --check ]]; then check_bios_boot; echo 'Source/package/storage/boot-layout checks passed; review recovery access before --upgrade.'; return 0; fi fi "$assume_yes" || [[ -r /dev/tty && -w /dev/tty ]] || { fail 'A real controlling terminal is required'; return 1; } [[ ( "$action" == --upgrade && -z "$phase" ) || ( "$action" == --resume && -n "$phase" ) ]] || { fail 'Use --upgrade for a new run or --resume for the existing run'; return 1; } if [[ -z "$phase" ]]; then confirm_upgrade UPGRADE 'Bootloader, rescue access and required backups reviewed.' || return 1 tar --acls --xattrs -cpf "$state/before.tar" /etc /boot /var/lib/dpkg/status tar -tf "$state/before.tar" >/dev/null : > "$state/identities.sha256" for file in /etc/ssh/ssh_host_*_key.pub /etc/komodo/keys/periphery.pub; do [[ ! -f "$file" ]] || sha256sum "$file" >> "$state/identities.sha256"; done phase=prepared; save_phase "$phase" fi case "$phase" in prepared|bookworm_updated|switching_sources|sources_switched|awaiting_reboot|verified) :;; *) fail 'Unknown recorded phase'; return 1;; esac if [[ "$phase" == awaiting_reboot || "$phase" == verified ]]; then echo 'Upgrade already finished; reboot manually and use --verify.'; return 0; fi check_bios_boot if [[ "$phase" == prepared ]]; then scan_sources bookworm apt-get update; apt_upgrade full-upgrade package_checks printf '%s\n' "$boot_id" > "$state/bookworm-boot" phase=bookworm_updated; save_phase "$phase" if reboot_pending; then touch "$state/reboot-required"; echo 'Reboot Debian 12 manually, reconnect, then --resume.'; return 3; fi fi if [[ "$phase" == bookworm_updated ]]; then ! reboot_pending || { fail 'Reboot before continuing'; return 1; } [[ ! -e "$state/reboot-required" || "$boot_id" != "$(cat "$state/bookworm-boot")" ]] || return 1 scan_sources bookworm confirm_upgrade TRIXIE 'Switch the reviewed Debian sources to trixie.' || return 1 uname -r > "$state/bookworm-kernel" install -d -m 0700 "$state/sources" printf '%s\n' "${source_files[@]}" > "$state/source-files" for file in "${source_files[@]}"; do cp --parents "$file" "$state/sources/" render_source "$file" > "$state/sources${file}.target" done phase=switching_sources; save_phase "$phase" fi check_source_set "$state" if [[ "$phase" == switching_sources ]]; then switch_sources "$state" phase=sources_switched; save_phase "$phase" fi scan_sources trixie while IFS= read -r file; do cmp -s "$file" "$state/sources$file.target" || { fail 'Target source changed'; return 1; }; done < "$state/source-files" apt-get update apt_upgrade upgrade --without-new-pkgs apt_upgrade full-upgrade package_checks printf '%s\n' "$boot_id" > "$state/upgrade-boot" save_phase awaiting_reboot echo 'APT stages completed. Review kernel/bootloader output, reboot manually, reconnect and run --verify.' } if [[ ${BASH_SOURCE[0]:-} == "$0" || -z ${BASH_SOURCE[0]:-} ]]; then main "$@"; fi