diff --git a/tools/pve/host-migrate.sh b/tools/pve/host-migrate.sh new file mode 100644 index 000000000..e8b25d05d --- /dev/null +++ b/tools/pve/host-migrate.sh @@ -0,0 +1,717 @@ +#!/usr/bin/env bash + +# Copyright (c) 2021-2026 community-scripts ORG +# Author: MickLesk (CanbiZ) +# License: MIT +# https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE + +source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/refs/heads/main/misc/core.func) +source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/api.func) 2>/dev/null || true +load_functions +declare -f init_tool_telemetry &>/dev/null && init_tool_telemetry "host-migrate" "pve" + +BACKTITLE="Proxmox VE Helper Scripts - Host Migrate" +BUNDLE_PREFIX="pve-migrate" +NFS_MOUNTPOINT="" +NFS_MOUNTED=0 + +function header_info { + clear + cat <<"EOF" + __ __ __ __ ___ _ __ + / / / /___ _____/ /_ / |/ /(_)____ _____ ____ / /_ ___ + / /_/ / __ \/ ___/ __/ / /|_/ // // __ `/ ___/ __ `/ __// _ \ + / __ / /_/ (__ ) /_ / / / // // /_/ / / / /_/ / /_ / __/ +/_/ /_/\____/____/\__/ /_/ /_//_/ \__, /_/ \__,_/\__/ \___/ + /____/ EXPORT / IMPORT +EOF +} + +# ---------------------------------------------------------------------------- +# Pre-flight checks +# ---------------------------------------------------------------------------- +header_info + +if [ "$(id -u)" -ne 0 ]; then + msg_error "This script must be run as root." + exit 1 +fi + +if ! command -v pveversion >/dev/null 2>&1; then + msg_error "No Proxmox VE detected!" + exit 1 +fi + +# ---------------------------------------------------------------------------- +# Helpers +# ---------------------------------------------------------------------------- + +# Cleanup handler: unmount NFS if we mounted it ourselves. +function cleanup { + if [ "$NFS_MOUNTED" -eq 1 ] && mountpoint -q "$NFS_MOUNTPOINT"; then + umount "$NFS_MOUNTPOINT" 2>/dev/null && rmdir "$NFS_MOUNTPOINT" 2>/dev/null + fi +} +trap cleanup EXIT + +# Show currently mounted filesystems (real storage only) and let the user pick +# one. Echoes the chosen mountpoint on stdout. Returns non-zero on cancel. +function browse_mounts { + local menu=() target fstype size avail source + local -A seen=() + # Pseudo / non-storage filesystems we never want as a backup target. + local exclude='tmpfs|devtmpfs|squashfs|overlay|fuse|fuse.lxcfs|cgroup|cgroup2|mqueue|pstore|bpf|debugfs|tracefs|configfs|sysfs|proc|autofs|ramfs|efivarfs|fusectl|securityfs|binfmt_misc|hugetlbfs|rpc_pipefs|devpts' + + if command -v findmnt >/dev/null 2>&1; then + while IFS=$'\t' read -r target source fstype size avail; do + [ -z "$target" ] && continue + seen["$target"]=1 + local note="" + [ "$target" = "/" ] && note=" <-- SYSTEM ROOT, use a subfolder!" + menu+=("$target" "${fstype} | ${avail:-?} free of ${size:-?} | ${source}${note}") + done < <(findmnt -rnD -o TARGET,SOURCE,FSTYPE,SIZE,AVAIL 2>/dev/null | + awk -v ex="$exclude" 'BEGIN{OFS="\t"} $3 !~ ("^(" ex ")$") && $1 !~ "^/(proc|sys|dev|run|boot)(/|$)" && $1 != "/etc/pve" && !s[$1]++ {print $1,$2,$3,$4,$5}') + else + while read -r source target fstype _; do + [[ "$fstype" =~ ^($exclude)$ ]] && continue + [[ "$target" =~ ^/(proc|sys|dev|run|boot)(/|$) ]] && continue + [ "$target" = "/etc/pve" ] && continue + seen["$target"]=1 + menu+=("$target" "${fstype} | ${source}") + done < <(awk '{print $1, $2, $3}' /proc/mounts) + fi + + # Append Proxmox directory-type storages (path + free space). These often live + # on an existing filesystem (e.g. /mnt/backup on root) and would otherwise be + # invisible to a pure mount listing. + if command -v pvesm >/dev/null 2>&1; then + local sid savail spath + while IFS=$'\t' read -r sid savail; do + [ -z "$sid" ] && continue + spath=$(awk -v id="$sid" ' + $1=="dir:" && $2==id {f=1; next} + f && $1=="path" {print $2; exit} + f && /^[^[:space:]]/ {exit}' /etc/pve/storage.cfg 2>/dev/null) + [ -n "$spath" ] && [ -d "$spath" ] && [ -z "${seen[$spath]:-}" ] || continue + seen["$spath"]=1 + menu+=("$spath" "pve-storage: ${sid} | ${savail} free") + done < <(pvesm status 2>/dev/null | awk 'NR>1 && $3=="active" && $2=="dir" {printf "%s\t%.1fG\n", $1, $6/1048576}') + fi + + if [ "${#menu[@]}" -eq 0 ]; then + msg_error "No usable mounts found. Mount an SSD/USB/NFS first or use the NFS option." + sleep 3 + return 1 + fi + + whiptail --backtitle "$BACKTITLE" --title "Mounted Filesystems / Storages" --menu \ + "\nSelect a target location (free space shown):" 22 110 12 "${menu[@]}" 3>&1 1>&2 2>&3 +} + +# Ask the user whether the destination/source is a local path or an NFS share. +# Sets global variable BASE_DIR to a usable directory. +function choose_location { + local prompt_title="$1" + local default_path="$2" + local choice + + choice=$(whiptail --backtitle "$BACKTITLE" --title "$prompt_title" --menu \ + "\nWhere is the migration bundle located?" 16 74 4 \ + "browse" "Pick from currently mounted filesystems" \ + "local" "Type a local path manually (SSD, USB, mount)" \ + "nfs" "NFS share (mount on demand)" \ + 3>&1 1>&2 2>&3) || return 1 + + if [ "$choice" = "browse" ]; then + local picked sub + picked=$(browse_mounts) || return 1 + [ -z "$picked" ] && return 1 + sub=$(whiptail --backtitle "$BACKTITLE" --inputbox \ + "\nOptional subfolder under:\n${picked}\n\nLeave empty to use it directly." 12 70 \ + --title "Subfolder" 3>&1 1>&2 2>&3) || return 1 + BASE_DIR="${picked%/}${sub:+/${sub#/}}" + if [ ! -d "$BASE_DIR" ]; then + mkdir -p "$BASE_DIR" || { + msg_error "Could not create '$BASE_DIR'" + return 1 + } + fi + return 0 + elif [ "$choice" = "nfs" ]; then + local nfs_server nfs_export + nfs_server=$(whiptail --backtitle "$BACKTITLE" --inputbox \ + "\nNFS server (IP or hostname):\ne.g. 192.168.1.10" 11 68 \ + --title "NFS Server" 3>&1 1>&2 2>&3) || return 1 + nfs_export=$(whiptail --backtitle "$BACKTITLE" --inputbox \ + "\nExported path on the NFS server:\ne.g. /volume1/proxmox-backups" 11 68 \ + --title "NFS Export Path" 3>&1 1>&2 2>&3) || return 1 + + if [ -z "$nfs_server" ] || [ -z "$nfs_export" ]; then + msg_error "NFS server and export path are required." + sleep 2 + return 1 + fi + + if ! command -v mount.nfs >/dev/null 2>&1; then + msg_info "Installing nfs-common" + apt-get update &>/dev/null + apt-get install -y nfs-common &>/dev/null || { + msg_error "Failed to install nfs-common" + return 1 + } + msg_ok "Installed nfs-common" + fi + + NFS_MOUNTPOINT="/mnt/${BUNDLE_PREFIX}-nfs-$$" + mkdir -p "$NFS_MOUNTPOINT" + msg_info "Mounting ${nfs_server}:${nfs_export}" + if mount -t nfs "${nfs_server}:${nfs_export}" "$NFS_MOUNTPOINT" 2>/dev/null; then + NFS_MOUNTED=1 + msg_ok "Mounted NFS share at ${NFS_MOUNTPOINT}" + BASE_DIR="$NFS_MOUNTPOINT" + else + msg_error "Could not mount ${nfs_server}:${nfs_export}" + rmdir "$NFS_MOUNTPOINT" 2>/dev/null + sleep 2 + return 1 + fi + else + local path + path=$(whiptail --backtitle "$BACKTITLE" --inputbox \ + "\nLocal directory (must already exist / be mounted):" 11 68 \ + --title "Local Path" "$default_path" 3>&1 1>&2 2>&3) || return 1 + path="${path:-$default_path}" + if [ ! -d "$path" ]; then + if whiptail --backtitle "$BACKTITLE" --yesno "Directory '$path' does not exist. Create it?" 9 68; then + mkdir -p "$path" || { + msg_error "Could not create '$path'" + return 1 + } + else + return 1 + fi + fi + BASE_DIR="$path" + fi + return 0 +} + +# Collect VM and CT lists into global arrays. +# GUEST_ROWS entries: "type|id|name|status" +function collect_guests { + GUEST_ROWS=() + if command -v qm >/dev/null 2>&1; then + while read -r id name status _; do + [ -z "$id" ] && continue + GUEST_ROWS+=("vm|${id}|${name}|${status}") + done < <(qm list 2>/dev/null | awk 'NR>1 {print $1, $2, $3}') + fi + if command -v pct >/dev/null 2>&1; then + while read -r id status name _; do + [ -z "$id" ] && continue + GUEST_ROWS+=("ct|${id}|${name}|${status}") + done < <(pct list 2>/dev/null | awk 'NR>1 {print $1, $2, $3}') + fi +} + +# ---------------------------------------------------------------------------- +# EXPORT +# ---------------------------------------------------------------------------- +function do_export { + choose_location "Export Destination" "/mnt/" || return + + local bundle="${BASE_DIR%/}/${BUNDLE_PREFIX}-$(hostname)-$(date +%Y_%m_%dT%H_%M)" + mkdir -p "$bundle/host" "$bundle/guests" || { + msg_error "Could not create bundle directory" + return + } + + # --- Component selection ------------------------------------------------- + local components + components=$(whiptail --backtitle "$BACKTITLE" --title "Export Components" --checklist \ + "\nSelect what to export into the bundle:" 18 78 7 \ + "hostcfg" "Host configs (/etc/pve, network, storage, users, fw)" ON \ + "etc" "Full /etc tarball (extra safety net)" OFF \ + "ssh" "SSH host keys + /root/.ssh" ON \ + "apt" "APT sources + installed package list" ON \ + "guests" "LXC / VM guests" ON \ + 3>&1 1>&2 2>&3) || return + + components="${components//\"/}" + + # --- Host config --------------------------------------------------------- + if [[ "$components" == *hostcfg* ]]; then + msg_info "Collecting host configuration" + mkdir -p "$bundle/host/etc-pve" "$bundle/host/network" + # pmxcfs content is readable as normal files + [ -d /etc/pve ] && cp -a /etc/pve/. "$bundle/host/etc-pve/" 2>/dev/null + [ -f /etc/network/interfaces ] && cp -a /etc/network/interfaces "$bundle/host/network/" + [ -d /etc/network/interfaces.d ] && cp -a /etc/network/interfaces.d "$bundle/host/network/" 2>/dev/null + [ -f /etc/hostname ] && cp -a /etc/hostname "$bundle/host/" + [ -f /etc/hosts ] && cp -a /etc/hosts "$bundle/host/" + [ -f /etc/resolv.conf ] && cp -a /etc/resolv.conf "$bundle/host/" 2>/dev/null + msg_ok "Collected host configuration" + fi + + if [[ "$components" == *etc* ]]; then + msg_info "Creating /etc tarball" + tar -czf "$bundle/host/etc-full.tar.gz" --absolute-names /etc 2>/dev/null + msg_ok "Created /etc tarball" + fi + + if [[ "$components" == *ssh* ]]; then + msg_info "Collecting SSH keys" + mkdir -p "$bundle/host/ssh" + cp -a /etc/ssh "$bundle/host/ssh/etc-ssh" 2>/dev/null + [ -d /root/.ssh ] && cp -a /root/.ssh "$bundle/host/ssh/root-ssh" 2>/dev/null + msg_ok "Collected SSH keys" + fi + + if [[ "$components" == *apt* ]]; then + msg_info "Collecting APT state" + mkdir -p "$bundle/host/apt" + cp -a /etc/apt/sources.list "$bundle/host/apt/" 2>/dev/null + cp -a /etc/apt/sources.list.d "$bundle/host/apt/" 2>/dev/null + dpkg --get-selections >"$bundle/host/apt/packages.selections" 2>/dev/null + msg_ok "Collected APT state" + fi + + # --- Guests -------------------------------------------------------------- + local guest_method="" guest_mode="snapshot" + : >"$bundle/guests.tsv" + if [[ "$components" == *guests* ]]; then + collect_guests + if [ "${#GUEST_ROWS[@]}" -eq 0 ]; then + msg_warn "No guests found on this host" + else + # method selection + guest_method=$(whiptail --backtitle "$BACKTITLE" --title "Guest Export Method" --menu \ + "\nHow should guests be exported?" 14 78 2 \ + "vzdump" "Full portable backup incl. disks (vzdump)" \ + "config" "Configs only (no disk data)" \ + 3>&1 1>&2 2>&3) || guest_method="" + + if [ -n "$guest_method" ]; then + # build checklist + local menu=() row type id name status + for row in "${GUEST_ROWS[@]}"; do + IFS='|' read -r type id name status <<<"$row" + menu+=("${type}:${id}" "${name} (${status})" ON) + done + + local selected + selected=$(whiptail --backtitle "$BACKTITLE" --title "Select Guests" --checklist \ + "\nSelect guests to export:" 20 78 10 "${menu[@]}" 3>&1 1>&2 2>&3) || selected="" + selected="${selected//\"/}" + + if [ "$guest_method" = "vzdump" ]; then + guest_mode=$(whiptail --backtitle "$BACKTITLE" --title "vzdump Mode" --menu \ + "\nBackup mode for running guests:" 14 78 3 \ + "snapshot" "Live snapshot (recommended)" \ + "suspend" "Suspend guest during backup" \ + "stop" "Stop guest during backup (most consistent)" \ + 3>&1 1>&2 2>&3) || guest_mode="snapshot" + fi + + local sel type id name status confs + for sel in $selected; do + type="${sel%%:*}" + id="${sel##*:}" + # resolve name from rows + name="" + for row in "${GUEST_ROWS[@]}"; do + IFS='|' read -r r_type r_id r_name r_status <<<"$row" + if [ "$r_type" = "$type" ] && [ "$r_id" = "$id" ]; then name="$r_name"; fi + done + + if [ "$guest_method" = "vzdump" ]; then + msg_info "vzdump ${type} ${id} (${name})" + if vzdump "$id" --dumpdir "$bundle/guests" --mode "$guest_mode" --compress zstd &>>"$bundle/guests/vzdump.log"; then + local file + file=$(ls -1t "$bundle/guests"/vzdump-*-"${id}"-*.* 2>/dev/null | grep -v '\.log$' | head -n1) + file="$(basename "$file")" + echo -e "${type}\t${id}\t${name}\tvzdump\t${file}" >>"$bundle/guests.tsv" + msg_ok "vzdump ${type} ${id} -> ${file}" + else + msg_error "vzdump failed for ${type} ${id} (see vzdump.log)" + fi + else + # config-only + mkdir -p "$bundle/guests/config" + if [ "$type" = "vm" ]; then + confs="/etc/pve/qemu-server/${id}.conf" + else + confs="/etc/pve/lxc/${id}.conf" + fi + if [ -f "$confs" ]; then + cp -a "$confs" "$bundle/guests/config/${type}-${id}.conf" + echo -e "${type}\t${id}\t${name}\tconfig\t${type}-${id}.conf" >>"$bundle/guests.tsv" + msg_ok "Saved config for ${type} ${id}" + else + msg_error "Config not found: ${confs}" + fi + fi + done + fi + fi + fi + + # --- Manifest ------------------------------------------------------------ + { + echo "EXPORT_HOSTNAME=\"$(hostname)\"" + echo "EXPORT_DATE=\"$(date -Iseconds)\"" + echo "EXPORT_PVE_VERSION=\"$(pveversion | head -n1)\"" + echo "EXPORT_ARCH=\"$(uname -m)\"" + echo "EXPORT_COMPONENTS=\"${components}\"" + echo "EXPORT_GUEST_METHOD=\"${guest_method}\"" + } >"$bundle/manifest.env" + + ip -br link >"$bundle/host/network-links.info" 2>/dev/null + ip -br addr >"$bundle/host/network-addr.info" 2>/dev/null + pvesm status >"$bundle/host/storage.info" 2>/dev/null + + header_info + msg_ok "Export finished" + echo -e "\nBundle: \e[1;33m${bundle}\e[0m\n" + if [ "$NFS_MOUNTED" -eq 1 ]; then + echo -e "Copied to NFS share (will be unmounted on exit).\n" + fi + read -rp "Press ENTER to return to the menu..." +} + +# ---------------------------------------------------------------------------- +# IMPORT +# ---------------------------------------------------------------------------- + +# Let the user pick a bundle directory inside BASE_DIR. +function pick_bundle { + local menu=() d count=0 + while IFS= read -r d; do + [ -f "$d/manifest.env" ] || continue + menu+=("$d" " ") + count=$((count + 1)) + done < <(find "$BASE_DIR" -maxdepth 2 -type d -name "${BUNDLE_PREFIX}-*" 2>/dev/null | sort) + + if [ "$count" -eq 0 ]; then + msg_error "No migration bundles found under ${BASE_DIR}" + sleep 2 + return 1 + fi + + BUNDLE=$(whiptail --backtitle "$BACKTITLE" --title "Select Bundle" --menu \ + "\nFound migration bundles:" 20 100 10 "${menu[@]}" 3>&1 1>&2 2>&3) || return 1 + return 0 +} + +function next_free_id { + if command -v pvesh >/dev/null 2>&1; then + pvesh get /cluster/nextid 2>/dev/null && return + fi + echo "999" +} + +function pick_storage { + local content="$1" menu=() store type + while read -r store type _; do + [ -z "$store" ] && continue + menu+=("$store" "$type") + done < <(pvesm status ${content:+-content "$content"} 2>/dev/null | awk 'NR>1 {print $1, $2}') + if [ "${#menu[@]}" -eq 0 ]; then + echo "" + return + fi + whiptail --backtitle "$BACKTITLE" --title "Target Storage" --menu \ + "\nSelect target storage:" 18 70 8 "${menu[@]}" 3>&1 1>&2 2>&3 +} + +function import_guests { + local bundle="$1" + [ -f "$bundle/guests.tsv" ] || { + msg_warn "No guests in this bundle" + sleep 2 + return + } + [ -s "$bundle/guests.tsv" ] || { + msg_warn "No guests in this bundle" + sleep 2 + return + } + + local menu=() type id name method file + while IFS=$'\t' read -r type id name method file; do + [ -z "$type" ] && continue + menu+=("${type}:${id}" "${name} [${method}]" ON) + done <"$bundle/guests.tsv" + + local selected + selected=$(whiptail --backtitle "$BACKTITLE" --title "Restore Guests" --checklist \ + "\nSelect guests to restore:" 20 78 10 "${menu[@]}" 3>&1 1>&2 2>&3) || return + selected="${selected//\"/}" + [ -z "$selected" ] && return + + local default_storage + default_storage=$(pick_storage "") || default_storage="" + + local sel target_type target_id + for sel in $selected; do + target_type="${sel%%:*}" + local src_id="${sel##*:}" + # find matching line + while IFS=$'\t' read -r type id name method file; do + [ "$type" = "$target_type" ] && [ "$id" = "$src_id" ] || continue + + target_id="$src_id" + # conflict check + local exists=0 + if [ "$type" = "vm" ]; then + qm config "$target_id" &>/dev/null && exists=1 + else + pct config "$target_id" &>/dev/null && exists=1 + fi + if [ "$exists" -eq 1 ]; then + local suggested + suggested=$(next_free_id) + target_id=$(whiptail --backtitle "$BACKTITLE" --inputbox \ + "\nID ${src_id} already exists on this host.\nEnter a new ID for ${name}:" 11 68 \ + "$suggested" --title "ID Conflict" 3>&1 1>&2 2>&3) || continue + fi + + if [ "$method" = "vzdump" ]; then + local archive="$bundle/guests/$file" + if [ ! -f "$archive" ]; then + msg_error "Archive missing: $file" + continue + fi + msg_info "Restoring ${type} ${src_id} -> ${target_id}" + if [ "$type" = "vm" ]; then + if qmrestore "$archive" "$target_id" ${default_storage:+--storage "$default_storage"} &>/tmp/host-migrate-restore.log; then + msg_ok "Restored VM ${target_id}" + else + msg_error "qmrestore failed for ${src_id} (see /tmp/host-migrate-restore.log)" + fi + else + if pct restore "$target_id" "$archive" ${default_storage:+--storage "$default_storage"} &>/tmp/host-migrate-restore.log; then + msg_ok "Restored CT ${target_id}" + else + msg_error "pct restore failed for ${src_id} (see /tmp/host-migrate-restore.log)" + fi + fi + else + # config-only + local conf="$bundle/guests/config/$file" + if [ ! -f "$conf" ]; then + msg_error "Config missing: $file" + continue + fi + local dest + if [ "$type" = "vm" ]; then + dest="/etc/pve/qemu-server/${target_id}.conf" + else + dest="/etc/pve/lxc/${target_id}.conf" + fi + if [ -f "$dest" ]; then + msg_error "Target config already exists: $dest" + continue + fi + cp "$conf" "$dest" + msg_ok "Restored config ${type} ${target_id} (disks must exist separately!)" + fi + done <"$bundle/guests.tsv" + done + read -rp "Press ENTER to continue..." +} + +function import_hostcfg { + local bundle="$1" + [ -d "$bundle/host" ] || { + msg_warn "No host configs in this bundle" + sleep 2 + return + } + + local sel + sel=$(whiptail --backtitle "$BACKTITLE" --title "Restore Host Configuration" --checklist \ + "\nSelect host components to restore.\nNETWORK and HOSTNAME are DANGEROUS - read the warnings!" 20 82 8 \ + "storage" "storage.cfg (storage definitions)" OFF \ + "users" "user.cfg / firewall (PVE users + ACLs)" OFF \ + "ssh" "SSH host keys + /root/.ssh" OFF \ + "apt" "APT sources + package selections" OFF \ + "hosts" "/etc/hosts" OFF \ + "network" "/etc/network/interfaces (!! DANGER !!)" OFF \ + "hostname" "hostname (!! DANGER !!)" OFF \ + 3>&1 1>&2 2>&3) || return + sel="${sel//\"/}" + [ -z "$sel" ] && return + + if [[ "$sel" == *storage* ]]; then + if [ -f "$bundle/host/etc-pve/storage.cfg" ]; then + cp /etc/pve/storage.cfg /etc/pve/storage.cfg.bak.$(date +%s) 2>/dev/null + cp "$bundle/host/etc-pve/storage.cfg" /etc/pve/storage.cfg + msg_ok "Restored storage.cfg (review with: pvesm status)" + else + msg_error "storage.cfg not in bundle" + fi + fi + + if [[ "$sel" == *users* ]]; then + [ -f "$bundle/host/etc-pve/user.cfg" ] && cp "$bundle/host/etc-pve/user.cfg" /etc/pve/user.cfg && msg_ok "Restored user.cfg" + [ -d "$bundle/host/etc-pve/firewall" ] && cp -a "$bundle/host/etc-pve/firewall/." /etc/pve/firewall/ 2>/dev/null && msg_ok "Restored firewall rules" + fi + + if [[ "$sel" == *ssh* ]]; then + [ -d "$bundle/host/ssh/etc-ssh" ] && cp -a "$bundle/host/ssh/etc-ssh/." /etc/ssh/ 2>/dev/null && msg_ok "Restored /etc/ssh" + [ -d "$bundle/host/ssh/root-ssh" ] && cp -a "$bundle/host/ssh/root-ssh/." /root/.ssh/ 2>/dev/null && chmod 700 /root/.ssh && msg_ok "Restored /root/.ssh" + fi + + if [[ "$sel" == *apt* ]]; then + [ -f "$bundle/host/apt/sources.list" ] && cp "$bundle/host/apt/sources.list" /etc/apt/sources.list && msg_ok "Restored sources.list" + [ -d "$bundle/host/apt/sources.list.d" ] && cp -a "$bundle/host/apt/sources.list.d/." /etc/apt/sources.list.d/ 2>/dev/null && msg_ok "Restored sources.list.d" + msg_warn "Package selections saved as reference: apt/packages.selections (not auto-installed)" + fi + + if [[ "$sel" == *hosts* ]]; then + [ -f "$bundle/host/hosts" ] && cp /etc/hosts /etc/hosts.bak.$(date +%s) && cp "$bundle/host/hosts" /etc/hosts && msg_ok "Restored /etc/hosts" + fi + + if [[ "$sel" == *network* ]]; then + import_network "$bundle" + fi + + if [[ "$sel" == *hostname* ]]; then + import_hostname "$bundle" + fi + + read -rp "Press ENTER to continue..." +} + +# Dangerous: applying a foreign network config can disconnect the host. +function import_network { + local bundle="$1" + local src="$bundle/host/network/interfaces" + [ -f "$src" ] || { + msg_error "No interfaces file in bundle" + return + } + + local target_nics source_nics + target_nics=$(ip -br link 2>/dev/null | awk '{print $1}' | grep -vE '^(lo|vmbr|tap|veth|fwbr|fwln|fwpr|bond)' | tr '\n' ' ') + source_nics=$(grep -oE 'en[a-z0-9]+|eth[0-9]+' "$src" 2>/dev/null | sort -u | tr '\n' ' ') + + whiptail --backtitle "$BACKTITLE" --title "!! NETWORK WARNING !!" --scrolltext --msgbox \ + "Applying the source network config can leave THIS host without network access if NIC names differ.\n\nSource NICs referenced:\n ${source_nics:-none detected}\n\nNICs on THIS host:\n ${target_nics:-none detected}\n\nThe current /etc/network/interfaces will be backed up.\nChanges require a reboot (no auto ifreload)." 22 82 + + local mode + mode=$(whiptail --backtitle "$BACKTITLE" --title "Network Import Mode" --menu \ + "\nHow do you want to handle the network config?" 15 80 2 \ + "template" "Save as /root/migrate-interfaces.template (safe, recommended)" \ + "apply" "Overwrite /etc/network/interfaces (DANGEROUS)" \ + 3>&1 1>&2 2>&3) || return + + if [ "$mode" = "template" ]; then + cp "$src" /root/migrate-interfaces.template + msg_ok "Saved to /root/migrate-interfaces.template (apply manually after review)" + return + fi + + local confirm + confirm=$(whiptail --backtitle "$BACKTITLE" --inputbox \ + "\nType exactly APPLY-NETWORK to overwrite /etc/network/interfaces:" 11 70 \ + --title "Final Confirmation" 3>&1 1>&2 2>&3) || return + if [ "$confirm" != "APPLY-NETWORK" ]; then + msg_warn "Confirmation mismatch - network NOT changed" + return + fi + cp /etc/network/interfaces "/etc/network/interfaces.bak.$(date +%s)" + cp "$src" /etc/network/interfaces + msg_ok "Overwrote /etc/network/interfaces (backup created)" + msg_warn "Verify NIC names, then reboot. You may lose connectivity!" +} + +# Dangerous: hostname change affects /etc/pve/nodes/ layout. +function import_hostname { + local bundle="$1" + local src="$bundle/host/hostname" + [ -f "$src" ] || { + msg_error "No hostname file in bundle" + return + } + local new_name + new_name=$(tr -d '[:space:]' <"$src") + + whiptail --backtitle "$BACKTITLE" --title "!! HOSTNAME WARNING !!" --scrolltext --msgbox \ + "Changing the hostname to '${new_name}' affects:\n - /etc/pve/nodes// (node-specific guest configs)\n - storage ownership and certificates\n\nRecommended only on a FRESH target before restoring guests.\nA reboot is required afterwards." 18 80 + + if ! whiptail --backtitle "$BACKTITLE" --yesno "Set hostname to '${new_name}' now?" 9 70; then + return + fi + cp /etc/hostname "/etc/hostname.bak.$(date +%s)" + echo "$new_name" >/etc/hostname + if command -v hostnamectl >/dev/null 2>&1; then + hostnamectl set-hostname "$new_name" 2>/dev/null + fi + msg_ok "Hostname set to ${new_name} (reboot required)" + msg_warn "Node-specific configs under /etc/pve/nodes may need manual migration" +} + +function do_import { + choose_location "Import Source" "/mnt/" || return + pick_bundle || return + + # Show manifest summary + # shellcheck disable=SC1090 + source "$BUNDLE/manifest.env" 2>/dev/null + whiptail --backtitle "$BACKTITLE" --title "Bundle Information" --scrolltext --msgbox \ + "Origin host : ${EXPORT_HOSTNAME:-?}\nExported : ${EXPORT_DATE:-?}\nPVE version : ${EXPORT_PVE_VERSION:-?}\nArch : ${EXPORT_ARCH:-?}\nComponents : ${EXPORT_COMPONENTS:-?}\nGuest method: ${EXPORT_GUEST_METHOD:-?}\n\nThis (target) host: $(hostname) / $(pveversion | head -n1)" 18 82 + + # Preflight: storage comparison + if [ -f "$BUNDLE/host/storage.info" ]; then + local src_stores cur_stores missing="" + src_stores=$(awk 'NR>1 {print $1}' "$BUNDLE/host/storage.info" 2>/dev/null) + cur_stores=$(pvesm status 2>/dev/null | awk 'NR>1 {print $1}') + local s + for s in $src_stores; do + grep -qx "$s" <<<"$cur_stores" || missing+="$s " + done + if [ -n "$missing" ]; then + whiptail --backtitle "$BACKTITLE" --title "Storage Preflight" --msgbox \ + "These storages from the source are MISSING on this host:\n\n ${missing}\n\nRestores using them may fail. Create them first or pick a different target storage during restore." 14 78 + fi + fi + + while true; do + local choice + choice=$(whiptail --backtitle "$BACKTITLE" --title "Import Menu" --menu \ + "\nBundle: $(basename "$BUNDLE")" 15 78 4 \ + "guests" "Restore LXC / VM guests" \ + "hostcfg" "Restore host configuration (selective)" \ + "back" "Back to main menu" \ + 3>&1 1>&2 2>&3) || break + case "$choice" in + guests) import_guests "$BUNDLE" ;; + hostcfg) import_hostcfg "$BUNDLE" ;; + back) break ;; + esac + done +} + +# ---------------------------------------------------------------------------- +# Main menu +# ---------------------------------------------------------------------------- +while true; do + header_info + ACTION=$(whiptail --backtitle "$BACKTITLE" --title "Proxmox VE Host Migrate" --menu \ + "\nExport this host or import a bundle onto a new host." 15 78 3 \ + "export" "Export host + guests to a bundle (mount/SSD/NFS)" \ + "import" "Import a bundle onto THIS host" \ + "quit" "Exit" \ + 3>&1 1>&2 2>&3) || break + + case "$ACTION" in + export) do_export ;; + import) do_import ;; + quit) break ;; + esac +done + +cleanup +clear