314 lines
16 KiB
Bash
314 lines
16 KiB
Bash
#!/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/tty
|
|
[[ "$answer" == "$expected" ]]
|
|
}
|
|
apt_upgrade() {
|
|
if "$assume_yes"; then
|
|
DEBIAN_FRONTEND=noninteractive APT_LISTCHANGES_FRONTEND=none apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold "$@" </dev/null
|
|
else apt-get "$@" </dev/tty; fi
|
|
}
|
|
# Validate only explicitly supported official source syntax. Unknown layouts stop.
|
|
validate_source() {
|
|
local file=$1 suite=$2
|
|
awk -v suite="$suite" -v deb822="$([[ "$file" == *.sources || "$file" == *.sources.target ]] && echo 1 || echo 0)" '
|
|
function reject(){bad=1}
|
|
function valid_uri(u){return u ~ /^https?:\/\/(deb\.debian\.org|security\.debian\.org)\/debian(-security)?\/?$/}
|
|
function valid_suite(s){if(s==suite)base=1;if(s==suite "-security")security=1;return s==suite || s==suite "-updates" || s==suite "-security"}
|
|
function reset(){have=0;types=uri=suites=enabled=components="";delete seen}
|
|
function block( n,a,i){
|
|
if(!have)return
|
|
if(enabled!="" && enabled!="yes" && enabled!="no")reject()
|
|
if(enabled=="no"){reset();return}
|
|
if(types!="deb" && types!="deb-src" && types!="deb deb-src" && types!="deb-src deb")reject()
|
|
if(!valid_uri(uri) || suites=="" || components=="")reject()
|
|
n=split(suites,a,/[ \t]+/);for(i=1;i<=n;i++)if(!valid_suite(a[i]))reject()
|
|
if(uri ~ /debian-security\/?$/ && suites!=suite "-security")reject()
|
|
if(uri !~ /debian-security\/?$/ && suites ~ /-security/)reject()
|
|
reset()
|
|
}
|
|
/^[ \t]*#/ {next}
|
|
/^[ \t]*$/ {if(deb822)block();next}
|
|
!deb822 {
|
|
line=$0;sub(/[ \t]+#.*/,"",line)
|
|
if(line !~ /^[ \t]*deb(-src)?[ \t]+/) {reject();next}
|
|
sub(/^[ \t]*deb(-src)?[ \t]+/,"",line)
|
|
if(line ~ /^\[/){
|
|
if(line !~ /^\[[^]]+\][ \t]+/){reject();next}
|
|
options=line;sub(/\].*$/,"",options);sub(/^\[/,"",options)
|
|
n=split(options,a,/[ \t]+/);delete options_seen
|
|
for(i=1;i<=n;i++){
|
|
if(a[i]!~/^(arch|signed-by)=[A-Za-z0-9_\/.+,-]+$/)reject()
|
|
option=a[i];sub(/=.*/,"",option);if(options_seen[option]++)reject()
|
|
}
|
|
sub(/^\[[^]]+\][ \t]+/,"",line)
|
|
}
|
|
n=split(line,a,/[ \t]+/)
|
|
if(n<3 || !valid_uri(a[1]) || !valid_suite(a[2]))reject()
|
|
if((a[1] ~ /debian-security\/?$/)!=(a[2]==suite "-security"))reject()
|
|
next
|
|
}
|
|
deb822 {
|
|
if($0 !~ /^[A-Za-z][A-Za-z-]*:/){reject();next}
|
|
field=tolower($0);sub(/:.*/,"",field)
|
|
if(seen[field]++)reject()
|
|
value=$0;sub(/^[^:]+:[ \t]*/,"",value);sub(/[ \t]+$/,"",value)
|
|
if(field=="types")types=value
|
|
else if(field=="uris")uri=value
|
|
else if(field=="suites")suites=value
|
|
else if(field=="enabled")enabled=tolower(value)
|
|
else if(field=="components")components=value
|
|
else if(field!="signed-by" && field!="architectures")reject()
|
|
have=1
|
|
}
|
|
END{if(deb822)block();if(bad)exit 1;if(base)print "base";if(security)print "security"}' "$file"
|
|
}
|
|
# Change only active suite fields, including every suite in a Deb822 list.
|
|
render_source() {
|
|
local file=$1
|
|
validate_source "$file" bookworm >/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
|