Files
pxe-server/playbook/clonezilla-scripts/pxe-cz.sh
cproudlock 9d56a5369e Clonezilla back up / restore by machine number
Boot menu: "Clonezilla: back up this PC" and "restore this PC" ask for the
machine number and boot Clonezilla (64-bit with Debian shim, or 32-bit
i686 by cpuid) with ocs_repository=smb3://clonezilla:<pw>@server/clonezilla/,
ocs_prerun fetching clonezilla-scripts/pxe-cz.sh and ocs_live_run running
it. The script validates the machine number, saves
<machine>-<YYYY-MM-DD-HHMM> (server time), writes pxe-meta.txt, runs
ocs-chkimg, and restores only that machine's images with -k1 -r after a
typed YES. The old entry is "Clonezilla: manual".

Playbook: clonezilla Samba user for the [clonezilla] share only, password
from clonezilla_smb_password (validated, filled into the menu
placeholder), scripts installed to the web root.

Webapp: Clonezilla backups page lists image folders and zips grouped by
machine number with serial/model/version/check, serial mismatch flag,
delete, move to another machine, streamed tar download, disk space bar
and an explicit keep-last-N retention with preview. Tests and a VM test
plan added.
2026-10-15 15:26:30 -04:00

474 lines
16 KiB
Bash

