#!/usr/bin/env bash # libvirt-backup.sh # Offline-Backup persistenter libvirt/QEMU-VMs für eine Host-Neuinstallation. set -Eeuo pipefail IFS=$'\n\t' umask 077 VERSION="1.2" URI="${LIBVIRT_URI:-qemu:///system}" BACKUP_ROOT="${1:-}" RSYNC_OPTS=(-aHAX --numeric-ids --sparse --info=progress2) ALLOW_RUNNING=0 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" usage() { cat <<'EOF' Aufruf: sudo ./libvirt-backup.sh /pfad/zum/backup sudo LIBVIRT_URI=qemu:///system ./libvirt-backup.sh /pfad/zum/backup Umgebungsvariablen: LIBVIRT_URI libvirt-Verbindung, Standard: qemu:///system ALLOW_RUNNING=1 Unsicher: Backup trotz laufender VMs erlauben Das Skript sichert: - Domain-XMLs - libvirt-Netzwerk-, Storage-Pool- und Interface-XMLs - alle file-basierten Disk-/CDROM-/Floppy-Quellen aus den Domain-XMLs - UEFI-NVRAM-Dateien - dateibasierte TPM-Backend-Zustände - qemu-img-Informationen und Prüfsummen - PAW-Netzwerk aus 50-paw-install.yml inklusive br-lan- und Port-MACs Blockgeräte wie LVM-LVs werden nur dokumentiert und NICHT kopiert. EOF } die() { echo "FEHLER: $*" >&2; exit 1; } warn() { echo "WARNUNG: $*" >&2; } log() { printf '\n==> %s\n' "$*"; } [[ "${ALLOW_RUNNING:-0}" == "1" ]] && ALLOW_RUNNING=1 [[ $EUID -eq 0 ]] || die "Bitte als root ausführen." [[ -n "$BACKUP_ROOT" ]] || { usage; exit 2; } for cmd in virsh rsync sha256sum python3 qemu-img find sort awk sed findmnt; do command -v "$cmd" >/dev/null || die "Benötigtes Programm fehlt: $cmd" done BACKUP_ROOT="$(readlink -m "$BACKUP_ROOT")" [[ "$BACKUP_ROOT" != "/" ]] || die "Backup-Ziel darf nicht / sein." mkdir -p "$BACKUP_ROOT" [[ -z "$(find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -print -quit)" ]] \ || die "Backup-Ziel ist nicht leer: $BACKUP_ROOT" # Früh erkennen, ob das Ziel beschreibbar ist und der Mount read-only ist. mount_target="$(findmnt -no TARGET -T "$BACKUP_ROOT" 2>/dev/null || true)" mount_opts="$(findmnt -no OPTIONS -T "$BACKUP_ROOT" 2>/dev/null || true)" [[ ",$mount_opts," != *,ro,* ]] \ || die "Backup-Dateisystem ist nur lesbar eingehängt: ${mount_target:-unbekannt}" probe="$BACKUP_ROOT/.libvirt-backup-write-test.$$" printf 'libvirt backup write test\n' > "$probe" \ || die "Backup-Ziel ist nicht beschreibbar: $BACKUP_ROOT" sync "$probe" 2>/dev/null || sync grep -qx 'libvirt backup write test' "$probe" \ || die "Schreib-/Lesetest auf dem Backup-Ziel fehlgeschlagen." rm -f "$probe" mkdir -p "$BACKUP_ROOT"/{domains,networks,pools,interfaces,files,metadata,logs} exec > >(tee -a "$BACKUP_ROOT/logs/backup.log") 2>&1 log "Prüfe libvirt-Verbindung $URI" virsh -c "$URI" uri >/dev/null mapfile -t VMS < <(virsh -c "$URI" list --all --name | sed '/^[[:space:]]*$/d') ((${#VMS[@]} > 0)) || warn "Keine persistenten Domains gefunden." if (( ! ALLOW_RUNNING )); then running=() for vm in "${VMS[@]}"; do state="$(virsh -c "$URI" domstate "$vm" 2>/dev/null | head -n1 | tr '[:upper:]' '[:lower:]')" case "$state" in "shut off"|"abgeschaltet"|"ausgeschaltet") ;; *) running+=("$vm ($state)") ;; esac done if ((${#running[@]})); then printf 'Nicht ausgeschaltete VMs:\n' >&2 printf ' - %s\n' "${running[@]}" >&2 die "Alle VMs sauber herunterfahren oder ausdrücklich ALLOW_RUNNING=1 setzen." fi fi log "Schreibe Host- und Paketinformationen" { echo "backup_format_version=$VERSION" echo "created_utc=$(date -u +%FT%TZ)" echo "hostname=$(hostname -f 2>/dev/null || hostname)" echo "libvirt_uri=$URI" echo "kernel=$(uname -srmo)" echo "virsh_version=$(virsh --version)" echo "qemu_img_version=$(qemu-img --version | head -n1)" } > "$BACKUP_ROOT/metadata/backup-info.txt" cp -a /etc/os-release "$BACKUP_ROOT/metadata/os-release" 2>/dev/null || true virsh -c "$URI" capabilities > "$BACKUP_ROOT/metadata/capabilities.xml" 2>/dev/null || true virsh -c "$URI" nodeinfo > "$BACKUP_ROOT/metadata/nodeinfo.txt" 2>/dev/null || true log "Exportiere Domain-Definitionen" for vm in "${VMS[@]}"; do safe="$(printf '%s' "$vm" | sed 's#[/[:cntrl:]]#_#g')" virsh -c "$URI" dumpxml --inactive --security-info "$vm" \ > "$BACKUP_ROOT/domains/$safe.xml" \ || virsh -c "$URI" dumpxml --inactive "$vm" > "$BACKUP_ROOT/domains/$safe.xml" printf '%s\t%s\n' "$safe" "$vm" >> "$BACKUP_ROOT/metadata/domains.tsv" virsh -c "$URI" domblklist "$vm" --details \ > "$BACKUP_ROOT/domains/$safe.domblklist.txt" || true virsh -c "$URI" domiflist "$vm" \ > "$BACKUP_ROOT/domains/$safe.domiflist.txt" || true virsh -c "$URI" snapshot-list "$vm" --tree \ > "$BACKUP_ROOT/domains/$safe.snapshots.txt" || true done export_objects() { local kind="$1" list_cmd="$2" dump_cmd="$3" outdir="$4" manifest="$5" local name safe while IFS= read -r name; do [[ -n "$name" ]] || continue safe="$(printf '%s' "$name" | sed 's#[/[:cntrl:]]#_#g')" if virsh -c "$URI" "$dump_cmd" "$name" > "$BACKUP_ROOT/$outdir/$safe.xml"; then printf '%s\t%s\n' "$safe" "$name" >> "$BACKUP_ROOT/metadata/$manifest" else warn "$kind konnte nicht exportiert werden: $name" rm -f "$BACKUP_ROOT/$outdir/$safe.xml" fi done < <( virsh -c "$URI" "$list_cmd" --all --name 2>/dev/null | sed 's/^[[:space:]]*//; s/[[:space:]]*$//; /^[[:space:]]*$/d' ) } log "Exportiere Netzwerke, Storage-Pools und Host-Interfaces" export_objects "Netzwerk" net-list net-dumpxml networks networks.tsv export_objects "Storage-Pool" pool-list pool-dumpxml pools pools.tsv export_objects "Interface" iface-list iface-dumpxml interfaces interfaces.tsv log "Ermittle referenzierte Dateien und Blockgeräte" python3 - "$BACKUP_ROOT" <<'PY' import os, sys, glob, csv import xml.etree.ElementTree as ET root = sys.argv[1] file_paths = set() block_paths = set() rows = [] for xml_path in glob.glob(os.path.join(root, "domains", "*.xml")): vm = os.path.basename(xml_path)[:-4] tree = ET.parse(xml_path) dom = tree.getroot() for disk in dom.findall("./devices/disk"): dtype = disk.get("type", "") device = disk.get("device", "") src = disk.find("source") if src is None: continue if dtype == "file" and src.get("file"): p = src.get("file") file_paths.add(p) rows.append((vm, "disk-file", device, p)) elif dtype == "block" and src.get("dev"): p = src.get("dev") block_paths.add(p) rows.append((vm, "disk-block", device, p)) elif dtype == "dir" and src.get("dir"): p = src.get("dir") rows.append((vm, "disk-dir", device, p)) elif dtype == "volume": rows.append((vm, "disk-volume", device, f"{src.get('pool','')}/{src.get('volume','')}")) elif dtype == "network": rows.append((vm, "disk-network", device, src.get("name") or src.get("protocol") or "")) nvram = dom.find("./os/nvram") if nvram is not None and nvram.text and nvram.text.strip(): p = nvram.text.strip() file_paths.add(p) rows.append((vm, "nvram", "", p)) for backend in dom.findall("./devices/tpm/backend"): for source in backend.findall("source"): p = source.get("path") or source.get("file") if p: file_paths.add(p) rows.append((vm, "tpm-file", "", p)) with open(os.path.join(root, "metadata", "references.tsv"), "w", newline="", encoding="utf-8") as f: w = csv.writer(f, delimiter="\t", lineterminator="\n") w.writerow(("domain_xml", "kind", "device", "source")) w.writerows(sorted(rows)) with open(os.path.join(root, "metadata", "file-sources.txt"), "w", encoding="utf-8") as f: for p in sorted(file_paths): f.write(p + "\n") with open(os.path.join(root, "metadata", "block-sources.txt"), "w", encoding="utf-8") as f: for p in sorted(block_paths): f.write(p + "\n") PY : > "$BACKUP_ROOT/metadata/file-map.tsv" : > "$BACKUP_ROOT/metadata/missing-files.txt" copy_source() { local src="$1" rel dst [[ -n "$src" ]] || return 0 if [[ ! -e "$src" ]]; then warn "Referenzierte Datei fehlt: $src" printf '%s\n' "$src" >> "$BACKUP_ROOT/metadata/missing-files.txt" return 0 fi # Originalpfad ohne führenden Slash innerhalb files/rootfs/ ablegen. rel="${src#/}" dst="$BACKUP_ROOT/files/rootfs/$rel" mkdir -p "$(dirname "$dst")" if [[ -d "$src" ]]; then mkdir -p "$dst" rsync "${RSYNC_OPTS[@]}" "$src/" "$dst/" printf '%s\t%s\t%s\n' "directory" "$src" "files/rootfs/$rel" \ >> "$BACKUP_ROOT/metadata/file-map.tsv" else rsync "${RSYNC_OPTS[@]}" "$src" "$dst" printf '%s\t%s\t%s\n' "file" "$src" "files/rootfs/$rel" \ >> "$BACKUP_ROOT/metadata/file-map.tsv" case "$src" in *.qcow|*.qcow2|*.qed|*.vmdk|*.vdi|*.raw|*.img) qemu-img info --backing-chain --output=json "$src" \ > "$dst.qemu-img-info.json" 2>/dev/null || true ;; esac fi } log "Kopiere referenzierte Dateien" while IFS= read -r src; do copy_source "$src" done < "$BACKUP_ROOT/metadata/file-sources.txt" if [[ -s "$BACKUP_ROOT/metadata/block-sources.txt" ]]; then warn "Blockgeräte wurden gefunden und nicht kopiert:" sed 's/^/ - /' "$BACKUP_ROOT/metadata/block-sources.txt" warn "Diese LVM-/Block-Datenträger separat sichern, z. B. per LVM-Snapshot, dd oder Storage-Replikation." fi log "Sichere PAW-Netzwerk und br-lan-MAC" if [[ -x "$SCRIPT_DIR/paw-network-backup.sh" ]]; then "$SCRIPT_DIR/paw-network-backup.sh" "$BACKUP_ROOT/network" else warn "paw-network-backup.sh fehlt neben dem Backup-Skript; Netzwerk wurde nicht gesichert." fi log "Prüfe Backup-Datenträger vor der Prüfsummenphase" mount_opts="$(findmnt -no OPTIONS -T "$BACKUP_ROOT" 2>/dev/null || true)" [[ ",$mount_opts," != *,ro,* ]] \ || die "Backup-Dateisystem wurde während des Backups nur-lesbar. Backup ist unvollständig." probe="$BACKUP_ROOT/.libvirt-backup-final-test.$$" printf 'final write test\n' > "$probe" \ || die "Abschließender Schreibtest fehlgeschlagen. Backup ist unvollständig." sync "$probe" 2>/dev/null || sync grep -qx 'final write test' "$probe" \ || die "Abschließender Lesetest fehlgeschlagen. Backup ist unvollständig." rm -f "$probe" log "Erzeuge Dateiliste und SHA-256-Prüfsummen" ( cd "$BACKUP_ROOT" find . -type f \ ! -path './metadata/SHA256SUMS' \ ! -path './logs/backup.log' \ -print0 | sort -z | xargs -0 sha256sum > metadata/SHA256SUMS ) sync log "Backup abgeschlossen: $BACKUP_ROOT" echo "Vor der Neuinstallation prüfen:" echo " cd '$BACKUP_ROOT' && sha256sum -c metadata/SHA256SUMS" [[ ! -s "$BACKUP_ROOT/metadata/block-sources.txt" ]] \ || echo "ACHTUNG: Blockgeräte laut metadata/block-sources.txt separat sichern."