#!/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'; } fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; } # 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" || !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[ \t]+/) {reject();next} sub(/^[ \t]*deb[ \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[ \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 [[ $# == 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; } [[ -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 echo 'Source/package/storage checks passed; review bootloader and recovery access before --upgrade.'; return 0; fi fi [[ -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 printf 'Bootloader, rescue access and required backups reviewed. Type UPGRADE: ' >/dev/tty read -r answer /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 if [[ "$phase" == prepared ]]; then scan_sources bookworm apt-get update; apt-get full-upgrade "$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 printf 'Switch the reviewed Debian sources to trixie. Type TRIXIE: ' >/dev/tty read -r answer "$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-get upgrade --without-new-pkgs "$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