New version 1.2.5

Stable release consolidating the v1.2.4 beta cycle (1.2.4.1-beta and 1.2.4.2-beta) into 1.2.5.

Highlights:

- Apps dashboard: single launcher for every LXC-registered app and user-defined Custom Web Link, with category badges, search, sort and one-click deep-links back to the guest modal.
- LXC Apps & Updates end-to-end: App tab inside every guest modal, upstream version tracking, and Easy Updates that cover OS packages, registered apps, Docker Engine and per-image updates on the same 24-hour cycle.
- Application detection catalog with 380+ tracked workloads generated live from community-scripts across seven detector methods.
- Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Slovak and Swedish (i18n scaffolding by @vaso73).
- NVIDIA multi-GPU passthrough by exact BDF so one card can be assigned to a VM while another stays operational on the host or LXC.
- Navigation reorder, Memory & Swap real memory-pressure signal, native Pushover channel, Actions API, plus wide-reaching improvements across health, hardware, network, backup and post-install.

Full release notes: see CHANGELOG.md and https://github.com/MacRimi/ProxMenux/releases
This commit is contained in:
MacRimi
2026-09-01 19:15:42 +02:00
parent f8e65cc4c4
commit 315259f5ec
54 changed files with 4343 additions and 333 deletions
@@ -3,6 +3,7 @@
# ProxMenux - Apply Pending Restore On Boot
# ==========================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PENDING_BASE="${PMX_RESTORE_PENDING_BASE:-/var/lib/proxmenux/restore-pending}"
CURRENT_LINK="${PENDING_BASE}/current"
LOG_DIR="${PMX_RESTORE_LOG_DIR:-/var/log/proxmenux}"
@@ -82,6 +83,7 @@ cluster_recovery_root=""
applied=0
skipped=0
failed=0
jobs_restored=0
while IFS= read -r rel; do
[[ -z "$rel" ]] && continue
@@ -168,12 +170,14 @@ while IFS= read -r rel; do
if [[ ${#RSYNC_EXCLUDES[@]} -gt 0 ]]; then
if rsync -aAXH "${RSYNC_EXCLUDES[@]}" "$src/" "$dst/" >/dev/null 2>&1; then
((applied++))
[[ "$rel" == "var/lib/proxmenux/backup-jobs" || "$rel" == "var/lib/proxmenux/backup-jobs/"* ]] && jobs_restored=1
else
((failed++))
fi
else
if rsync -aAXH --delete "$src/" "$dst/" >/dev/null 2>&1; then
((applied++))
[[ "$rel" == "var/lib/proxmenux/backup-jobs" || "$rel" == "var/lib/proxmenux/backup-jobs/"* ]] && jobs_restored=1
else
((failed++))
fi
@@ -182,13 +186,36 @@ while IFS= read -r rel; do
mkdir -p "$(dirname "$dst")" >/dev/null 2>&1 || true
if cp -a "$src" "$dst" >/dev/null 2>&1; then
((applied++))
[[ "$rel" == "var/lib/proxmenux/backup-jobs/"* ]] && jobs_restored=1
else
((failed++))
fi
fi
done <"$APPLY_LIST"
systemctl daemon-reload >/dev/null 2>&1 || true
if (( jobs_restored )); then
scheduler_script="$SCRIPT_DIR/backup_scheduler.sh"
[[ -f "$scheduler_script" ]] || scheduler_script="/usr/local/share/proxmenux/scripts/backup_restore/backup_scheduler.sh"
jobs_dir="${DEST_PREFIX%/}/var/lib/proxmenux/backup-jobs"
systemd_dir="${DEST_PREFIX%/}/etc/systemd/system"
if [[ -f "$scheduler_script" ]]; then
if [[ "$DEST_PREFIX" == "/" ]]; then
bash "$scheduler_script" --reconcile-restored || ((failed++))
else
PMX_BACKUP_JOBS_DIR="$jobs_dir" \
PMX_BACKUP_SYSTEMD_DIR="$systemd_dir" \
PMX_BACKUP_NO_SYSTEMCTL=1 \
bash "$scheduler_script" --reconcile-restored || ((failed++))
fi
else
echo "Backup scheduler script not found; restored jobs were not reconciled."
((failed++))
fi
fi
if [[ "$DEST_PREFIX" == "/" ]]; then
systemctl daemon-reload >/dev/null 2>&1 || true
fi
# `update-initramfs -u -k all` and `update-grub` used to live here
# but: (a) they take 5-10 minutes for 3 kernels, hanging early-boot
@@ -248,7 +275,7 @@ EOF
# without restarting anything. That second unit is gated by
# ConditionPathExists on the marker file we drop here, so on
# a normal boot (no marker) it's a no-op.
if [[ "${cluster_live_apply:-0}" == "1" ]]; then
if [[ "${cluster_live_apply:-0}" == "1" && "$DEST_PREFIX" == "/" ]]; then
echo "Installing post-boot cluster apply unit..."
# Decide whether the post-boot script needs to run
@@ -353,7 +380,9 @@ restore_id="$(basename "$PENDING_DIR")"
mv "$PENDING_DIR" "${PENDING_BASE}/completed/${restore_id}" >/dev/null 2>&1 || true
rm -f "$CURRENT_LINK" >/dev/null 2>&1 || true
systemctl disable proxmenux-restore-onboot.service >/dev/null 2>&1 || true
if [[ "$DEST_PREFIX" == "/" ]]; then
systemctl disable proxmenux-restore-onboot.service >/dev/null 2>&1 || true
fi
echo "=== ProxMenux pending restore finished at $(date -Iseconds) ==="
echo "Log file: $LOG_FILE"
+43 -6
View File
@@ -98,7 +98,10 @@ _bk_pbs() {
echo -e ""
msg_info "$(translate "Preparing files for backup...")"
hb_prepare_staging "$staging_root" "${paths[@]}"
if ! hb_prepare_staging "$staging_root" "${paths[@]}" >"$log_file" 2>&1; then
msg_error "$(translate "Backup failed. See log:") $log_file"
return 1
fi
staged_size=$(hb_file_size "$staging_root/rootfs")
msg_ok "$(translate "Staging ready.") $(translate "Data size:") $staged_size"
@@ -242,7 +245,10 @@ _bk_borg() {
echo -e ""
msg_info "$(translate "Preparing files for backup...")"
hb_prepare_staging "$staging_root" "${paths[@]}"
if ! hb_prepare_staging "$staging_root" "${paths[@]}" >"$log_file" 2>&1; then
msg_error "$(translate "Backup failed. See log:") $log_file"
return 1
fi
staged_size=$(hb_file_size "$staging_root/rootfs")
msg_ok "$(translate "Staging ready.") $(translate "Data size:") $staged_size"
@@ -376,7 +382,10 @@ _bk_local() {
echo -e ""
msg_info "$(translate "Preparing files for backup...")"
hb_prepare_staging "$staging_root" "${paths[@]}"
if ! hb_prepare_staging "$staging_root" "${paths[@]}" >"$log_file" 2>&1; then
msg_error "$(translate "Backup failed. See log:") $log_file"
return 1
fi
staged_size=$(hb_file_size "$staging_root/rootfs")
msg_ok "$(translate "Staging ready.") $(translate "Data size:") $staged_size"
@@ -1681,7 +1690,7 @@ _rs_apply() {
backup_root="/var/lib/proxmenux/pre-restore/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$backup_root"
local applied=0 skipped=0 t_start elapsed
local applied=0 skipped=0 t_start elapsed jobs_restored=0 jobs_reconcile_failed=0
local cluster_recovery_root="" CLUSTER_DATA_EXTRACTED=""
t_start=$SECONDS
@@ -1774,13 +1783,31 @@ _rs_apply() {
--exclude "restore-pending/"
)
fi
rsync -aAXH --delete "${rsync_extra[@]}" "$src/" "$dst/" 2>/dev/null && ((applied++)) || ((skipped++))
if rsync -aAXH --delete "${rsync_extra[@]}" "$src/" "$dst/" 2>/dev/null; then
((applied++))
[[ "$rel" == "var/lib/proxmenux/backup-jobs" || "$rel" == "var/lib/proxmenux/backup-jobs/"* ]] && jobs_restored=1
else
((skipped++))
fi
else
mkdir -p "$(dirname "$dst")"
cp -a "$src" "$dst" 2>/dev/null && ((applied++)) || ((skipped++))
if cp -a "$src" "$dst" 2>/dev/null; then
((applied++))
[[ "$rel" == "var/lib/proxmenux/backup-jobs/"* ]] && jobs_restored=1
else
((skipped++))
fi
fi
done
if (( jobs_restored )); then
local scheduler_script="$SCRIPT_DIR/backup_scheduler.sh"
[[ -f "$scheduler_script" ]] || scheduler_script="$LOCAL_SCRIPTS_DEFAULT/backup_restore/backup_scheduler.sh"
if [[ ! -f "$scheduler_script" ]] || ! bash "$scheduler_script" --reconcile-restored; then
jobs_reconcile_failed=1
fi
fi
elapsed=$((SECONDS - t_start))
# Skip `systemctl daemon-reload` when invoked from the Monitor
# (HB_MONITOR_FLOW=1). The reload itself doesn't restart the
@@ -1808,6 +1835,14 @@ _rs_apply() {
msg_warn "$(translate "Changes applied. A system reboot is recommended for them to take full effect.")"
fi
if (( jobs_restored )); then
if (( jobs_reconcile_failed )); then
msg_warn "$(translate "Scheduled backup jobs")"
else
msg_ok "$(translate "Scheduled backup jobs")"
fi
fi
if [[ -n "$CLUSTER_DATA_EXTRACTED" ]]; then
export HB_CLUSTER_DATA_EXTRACTED="$CLUSTER_DATA_EXTRACTED"
_rs_write_cluster_recovery_helper "$CLUSTER_DATA_EXTRACTED"
@@ -1816,6 +1851,8 @@ _rs_apply() {
else
unset HB_CLUSTER_DATA_EXTRACTED
fi
(( jobs_reconcile_failed == 0 ))
}
_rs_collect_plan_stats() {
+200 -12
View File
@@ -35,17 +35,20 @@ else
exit 1
fi
load_language
initialize_cache
JOBS_DIR="/var/lib/proxmenux/backup-jobs"
LOG_DIR="/var/log/proxmenux/backup-jobs"
JOBS_DIR="${PMX_BACKUP_JOBS_DIR:-/var/lib/proxmenux/backup-jobs}"
LOG_DIR="${PMX_BACKUP_LOG_DIR:-/var/log/proxmenux/backup-jobs}"
SYSTEMD_DIR="${PMX_BACKUP_SYSTEMD_DIR:-/etc/systemd/system}"
mkdir -p "$JOBS_DIR" "$LOG_DIR" >/dev/null 2>&1 || true
_job_file() { echo "${JOBS_DIR}/$1.env"; }
_job_paths_file() { echo "${JOBS_DIR}/$1.paths"; }
_service_file() { echo "/etc/systemd/system/proxmenux-backup-$1.service"; }
_timer_file() { echo "/etc/systemd/system/proxmenux-backup-$1.timer"; }
_service_file() { echo "${SYSTEMD_DIR}/proxmenux-backup-$1.service"; }
_timer_file() { echo "${SYSTEMD_DIR}/proxmenux-backup-$1.timer"; }
_scheduler_systemctl() {
[[ "${PMX_BACKUP_NO_SYSTEMCTL:-0}" == "1" ]] && return 0
systemctl "$@"
}
_normalize_uint() {
local v="${1:-0}"
@@ -95,10 +98,11 @@ _list_scheduled_jobs() {
# timer — the trigger comes from the vzdump hook, matched by PVE_STORAGE
# against $STOREID set by PVE for every backup phase).
_job_is_attached() {
local id="$1" f
local id="$1" f storage
f=$(_job_file "$id")
[[ -f "$f" ]] || return 1
grep -q "^PVE_STORAGE=" "$f"
storage=$(_job_env_get "$id" "PVE_STORAGE" || echo "")
[[ -n "$storage" ]]
}
# Reads a key=val pair from the job .env file (handles `printf %q`
@@ -111,6 +115,21 @@ _job_env_get() {
eval "echo $raw" 2>/dev/null || echo "$raw"
}
_set_job_enabled() {
local id="$1" value="$2" file tmp
file=$(_job_file "$id")
[[ -f "$file" ]] || return 1
tmp="${file}.tmp.$$"
awk -v value="$value" '
BEGIN { found=0 }
/^ENABLED=/ { print "ENABLED=" value; found=1; next }
{ print }
END { if (!found) print "ENABLED=" value }
' "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
chmod --reference="$file" "$tmp" 2>/dev/null || chmod 600 "$tmp"
mv -f "$tmp" "$file"
}
_show_job_status() {
local id="$1"
if _job_is_attached "$id"; then
@@ -139,6 +158,7 @@ _write_job_units() {
local on_calendar="$2"
local runner="$LOCAL_SCRIPTS/backup_restore/run_scheduled_backup.sh"
[[ ! -f "$runner" ]] && runner="$SCRIPT_DIR/run_scheduled_backup.sh"
mkdir -p "$SYSTEMD_DIR" || return 1
cat > "$(_service_file "$id")" <<EOF
[Unit]
@@ -168,7 +188,166 @@ Unit=proxmenux-backup-${id}.service
WantedBy=timers.target
EOF
systemctl daemon-reload >/dev/null 2>&1 || true
_scheduler_systemctl daemon-reload >/dev/null 2>&1 || true
}
_sanitize_restored_job() {
local env_file="$1" expected_id="$2" paths_file="$3"
command -v python3 >/dev/null 2>&1 || {
echo "Cannot validate restored backup job ${expected_id}: python3 is unavailable." >&2
return 1
}
python3 - "$env_file" "$expected_id" "$paths_file" <<'PY'
import os
import re
import shlex
import stat
import sys
import tempfile
env_path, expected_id, paths_path = sys.argv[1:]
allowed = {
'JOB_ID', 'BACKEND', 'PVE_PARENT_JOB', 'PVE_STORAGE', 'ON_CALENDAR',
'PROFILE_MODE', 'ENABLED', 'KEEP_LAST', 'KEEP_HOURLY', 'KEEP_DAILY',
'KEEP_WEEKLY', 'KEEP_MONTHLY', 'KEEP_YEARLY', 'LOCAL_DEST_DIR',
'LOCAL_ARCHIVE_EXT', 'BORG_REPO', 'BORG_PASSPHRASE',
'BORG_ENCRYPT_MODE', 'PBS_REPOSITORY', 'PBS_PASSWORD', 'PBS_BACKUP_ID',
'PBS_KEYFILE', 'PBS_ENCRYPTION_PASSWORD', 'PBS_FINGERPRINT', 'MANUAL_RUN',
}
key_re = re.compile(r'^[A-Z][A-Z0-9_]*$')
id_re = re.compile(r'^[A-Za-z0-9_-]+$')
values = {}
order = []
try:
env_stat = os.lstat(env_path)
paths_stat = os.lstat(paths_path)
if not stat.S_ISREG(env_stat.st_mode) or not stat.S_ISREG(paths_stat.st_mode):
raise ValueError('job definition and paths list must be regular files')
with open(env_path, encoding='utf-8') as handle:
for line_number, raw in enumerate(handle, 1):
line = raw.strip()
if not line or line.startswith('#'):
continue
if '=' not in line:
raise ValueError(f'line {line_number} is not KEY=value')
key, rhs = line.split('=', 1)
if not key_re.fullmatch(key) or key not in allowed:
raise ValueError(f'line {line_number} contains unsupported key {key!r}')
parsed = shlex.split(rhs, posix=True)
if len(parsed) != 1:
raise ValueError(f'line {line_number} does not contain one safely quoted value')
if key not in values:
order.append(key)
values[key] = parsed[0]
for required in ('JOB_ID', 'BACKEND', 'PROFILE_MODE', 'ENABLED'):
if required not in values:
raise ValueError(f'missing required key {required}')
if not id_re.fullmatch(expected_id) or values['JOB_ID'] != expected_id:
raise ValueError('JOB_ID does not match the file name')
if values['BACKEND'] not in {'local', 'borg', 'pbs'}:
raise ValueError('unsupported BACKEND')
if values['PROFILE_MODE'] not in {'default', 'custom'}:
raise ValueError('unsupported PROFILE_MODE')
if values['ENABLED'] not in {'0', '1'}:
raise ValueError('ENABLED must be 0 or 1')
if values.get('MANUAL_RUN', '0') not in {'0', '1'}:
raise ValueError('MANUAL_RUN must be 0 or 1')
if not values.get('PVE_STORAGE') and values.get('MANUAL_RUN') != '1' and not values.get('ON_CALENDAR'):
raise ValueError('standalone job has no ON_CALENDAR value')
if any(char in values.get('ON_CALENDAR', '') for char in ('\r', '\n', '\x00')):
raise ValueError('ON_CALENDAR contains invalid control characters')
with open(paths_path, encoding='utf-8') as handle:
paths = [line.strip() for line in handle if line.strip()]
if not paths or any(not path.startswith('/') or '\x00' in path for path in paths):
raise ValueError('paths file must contain at least one absolute path')
fd, temp_path = tempfile.mkstemp(prefix='.restore-', dir=os.path.dirname(env_path), text=True)
try:
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
handle.write('# ProxMenux scheduled backup job\n')
for key in order:
handle.write(f'{key}={shlex.quote(values[key])}\n')
os.chmod(temp_path, 0o600)
os.replace(temp_path, env_path)
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
except (OSError, ValueError) as exc:
print(f'{expected_id}: {exc}', file=sys.stderr)
raise SystemExit(1)
PY
}
_reconcile_restored_jobs() {
local total=0 restored=0 invalid=0 attached=0 disabled=0 hook_needed=0
local env_file id paths_file manual enabled on_calendar
mkdir -p "$JOBS_DIR" "$SYSTEMD_DIR" || return 1
for env_file in "$JOBS_DIR"/*.env; do
[[ -f "$env_file" ]] || continue
total=$((total + 1))
id="$(basename "$env_file" .env)"
paths_file="$(_job_paths_file "$id")"
if [[ ! -f "$paths_file" ]] || ! _sanitize_restored_job "$env_file" "$id" "$paths_file"; then
invalid=$((invalid + 1))
continue
fi
manual=$(_job_env_get "$id" MANUAL_RUN || echo 0)
if [[ "$manual" == "1" ]]; then
rm -f "$(_service_file "$id")" "$(_timer_file "$id")"
continue
fi
enabled=$(_job_env_get "$id" ENABLED || echo 0)
if _job_is_attached "$id"; then
rm -f "$(_service_file "$id")" "$(_timer_file "$id")"
attached=$((attached + 1))
hook_needed=1
[[ "$enabled" == "1" ]] || disabled=$((disabled + 1))
restored=$((restored + 1))
continue
fi
on_calendar=$(_job_env_get "$id" ON_CALENDAR || echo "")
if [[ "${PMX_BACKUP_NO_SYSTEMCTL:-0}" != "1" ]] && command -v systemd-analyze >/dev/null 2>&1; then
if ! systemd-analyze calendar "$on_calendar" >/dev/null 2>&1; then
echo "${id}: invalid systemd OnCalendar expression." >&2
invalid=$((invalid + 1))
continue
fi
fi
if ! _write_job_units "$id" "$on_calendar"; then
echo "${id}: could not reconstruct systemd units." >&2
invalid=$((invalid + 1))
continue
fi
if [[ "$enabled" == "1" ]]; then
_scheduler_systemctl enable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || {
invalid=$((invalid + 1))
continue
}
else
_scheduler_systemctl disable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
disabled=$((disabled + 1))
fi
restored=$((restored + 1))
done
_scheduler_systemctl daemon-reload >/dev/null 2>&1 || true
if (( hook_needed )) && [[ "${PMX_BACKUP_NO_SYSTEMCTL:-0}" != "1" ]]; then
hb_install_vzdump_hook >/dev/null 2>&1 || invalid=$((invalid + 1))
fi
printf 'Backup jobs reconciled: total=%d restored=%d attached=%d disabled=%d invalid=%d\n' \
"$total" "$restored" "$attached" "$disabled" "$invalid"
(( invalid == 0 ))
}
_prompt_retention() {
@@ -620,18 +799,20 @@ _job_toggle() {
f=$(_job_file "$id")
current=$(_job_env_get "$id" "ENABLED")
if [[ "$current" == "0" ]]; then
sed -i 's/^ENABLED=.*/ENABLED=1/' "$f"
_set_job_enabled "$id" 1
action_label="enabled"
else
sed -i 's/^ENABLED=.*/ENABLED=0/' "$f"
_set_job_enabled "$id" 0
action_label="disabled"
fi
else
if systemctl is-enabled --quiet "proxmenux-backup-${id}.timer" >/dev/null 2>&1; then
systemctl disable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
_set_job_enabled "$id" 0
action_label="disabled"
else
systemctl enable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
_set_job_enabled "$id" 1
action_label="enabled"
fi
fi
@@ -786,4 +967,11 @@ main_menu() {
done
}
if [[ "${1:-}" == "--reconcile-restored" ]]; then
_reconcile_restored_jobs
exit $?
fi
load_language
initialize_cache
main_menu
+139 -17
View File
@@ -16,6 +16,7 @@
}
HB_STATE_DIR="/usr/local/share/proxmenux"
HB_BACKUP_JOBS_DIR="${PMX_BACKUP_JOBS_DIR:-/var/lib/proxmenux/backup-jobs}"
HB_BORG_VERSION="1.2.8"
HB_BORG_LINUX64_SHA256="cfa50fb704a93d3a4fa258120966345fddb394f960dca7c47fcb774d0172f40b"
HB_BORG_LINUX64_URL="https://github.com/borgbackup/borg/releases/download/${HB_BORG_VERSION}/borg-linux64"
@@ -130,6 +131,7 @@ hb_default_profile_paths() {
"/usr/local/bin"
"/usr/local/sbin"
"/usr/local/share/proxmenux"
"$HB_BACKUP_JOBS_DIR"
# ── Root home (rsync excludes volatile dirs) ─────────
"/root"
@@ -411,6 +413,31 @@ hb_del_extra_path() {
chmod 600 "$f"
}
# Returns 0 when a path is operator-added and therefore must be copied
# verbatim. Persisted extra paths always win, even when their value is also
# present in the built-in profile (for example /root). A path supplied by an
# API or a restored custom job is also operator-added when it is not one of
# the exact built-in profile entries.
hb_path_is_operator_added() {
local candidate="${1%/}"
[[ -z "$candidate" ]] && candidate="/"
local configured normalized
while IFS= read -r configured; do
normalized="${configured%/}"
[[ -z "$normalized" ]] && normalized="/"
[[ "$candidate" == "$normalized" ]] && return 0
done < <(hb_load_extra_paths)
while IFS= read -r configured; do
normalized="${configured%/}"
[[ -z "$normalized" ]] && normalized="/"
[[ "$candidate" == "$normalized" ]] && return 1
done < <(hb_default_profile_paths)
return 0
}
hb_select_profile_paths() {
local mode="$1"
local __out_var="$2"
@@ -513,17 +540,67 @@ hb_select_profile_paths() {
# ==========================================================
# STAGING OPERATIONS
# ==========================================================
hb_snapshot_backup_job_states() {
local staging_root="$1"
local jobs_root="$staging_root/rootfs/${HB_BACKUP_JOBS_DIR#/}"
[[ -d "$jobs_root" ]] || return 0
[[ "${PMX_BACKUP_NO_SYSTEMCTL:-0}" == "1" ]] && return 0
local env_file job_id enabled tmp_file
for env_file in "$jobs_root"/*.env; do
[[ -f "$env_file" ]] || continue
grep -q '^MANUAL_RUN=1$' "$env_file" 2>/dev/null && continue
grep -q '^PVE_STORAGE=' "$env_file" 2>/dev/null && continue
job_id="$(basename "$env_file" .env)"
[[ "$job_id" =~ ^[A-Za-z0-9_-]+$ ]] || continue
enabled=0
systemctl is-enabled --quiet "proxmenux-backup-${job_id}.timer" >/dev/null 2>&1 && enabled=1
tmp_file="${env_file}.tmp.$$"
awk -v value="$enabled" '
BEGIN { found=0 }
/^ENABLED=/ { print "ENABLED=" value; found=1; next }
{ print }
END { if (!found) print "ENABLED=" value }
' "$env_file" > "$tmp_file" || { rm -f "$tmp_file"; return 1; }
chmod --reference="$env_file" "$tmp_file" 2>/dev/null || chmod 600 "$tmp_file"
mv -f "$tmp_file" "$env_file" || return 1
done
}
hb_prepare_staging() {
local staging_root="$1"; shift
local paths=("$@")
local -a built_in_paths=() operator_paths=()
mapfile -t built_in_paths < <(hb_default_profile_paths)
mapfile -t operator_paths < <(hb_load_extra_paths)
# Job definitions are control-plane state. Include them even in a
# custom payload so the restored host can reconstruct its schedules.
if [[ -e "$HB_BACKUP_JOBS_DIR" ]]; then
local jobs_covered=0 candidate normalized
for candidate in "${paths[@]}"; do
normalized="${candidate%/}"
[[ -z "$normalized" ]] && normalized="/"
if [[ "$HB_BACKUP_JOBS_DIR" == "$normalized" || "$HB_BACKUP_JOBS_DIR" == "$normalized"/* ]]; then
jobs_covered=1
break
fi
done
(( jobs_covered )) || paths+=("$HB_BACKUP_JOBS_DIR")
fi
rm -rf "$staging_root"
mkdir -p "$staging_root/rootfs" "$staging_root/metadata"
local selected_file="$staging_root/metadata/selected_paths.txt"
local missing_file="$staging_root/metadata/missing_paths.txt"
local failed_file="$staging_root/metadata/failed_paths.txt"
local copy_failed=0
: > "$selected_file"
: > "$missing_file"
: > "$failed_file"
# pmxcfs (/etc/pve) is served from this SQLite DB with pve-cluster
# running. A plain rsync of the raw file can catch it mid-WAL
@@ -538,31 +615,57 @@ hb_prepare_staging() {
".backup '$staging_root/rootfs/var/lib/pve-cluster/config.db'" 2>/dev/null; then
echo "pmxcfs_config_db=sqlite_backup" >> "$staging_root/metadata/run_info.env.tmp"
else
cp -a /var/lib/pve-cluster/config.db \
"$staging_root/rootfs/var/lib/pve-cluster/config.db.raw-fallback" 2>/dev/null || true
echo "pmxcfs_config_db=raw_fallback" >> "$staging_root/metadata/run_info.env.tmp"
if cp -a /var/lib/pve-cluster/config.db \
"$staging_root/rootfs/var/lib/pve-cluster/config.db.raw-fallback"; then
echo "pmxcfs_config_db=raw_fallback" >> "$staging_root/metadata/run_info.env.tmp"
else
echo "/var/lib/pve-cluster/config.db" >> "$failed_file"
copy_failed=1
fi
fi
else
cp -a /var/lib/pve-cluster/config.db \
"$staging_root/rootfs/var/lib/pve-cluster/config.db.raw-fallback" 2>/dev/null || true
echo "pmxcfs_config_db=raw_fallback" >> "$staging_root/metadata/run_info.env.tmp"
if cp -a /var/lib/pve-cluster/config.db \
"$staging_root/rootfs/var/lib/pve-cluster/config.db.raw-fallback"; then
echo "pmxcfs_config_db=raw_fallback" >> "$staging_root/metadata/run_info.env.tmp"
else
echo "/var/lib/pve-cluster/config.db" >> "$failed_file"
copy_failed=1
fi
fi
fi
local p rel target
local p rel target operator_added configured normalized built_in_match
for p in "${paths[@]}"; do
rel="${p#/}"
echo "$rel" >> "$selected_file"
operator_added=0
normalized="${p%/}"
[[ -z "$normalized" ]] && normalized="/"
for configured in "${operator_paths[@]}"; do
configured="${configured%/}"
[[ -z "$configured" ]] && configured="/"
if [[ "$normalized" == "$configured" ]]; then
operator_added=1
break
fi
done
if (( ! operator_added )); then
built_in_match=0
for configured in "${built_in_paths[@]}"; do
configured="${configured%/}"
[[ -z "$configured" ]] && configured="/"
if [[ "$normalized" == "$configured" ]]; then
built_in_match=1
break
fi
done
(( built_in_match )) || operator_added=1
fi
[[ -e "$p" ]] || { echo "$p" >> "$missing_file"; continue; }
target="$staging_root/rootfs/$rel"
if [[ -d "$p" ]]; then
mkdir -p "$target"
local -a rsync_opts=(
-aAXH --numeric-ids
--exclude "images/"
--exclude "dump/"
--exclude "tmp/"
--exclude "*.log"
)
# /var/lib/pve-cluster: skip the raw config.db and its WAL/SHM
@@ -570,7 +673,7 @@ hb_prepare_staging() {
# sqlite3 .backup (or as raw-fallback when sqlite3 isn't
# available). Everything else in the directory (backup subdir,
# auxiliary state) is safe to rsync live.
if [[ "$rel" == "var/lib/pve-cluster" || "$rel" == "var/lib/pve-cluster/"* ]]; then
if (( ! operator_added )) && [[ "$rel" == "var/lib/pve-cluster" ]]; then
rsync_opts+=(
--exclude "config.db"
--exclude "config.db-wal"
@@ -579,7 +682,7 @@ hb_prepare_staging() {
fi
# /root is included by default for easier recovery, but avoid volatile/sensitive noise.
if [[ "$rel" == "root" || "$rel" == "root/"* ]]; then
if (( ! operator_added )) && [[ "$rel" == "root" ]]; then
rsync_opts+=(
--exclude ".bash_history"
--exclude ".cache/"
@@ -595,7 +698,7 @@ hb_prepare_staging() {
# the destination's fresh install silently regresses the apply_cluster_postboot
# dispatcher and the *_installer.sh --auto-reinstall hooks, breaking the
# "user reinstalls nothing" promise.
if [[ "$rel" == "usr/local/share/proxmenux" || "$rel" == "usr/local/share/proxmenux/"* ]]; then
if (( ! operator_added )) && [[ "$rel" == "usr/local/share/proxmenux" ]]; then
rsync_opts+=(
--exclude "restore-pending/"
--exclude "scripts/"
@@ -612,13 +715,30 @@ hb_prepare_staging() {
)
fi
rsync "${rsync_opts[@]}" "$p/" "$target/" 2>/dev/null || true
if rsync "${rsync_opts[@]}" "$p/" "$target/"; then
echo "$rel" >> "$selected_file"
else
echo "$p" >> "$failed_file"
copy_failed=1
rm -rf "$target"
fi
else
mkdir -p "$(dirname "$target")"
cp -a "$p" "$target" 2>/dev/null || true
if cp -a "$p" "$target"; then
echo "$rel" >> "$selected_file"
else
echo "$p" >> "$failed_file"
copy_failed=1
rm -f "$target"
fi
fi
done
if ! hb_snapshot_backup_job_states "$staging_root"; then
echo "$HB_BACKUP_JOBS_DIR (timer state)" >> "$failed_file"
copy_failed=1
fi
# Metadata snapshot
local meta="$staging_root/metadata"
{
@@ -696,6 +816,8 @@ hb_prepare_staging() {
# parse_manifest doesn't read a 0-byte JSON downstream.
[[ -s "$staging_root/manifest.json" ]] || rm -f "$staging_root/manifest.json"
fi
return "$copy_failed"
}
hb_load_restore_paths() {
+11 -1
View File
@@ -713,7 +713,17 @@ main() {
echo "Paths to back up: ${#paths[@]}"
echo "Preparing staging area at $stage_root ..."
} >>"$log_file"
hb_prepare_staging "$stage_root" "${paths[@]}" >>"$log_file" 2>&1
if ! hb_prepare_staging "$stage_root" "${paths[@]}" >>"$log_file" 2>&1; then
echo "RESULT=failed" >>"$summary_file"
echo "LOG_FILE=${log_file}" >>"$summary_file"
echo "=== Job aborted: one or more selected paths could not be copied ===" >>"$log_file"
export HB_NOTIFY_REASON="One or more selected backup paths could not be copied"
export HB_NOTIFY_DURATION="0s"
hb_notify_lifecycle "fail"
rm -rf "$stage_root"
(( TTY )) && msg_error "$(translate "Backup failed. See log:") $log_file"
exit 1
fi
local staged_files staged_size
staged_files=$(find "$stage_root/rootfs" -type f 2>/dev/null | wc -l)
staged_size=$(hb_file_size "$stage_root/rootfs" 2>/dev/null || echo "?")
+1 -1
View File
@@ -159,7 +159,7 @@ apt_upgrade() {
remove_subscription_banner() {
local FUNC_VERSION="1.1"
local FUNC_VERSION="1.0"
# description: Patch the Proxmox web UI to suppress the subscription dialog and register a successful patch.
local pve_version
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
@@ -2045,7 +2045,7 @@ EOF
setup_motd() {
local FUNC_VERSION="1.1"
local FUNC_VERSION="1.0"
# description: Add the ProxMenux MOTD banner while preserving the original file contents or absence for rollback.
msg_info2 "$(translate "Configuring MOTD (Message of the Day) banner...")"
@@ -105,6 +105,22 @@ ensure_flow_loaded() {
esac
}
# Suppress the Monitor's `service_fail` notifications while this
# wrapper runs. Tools like log2ram or chrony stop and reinstall their
# systemd units, and systemd flags the transient "failed" state
# before the fresh unit starts — same class of noise as PVE services
# restarting during an apt full-upgrade. Shared markers with
# scripts/utilities/proxmox_update.sh; consumed by
# AppImage/scripts/notification_events.py::is_apt_active_on_host.
_PROXMENUX_UPDATE_MARKER="/var/run/proxmenux-update-in-progress"
_PROXMENUX_UPDATE_FINISHED_MARKER="/var/run/proxmenux-update-just-finished"
_proxmenux_postinstall_cleanup() {
rm -f "$_PROXMENUX_UPDATE_MARKER"
touch "$_PROXMENUX_UPDATE_FINISHED_MARKER"
}
trap _proxmenux_postinstall_cleanup EXIT
touch "$_PROXMENUX_UPDATE_MARKER"
# ----------------------------------------------------------------------
# Run each tool. We don't bail on the first failure — the user marked a
# multi-select, they expect every chosen tool to be attempted. RCs are