#!/bin/bash
# PXE Clonezilla: back up or restore a PC by its machine number.
#
# The iPXE menu (GetPxeScript.aspx, "Clonezilla: back up / restore this
# PC") boots Clonezilla live with:
# ocs_prerun="wget ... -O /tmp/pxe-cz.sh http://<server>/clonezilla-scripts/pxe-cz.sh"
# ocs_repository="smb3://clonezilla:<password>@<server>/clonezilla/"
# ocs_live_run="bash /tmp/pxe-cz.sh"
# pxe_cz_mode=save|restore pxe_machine=<typed text> pxe_end=1
# Clonezilla runs the prerun (this download), mounts the repository on
# /home/partimag, then runs this script as root on the console.
#
# Images are named <machine>-<YYYY-MM-DD-HHMM> (server time, site zone),
# with pxe-meta.txt (machine, serial, model, disk, date, Clonezilla
# version, 32/64-bit, check result) next to Clonezilla's own files. The
# PXE webapp (Tools > Clonezilla backups) reads both.
#
# Runs under Clonezilla live's bash (3.3.3 amd64 and 3.2.0-5 i686, both
# Debian with coreutils, util-linux, dmidecode, wget). Plain ASCII only.
set -u
# PXE_CZ_* overrides exist only for testing this script off Clonezilla.
OCSROOT="${PXE_CZ_OCSROOT:-/home/partimag}"
CMDLINE_FILE="${PXE_CZ_CMDLINE:-/proc/cmdline}"
SYS_BLOCK="${PXE_CZ_SYS_BLOCK:-/sys/block}"
SITE_TZ="America/New_York"
SERVER="$(sed -n 's/.*ocs_prerun="[^"]*http:\/\/\([^/":]*\).*/\1/p' "$CMDLINE_FILE" 2>/dev/null)"
[ -n "$SERVER" ] || SERVER=172.16.9.1
CMDLINE=" $(cat "$CMDLINE_FILE" 2>/dev/null) "
# --- output helpers -------------------------------------------------------
line() { echo "======================================================================"; }
banner() {
echo
line
for l in "$@"; do echo " $l"; done
line
echo
}
# Ask what to do at the end. Success: power off after 60 s unless a key is
# pressed. Failure: wait, the operator has to read the message.
finish() {
local ok="$1" ans
sync
while true; do
echo
echo " [Enter] power off r = reboot s = shell (type exit to come back)"
if [ "$ok" = "yes" ]; then
echo -n " Powering off in 60 seconds... "
if ! read -r -t 60 ans; then ans=""; echo; fi
else
echo -n " Choice: "
read -r ans || ans=""
fi
case "$ans" in
r|R) umount "$OCSROOT" 2>/dev/null; sync; reboot; sleep 30 ;;
s|S) bash -i ;;
*) umount "$OCSROOT" 2>/dev/null; sync; poweroff; sleep 30 ;;
esac
done
}
fail() {
banner "FAILED: $1" "${@:2}"
finish no
exit 1
}
# --- machine number -------------------------------------------------------
# Letters, digits and dash, starting with a letter or digit, up to 32.
valid_machine() {
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,31}$ ]]
}
# The text typed at the iPXE prompt sits between pxe_machine= and the
# pxe_end=1 sentinel, so a typed space shows up here and is rejected
# instead of being split into another kernel parameter.
machine_from_cmdline() {
local rest
case "$CMDLINE" in
*" pxe_machine="*" pxe_end=1 "*) ;;
*) echo ""; return ;;
esac
rest="${CMDLINE#* pxe_machine=}"
echo "${rest%% pxe_end=1 *}"
}
# Sets MACHINE (upper case). Not run in $(...): finish() reads the
# keyboard and must write to the console.
get_machine() {
local m
m="$(machine_from_cmdline)"
if [ -z "$m" ] || ! valid_machine "$m"; then
if [ -n "$m" ]; then
banner "The machine number typed at the boot menu is not valid:" \
" [$m]" "Use letters, digits and dash only (for example 6501 or WJ-6501)."
else
banner "No machine number was given at the boot menu."
fi
while true; do
echo -n " Machine number (letters, digits, dash; empty = stop): "
read -r m || m=""
[ -n "$m" ] || finish no
valid_machine "$m" && break
echo " [$m] is not a valid machine number: use letters, digits and dash only."
done
fi
MACHINE="$(echo "$m" | tr '[:lower:]' '[:upper:]')"
}
# --- time -----------------------------------------------------------------
# The PC's clock is often wrong (dead CMOS battery on old PCs, local time
# on Windows PCs), so take the time from the PXE server's HTTP Date header
# and set the live system clock from it. Falls back to the PC clock.
TIME_SOURCE="pc-clock"
sync_clock() {
local hdr epoch
hdr="$(wget -q -S --spider -T 10 -t 2 "http://$SERVER/clonezilla-scripts/pxe-cz.sh" 2>&1 \
| sed -n 's/^ *Date: *//p' | head -1)"
[ -n "$hdr" ] || return 0
epoch="$(date -u -d "$hdr" +%s 2>/dev/null)" || return 0
[ -n "$epoch" ] || return 0
date -u -s "@$epoch" >/dev/null 2>&1 && TIME_SOURCE="pxe-server"
}
site_date() {
if [ -e "/usr/share/zoneinfo/$SITE_TZ" ]; then
TZ="$SITE_TZ" date "$@"
else
date "$@"
fi
}
# --- hardware -------------------------------------------------------------
dmi() {
local v
v="$(dmidecode -s "$1" 2>/dev/null | grep -v '^#' | head -1 | tr -d '\r' | sed 's/^ *//; s/ *$//')"
echo "$v"
}
# Whole disks a backup can come from or a restore can go to: no CD, loop,
# RAM, zram, device-mapper, md, eMMC boot areas, or empty card readers.
list_disks() {
local d n size
for d in "$SYS_BLOCK"/*; do
n="${d##*/}"
case "$n" in
loop*|sr*|ram*|zram*|dm-*|md*|fd*|nbd*|*boot[0-9]|*rpmb) continue ;;
esac
size="$(cat "$d/size" 2>/dev/null)"
[ -n "$size" ] && [ "$size" -gt 0 ] 2>/dev/null || continue
echo "$n"
done
}
disk_bytes() { echo $(( $(cat "$SYS_BLOCK/$1/size" 2>/dev/null || echo 0) * 512 )); }
disk_model() {
local m
m="$(cat "$SYS_BLOCK/$1/device/model" 2>/dev/null | sed 's/^ *//; s/ *$//')"
[ -n "$m" ] || m="$(lsblk -dn -o MODEL "/dev/$1" 2>/dev/null | sed 's/^ *//; s/ *$//')"
[ -n "$m" ] || m="unknown model"
echo "$m"
}
disk_tran() { lsblk -dn -o TRAN "/dev/$1" 2>/dev/null | sed 's/ //g'; }
human() {
local b="$1"
awk -v b="$b" 'BEGIN { if (b >= 1e12) printf "%.1f TB", b/1e12; else printf "%.0f GB", b/1e9 }'
}
describe_disk() {
local n="$1" t
t="$(disk_tran "$n")"
echo "/dev/$n $(human "$(disk_bytes "$n")") $(disk_model "$n")${t:+ ($t)}"
}
# pick_disk "prompt" -> disk name on stdout. One disk: that one.
pick_disk() {
local prompt="$1" disks n i ans
mapfile -t disks < <(list_disks)
if [ "${#disks[@]}" -eq 0 ]; then
echo ""
return
fi
if [ "${#disks[@]}" -eq 1 ]; then
echo "${disks[0]}"
return
fi
while true; do
{
echo
echo " $prompt"
i=1
for n in "${disks[@]}"; do
echo " $i) $(describe_disk "$n")"
i=$((i + 1))
done
echo -n " Number (1-${#disks[@]}): "
} >&2
read -r ans || ans=""
if [[ "$ans" =~ ^[0-9]+$ ]] && [ "$ans" -ge 1 ] && [ "$ans" -le "${#disks[@]}" ]; then
echo "${disks[$((ans - 1))]}"
return
fi
echo " Type a number from the list." >&2
done
}
live_version() {
local v=""
if [ -e /etc/ocs/ocs-live.conf ]; then
# shellcheck disable=SC1091
v="$(. /etc/ocs/ocs-live.conf >/dev/null 2>&1; echo "${drbl_ocs_live_ver:-}")"
fi
[ -n "$v" ] || v="clonezilla $(dpkg-query -W -f='${Version}' clonezilla 2>/dev/null)"
echo "$v"
}
bits() {
case "$(uname -m)" in
x86_64|amd64) echo 64 ;;
*) echo 32 ;;
esac
}
# set_meta FILE KEY VALUE: replace or add one key=value line.
set_meta() {
local f="$1" k="$2" v="$3" tmp
v="$(echo "$v" | tr -d '\r\n')"
tmp="$(mktemp /tmp/pxe-meta.XXXXXX)"
[ -e "$f" ] && grep -v "^$k=" "$f" > "$tmp"
echo "$k=$v" >> "$tmp"
cat "$tmp" > "$f"
rm -f "$tmp"
}
get_meta() {
sed -n "s/^$2=//p" "$1" 2>/dev/null | head -1
}
repo_ready() {
mountpoint -q "$OCSROOT" 2>/dev/null || grep -qs " $OCSROOT cifs " /proc/mounts
}
# --- backup ---------------------------------------------------------------
do_save() {
local m="$1" disk stamp img n meta rc chk
disk="$(pick_disk "This PC has more than one disk. Which one do you want to back up?")"
[ -n "$disk" ] || fail "No disk found to back up." "Check the disk is connected and shows in the BIOS."
stamp="$(site_date +%Y-%m-%d-%H%M)"
img="$m-$stamp"
n=2
while [ -e "$OCSROOT/$img" ]; do
img="$m-$stamp-$n"
n=$((n + 1))
done
banner "BACKUP of machine $m" \
"Disk: $(describe_disk "$disk")" \
"Image: $img" \
"To: \\\\$SERVER\\clonezilla\\$img"
ocs-sr -q2 -batch -j2 -z9p -i 0 -sfsck -scs -senc -p command savedisk "$img" "$disk"
rc=$?
meta="$OCSROOT/$img/pxe-meta.txt"
if [ ! -d "$OCSROOT/$img" ]; then
fail "Clonezilla did not create the image $img (exit code $rc)." \
"See the messages above. Nothing was saved."
fi
: > "$meta"
set_meta "$meta" machine "$m"
set_meta "$meta" date "$(site_date '+%Y-%m-%d %H:%M %Z')"
set_meta "$meta" date_utc "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
set_meta "$meta" time_source "$TIME_SOURCE"
set_meta "$meta" serial "$(dmi system-serial-number)"
set_meta "$meta" manufacturer "$(dmi system-manufacturer)"
set_meta "$meta" model "$(dmi system-product-name)"
set_meta "$meta" disk "$disk"
set_meta "$meta" disk_model "$(disk_model "$disk")"
set_meta "$meta" disk_size_bytes "$(disk_bytes "$disk")"
set_meta "$meta" clonezilla "$(live_version)"
set_meta "$meta" bits "$(bits)"
if [ -d /sys/firmware/efi ]; then set_meta "$meta" firmware uefi; else set_meta "$meta" firmware bios; fi
if [ "$rc" -ne 0 ]; then
set_meta "$meta" save failed
set_meta "$meta" check not-run
fail "Clonezilla could not save machine $m (exit code $rc)." \
"The partial image $img is marked as failed on the share;" \
"delete it in the webapp (Tools > Clonezilla backups) and try again."
fi
set_meta "$meta" save ok
banner "Checking the saved image $img ..."
ocs-chkimg -b "$img"
chk=$?
set_meta "$meta" check_date "$(site_date '+%Y-%m-%d %H:%M %Z')"
if [ "$chk" -ne 0 ]; then
set_meta "$meta" check failed
fail "The image $img was saved but its CHECK FAILED (exit code $chk)." \
"Do not rely on it. Back this PC up again."
fi
set_meta "$meta" check ok
sync
banner "SUCCESS: machine $m backed up and checked." \
"Image: $img" \
"Serial: $(get_meta "$meta" serial) Model: $(get_meta "$meta" model)"
finish yes
}
# --- restore --------------------------------------------------------------
is_cz_image() {
local d="$1"
[ -d "$d" ] || return 1
[ -f "$d/disk" ] || [ -f "$d/parts" ] || return 1
local f
for f in "$d"/*; do
case "${f##*/}" in
*-ptcl-img*|*.dd-img*|*-img.*|clonezilla-img) return 0 ;;
esac
done
return 1
}
# Images of one machine, newest first: <machine>-YYYY-MM-DD-HHMM[-n] by
# name, plus any image whose pxe-meta.txt says machine=<machine>.
machine_images() {
local m="$1" d n mm
for d in "$OCSROOT"/*/; do
d="${d%/}"
n="${d##*/}"
is_cz_image "$d" || continue
mm="$(get_meta "$d/pxe-meta.txt" machine | tr '[:lower:]' '[:upper:]')"
if [ "$mm" = "$m" ]; then
echo "$n"
elif [ -z "$mm" ] && echo "$n" | tr '[:lower:]' '[:upper:]' | grep -qE "^$m-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}(-[0-9]+)?$"; then
echo "$n"
fi
done | sort_newest
}
# Sort names by their date stamp (the last YYYY-MM-DD-HHMM[-n]), newest first.
sort_newest() {
local n key
while read -r n; do
key="$(echo "$n" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}(-[0-9]+)?$')"
echo "${key:-0000}|$n"
done | sort -r | cut -d'|' -f2-
}
image_disks() { tr -s ' \t' '\n' < "$OCSROOT/$1/disk" 2>/dev/null | grep -v '^$'; }
image_disk_bytes() {
local img="$1" src="$2" b sec
b="$(get_meta "$OCSROOT/$img/pxe-meta.txt" disk_size_bytes)"
if [[ "$b" =~ ^[0-9]+$ ]]; then echo "$b"; return; fi
# Clonezilla's sfdisk dump: "last-lba: N" (GPT) or the partitions' end.
sec="$(sed -n 's/^last-lba: *//p' "$OCSROOT/$img/$src-pt.sf" 2>/dev/null | head -1)"
if [[ "$sec" =~ ^[0-9]+$ ]]; then echo $(( (sec + 34) * 512 )); return; fi
echo 0
}
do_restore() {
local m="$1" imgs i n ans img src srcb tgt tgtb meta chkopt rc mo se ck
mapfile -t imgs < <(machine_images "$m")
if [ "${#imgs[@]}" -eq 0 ]; then
fail "No images for machine $m on \\\\$SERVER\\clonezilla." \
"Check the machine number, or look in the webapp (Tools > Clonezilla backups)."
fi
while true; do
echo
echo " Images of machine $m (newest first):"
i=1
for n in "${imgs[@]}"; do
meta="$OCSROOT/$n/pxe-meta.txt"
mo="$(get_meta "$meta" model)"; se="$(get_meta "$meta" serial)"; ck="$(get_meta "$meta" check)"
echo " $i) $n ${mo:-model ?} serial ${se:-?} check: ${ck:-?}"
i=$((i + 1))
done
echo -n " Image to restore (1-${#imgs[@]}, q = quit): "
read -r ans || ans=q
case "$ans" in q|Q) finish no ;; esac
if [[ "$ans" =~ ^[0-9]+$ ]] && [ "$ans" -ge 1 ] && [ "$ans" -le "${#imgs[@]}" ]; then
img="${imgs[$((ans - 1))]}"
break
fi
echo " Type a number from the list."
done
meta="$OCSROOT/$img/pxe-meta.txt"
if [ "$(get_meta "$meta" save)" = "failed" ]; then
fail "The image $img is marked as a FAILED backup and cannot be restored."
fi
mapfile -t src < <(image_disks "$img")
if [ "${#src[@]}" -ne 1 ]; then
fail "The image $img holds ${#src[@]} disks (${src[*]:-none})." \
"Restore it with Clonezilla: manual (boot menu)."
fi
srcb="$(image_disk_bytes "$img" "${src[0]}")"
tgt="$(pick_disk "Which disk do you want to restore $img to? (ALL DATA ON IT WILL BE ERASED)")"
[ -n "$tgt" ] || fail "No disk found to restore to."
tgtb="$(disk_bytes "$tgt")"
if [ "$srcb" -gt 0 ] && [ "$tgtb" -lt "$srcb" ]; then
fail "The target disk is SMALLER than the disk the image came from." \
"Image disk: $(human "$srcb")" "Target disk: $(describe_disk "$tgt")" \
"Use a disk at least as large, or Clonezilla: manual (expert mode, -icds)."
fi
banner "RESTORE machine $m" \
"Image: $img (saved $(get_meta "$meta" date))" \
"From: $(get_meta "$meta" model) serial $(get_meta "$meta" serial) disk $(human "$srcb")" \
"Target: $(describe_disk "$tgt")" \
"" \
"EVERYTHING on /dev/$tgt will be ERASED and replaced by this image." \
"This PC: $(dmi system-product-name) serial $(dmi system-serial-number)"
echo -n " Type YES (capitals) to erase /dev/$tgt and restore: "
read -r ans || ans=""
if [ "$ans" != "YES" ]; then
banner "Cancelled. Nothing was written."
finish no
fi
# A larger target: -k1 recreates the partition table proportionally
# (every partition scaled by target/source size) and -r grows each
# filesystem to its new partition, so the extra space is used. The
# default (-k0) copies the table as-is and leaves the rest unallocated.
# The image was checked after it was saved; check again only if that
# check did not pass.
chkopt=""
[ "$(get_meta "$meta" check)" = "ok" ] && chkopt="-scr"
ocs-sr -g auto -e1 auto -e2 -r -j2 -k1 $chkopt -batch -p command restoredisk "$img" "$tgt"
rc=$?
if [ "$rc" -ne 0 ]; then
fail "Clonezilla could not restore $img to /dev/$tgt (exit code $rc)." \
"The disk may be partly written: do not boot from it until a restore succeeds."
fi
sync
banner "SUCCESS: $img restored to /dev/$tgt." "At the next start, boot this PC from its disk (not the network)."
finish yes
}
# --- main -----------------------------------------------------------------
MODE="$(echo "$CMDLINE" | sed -n 's/.* pxe_cz_mode=\([a-z]*\) .*/\1/p')"
case "$MODE" in
save|restore) ;;
*) fail "Unknown mode [$MODE] on the kernel command line (pxe_cz_mode=save or restore)." ;;
esac
if ! repo_ready; then
fail "The backup share \\\\$SERVER\\clonezilla is not mounted on $OCSROOT." \
"Check the PXE server is up and the clonezilla Samba account works" \
"(playbook var clonezilla_smb_password), then boot this option again."
fi
sync_clock
MACHINE=""
get_machine
case "$MODE" in
save) do_save "$MACHINE" ;;
restore) do_restore "$MACHINE" ;;
esac