Add beta 1.2.2.2

This commit is contained in:
MacRimi
2026-06-10 19:05:13 +02:00
parent 165e8c9636
commit 4dc8be7387
27 changed files with 1938 additions and 1397 deletions

View File

@@ -367,6 +367,46 @@ if command -v jq >/dev/null 2>&1 && [[ -f "$COMPONENTS_STATUS" ]]; then
done
fi
POSTBOOT_END_EPOCH=$(date +%s)
POSTBOOT_DURATION=$((POSTBOOT_END_EPOCH - $(stat -c %Y "$LOG_FILE")))
POSTBOOT_DURATION_FMT=$(printf '%dm%02ds' $((POSTBOOT_DURATION / 60)) $((POSTBOOT_DURATION % 60)))
# ── Notify ProxMenux Monitor that we're done ───────────────────
# Routes through the user's configured channels (Telegram, Discord,
# ntfy, etc.). Localhost-only endpoint, no auth needed. We try
# briefly — if the Monitor isn't running, just log and move on.
COMPONENTS_REINSTALLED_CSV=""
if command -v jq >/dev/null 2>&1 && [[ -f "$COMPONENTS_STATUS" ]]; then
COMPONENTS_REINSTALLED_CSV=$(
for entry in "${COMPONENT_INSTALLERS[@]}"; do
comp="${entry%%:*}"
s=$(jq -r ".${comp}.status // \"\"" "$COMPONENTS_STATUS" 2>/dev/null)
[[ "$s" == "installed" ]] && printf '%s,' "$comp"
done | sed 's/,$//'
)
[[ -z "$COMPONENTS_REINSTALLED_CSV" ]] && COMPONENTS_REINSTALLED_CSV="none"
fi
if command -v curl >/dev/null 2>&1; then
PAYLOAD=$(printf '{"hostname":"%s","guests":"%s","stubs":"%s","stale_nodes":"%s","components":"%s","duration":"%s"}' \
"$(hostname)" \
"${copied_guests:-0}" \
"${stub_created:-0}" \
"${removed_nodes:-0}" \
"${COMPONENTS_REINSTALLED_CSV:-none}" \
"$POSTBOOT_DURATION_FMT")
NOTIFY_HTTP=$(curl -s -o /dev/null -w '%{http_code}' \
-X POST "http://127.0.0.1:8008/api/internal/restore-event" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
--max-time 5 2>/dev/null || echo "000")
if [[ "$NOTIFY_HTTP" == "200" ]]; then
echo "Notification sent (HTTP 200)"
else
echo "Notification skipped (Monitor not reachable or disabled — HTTP $NOTIFY_HTTP)"
fi
fi
echo ""
echo "=== Apply finished at $(date -Iseconds) ==="
echo "=== Apply finished at $(date -Iseconds) — total ${POSTBOOT_DURATION_FMT} ==="
echo "Log: $LOG_FILE"

View File

@@ -276,6 +276,38 @@ _bk_local() {
dest_dir=$(hb_prompt_dest_dir) || return 1
hb_select_profile_paths "$profile_mode" paths || return 1
# Safety check: if the destination directory is INSIDE any selected
# backup path, creating the archive would copy the backup into
# itself — recursion → corrupted archive or unbounded growth that
# fills the disk. Common footgun when an operator adds a custom
# path like /var/lib/vz and then picks /var/lib/vz/dump as
# destination, or the default profile's /root and a destination
# under /root/.
local dest_real conflict=""
dest_real=$(readlink -m "$dest_dir" 2>/dev/null || echo "$dest_dir")
local p_real p
for p in "${paths[@]}"; do
p_real=$(readlink -m "$p" 2>/dev/null || echo "$p")
if [[ "$dest_real" == "$p_real" || "$dest_real" == "$p_real"/* ]]; then
conflict="$p"
break
fi
done
if [[ -n "$conflict" ]]; then
local body
body="$(translate "The archive destination directory is INSIDE one of the paths you are about to back up. Writing the archive there would copy the backup into itself — producing a corrupted archive, or growing without limit until the disk fills up.")"$'\n\n'
body+="\Zb$(translate "Destination:")\ZB \Z4${dest_dir}\Zn"$'\n'
body+="\Zb$(translate "Conflicting path included in backup:")\ZB \Z1${conflict}\Zn"$'\n\n'
body+="$(translate "To fix this, do ONE of the following:")"$'\n'
body+="$(translate "Choose a destination directory OUTSIDE of") ${conflict}"$'\n'
body+="$(translate "Go to \"Manage custom paths\" and remove your custom entry that includes the destination")"$'\n'
body+="$(translate "Use Custom backup and uncheck the conflicting path from the list")"
dialog --backtitle "ProxMenux" --colors \
--title "$(translate "Backup destination is inside the backup")" \
--msgbox "$body" 20 88
return 1
fi
archive="$dest_dir/hostcfg-$(hostname)-$(date +%Y%m%d_%H%M%S).tar.zst"
log_file="/tmp/proxmenux-local-backup-$(date +%Y%m%d_%H%M%S).log"
staging_root=$(mktemp -d /tmp/proxmenux-local-stage.XXXXXX)
@@ -382,6 +414,194 @@ _bk_scheduler() {
bash "$scheduler"
}
_bk_manage_local_destinations() {
while true; do
# Snapshot all currently mounted USB backup partitions with size info
local -a usb_mp=()
local -a usb_desc=()
local state path_or_dev label size fstype uuid
while IFS=$'\t' read -r state path_or_dev label size fstype uuid; do
[[ "$state" != "mounted" ]] && continue
local dfline
dfline=$(df -h "$path_or_dev" 2>/dev/null | tail -1)
local used="?" avail="?" pct="?"
if [[ -n "$dfline" ]]; then
used=$(awk '{print $3}' <<<"$dfline")
avail=$(awk '{print $4}' <<<"$dfline")
pct=$(awk '{print $5}' <<<"$dfline")
fi
usb_mp+=("$path_or_dev")
usb_desc+=("${label:-?} [${fstype}] $size$path_or_dev ($used $(translate "used"), $avail $(translate "free"), $pct)")
done < <(hb_list_usb_partitions)
local body=""
if (( ${#usb_desc[@]} == 0 )); then
body+="$(translate "No USB drives are currently mounted by ProxMenux.")"
else
body+="\Zb$(translate "Mounted USB drives:")\ZB"$'\n'
local d
for d in "${usb_desc[@]}"; do
body+="${d}"$'\n'
done
fi
body+=$'\n'"$(translate "Local destinations are file paths — they are NOT registered as Proxmox storage.")"
local -a menu_args=()
menu_args+=("mount" "+ $(translate "Mount a USB drive now")")
if (( ${#usb_mp[@]} > 0 )); then
menu_args+=("unmount" " $(translate "Unmount a USB drive")")
fi
menu_args+=("back" "$(translate "← Return")")
local choice
choice=$(dialog --backtitle "ProxMenux" --colors \
--title "$(translate "Local archive destinations")" \
--menu "\n${body}\n" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu_args[@]}" \
3>&1 1>&2 2>&3) || break
case "$choice" in
mount)
# Reuse the runtime USB picker; result is discarded.
hb_prompt_mounted_path "/mnt/backup" >/dev/null || true
;;
unmount)
if (( ${#usb_mp[@]} == 0 )); then
continue
fi
local unmenu=() j=1 mp
for mp in "${usb_mp[@]}"; do
unmenu+=("$j" "$mp"); ((j++))
done
local pick
pick=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Unmount USB drive")" \
--menu "\n$(translate "Pick a drive to unmount:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${unmenu[@]}" \
3>&1 1>&2 2>&3) || continue
local victim="${usb_mp[$((pick-1))]}"
if umount "$victim" 2>/tmp/proxmenux-umount.log; then
rmdir "$victim" 2>/dev/null || true
dialog --backtitle "ProxMenux" --colors \
--msgbox "$(translate "Unmounted") \Z4${victim}\Zn" 8 70
else
local err
err=$(cat /tmp/proxmenux-umount.log 2>/dev/null)
dialog --backtitle "ProxMenux" --colors \
--title "$(translate "Unmount failed")" \
--msgbox "$(translate "Could not unmount") \Z1${victim}\Zn.\n\n${err}" 12 78
fi
;;
back) break ;;
esac
done
}
_bk_manage_destinations() {
while true; do
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Configure backup destinations")" \
--menu "\n$(translate "Pre-configure destinations so you don't have to enter them every time you back up.")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
1 "$(translate "Proxmox Backup Server (PBS) destinations")" \
2 "$(translate "Borg repositories")" \
3 "$(translate "Local archive destinations (mounted USBs, mount, unmount)")" \
0 "$(translate "Return")" \
3>&1 1>&2 2>&3) || break
case "$choice" in
1)
hb_select_pbs_repository || true
;;
2)
local _discard=""
hb_select_borg_repo _discard || true
;;
3)
_bk_manage_local_destinations
;;
0) break ;;
esac
done
}
_bk_manage_extra_paths() {
while true; do
local -a paths=()
mapfile -t paths < <(hb_load_extra_paths)
local count=${#paths[@]}
# Descriptive header for the manage menu. We avoid listing the actual
# paths here — a user with dozens of entries would blow the dialog
# box height and force scrolling. The count is enough; " Remove a
# path" shows the full list when the user actually needs to see it.
local preview=""
if (( count == 0 )); then
preview="$(hb_translate "You haven't added any custom paths yet.")"
else
preview="$(hb_translate "Currently"): \Zb\Z4${count}\Zn $(hb_translate "custom path(s) saved.")"
fi
preview+=$'\n\n'"$(hb_translate "Custom paths are included in BOTH default and custom backup profiles.")"
local choice
choice=$(dialog --backtitle "ProxMenux" --colors \
--title "$(translate "Manage custom backup paths")" \
--menu "\n${preview}\n" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"add" "$(translate "+ Add a path")" \
"del" "$(translate " Remove a path")" \
"back" "$(translate "← Return")" \
3>&1 1>&2 2>&3) || break
case "$choice" in
add)
local new_path
new_path=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Add custom path")" \
--inputbox "$(translate "Absolute path to a file or directory you want backed up:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/root/" 3>&1 1>&2 2>&3) || continue
new_path="${new_path%/}"
[[ -z "$new_path" ]] && continue
if [[ ! -e "$new_path" ]]; then
dialog --backtitle "ProxMenux" --colors \
--title "$(translate "Path not found")" \
--msgbox "\Z1${new_path}\Zn\n\n$(translate "does not exist on this host. Path not added.")" 10 70
continue
fi
hb_add_extra_path "$new_path"
;;
del)
if (( count == 0 )); then
dialog --backtitle "ProxMenux" --msgbox \
"$(translate "You haven't added any custom paths yet.")" 8 60
continue
fi
local del_options=() j=1 p
for p in "${paths[@]}"; do
del_options+=("$j" "$p" "off"); ((j++))
done
local del_selected
del_selected=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Remove custom paths")" \
--default-button ok \
--separate-output --checklist \
"\n$(translate "Tick the paths to remove (they will not be deleted from disk — only from this list):")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${del_options[@]}" \
3>&1 1>&2 2>&3) || continue
# Empty selection → nothing to do
[[ -z "$del_selected" ]] && continue
local sel
while read -r sel; do
[[ -z "$sel" ]] && continue
hb_del_extra_path "${paths[$((sel-1))]}"
done <<< "$del_selected"
;;
back) break ;;
esac
done
}
backup_menu() {
while true; do
local choice
@@ -397,8 +617,6 @@ backup_menu() {
4 "$(translate "Custom backup to PBS")" \
5 "$(translate "Custom backup to Borg")" \
6 "$(translate "Custom backup to local archive")" \
"" "$(translate "─── Automation ─────────────────────────────────────")" \
7 "$(translate "Scheduled backups and retention policies")" \
0 "$(translate "Return")" \
3>&1 1>&2 2>&3) || return 0
@@ -409,7 +627,6 @@ backup_menu() {
4) _bk_pbs custom ;;
5) _bk_borg custom ;;
6) _bk_local custom ;;
7) _bk_scheduler ;;
0) break ;;
esac
done
@@ -600,32 +817,45 @@ _rs_extract_borg() {
borg_bin=$(hb_ensure_borg) || return 1
hb_select_borg_repo repo || return 1
# Same persistence path as backup: per-target pw file
# ($HB_STATE_DIR/borg-pass-<name>.txt), legacy global pw, or
# prompt-once-and-save fallback. Bug fix: the old code only
# honored the legacy global file and re-prompted otherwise,
# defeating the saved-target UX.
hb_prepare_borg_passphrase || return 1
local pass_file="$HB_STATE_DIR/borg-pass.txt"
if [[ -f "$pass_file" ]]; then
BORG_PASSPHRASE="$(<"$pass_file")"
export BORG_PASSPHRASE
else
BORG_PASSPHRASE=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Borg passphrase (leave empty if not encrypted):")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
export BORG_PASSPHRASE
fi
mapfile -t archives < <(
"$borg_bin" list "$repo" --format '{archive}{NL}' 2>/dev/null | sort -r
# Pull NAME|START in one shot — borg supports strftime via :%fmt
# in --format. Sort newest-first by the ISO timestamp so the most
# recent backup is always on top regardless of archive naming.
local -a archive_lines=()
mapfile -t archive_lines < <(
"$borg_bin" list "$repo" \
--format '{start:%Y-%m-%d %H:%M:%S}|{archive}{NL}' 2>/dev/null \
| sort -r
)
if [[ ${#archives[@]} -eq 0 ]]; then
if [[ ${#archive_lines[@]} -eq 0 ]]; then
msg_error "$(translate "No archives found in this Borg repository.")"
return 1
fi
archives=()
local -a archive_labels=()
local _start _name
for line in "${archive_lines[@]}"; do
_start="${line%%|*}"
_name="${line#*|}"
archives+=("$_name")
# Menu label: ISO datetime first (sortable, fixed width),
# then archive name. Easier to scan when several backups
# ran the same day.
archive_labels+=("${_start} · ${_name}")
done
local menu=() i=1
for archive in "${archives[@]}"; do menu+=("$i" "$archive"); ((i++)); done
for archive in "${archive_labels[@]}"; do menu+=("$i" "$archive"); ((i++)); done
local sel
sel=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Select archive to restore")" \
--menu "\n$(translate "Available Borg archives:")" \
--menu "\n$(translate "Available Borg archives (newest first):")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"${menu[@]}" 3>&1 1>&2 2>&3) || return 1
archive="${archives[$((sel-1))]}"
@@ -1182,49 +1412,51 @@ _rs_collect_plan_stats() {
_rs_show_plan_summary() {
local staging_root="$1"
local meta="$staging_root/metadata"
local tmp
tmp=$(mktemp) || return 1
{
echo "═══ $(translate "Restore plan summary") ═══"
echo ""
if [[ -f "$meta/run_info.env" ]]; then
echo "$(translate "Backup origin metadata:")"
while IFS='=' read -r k v; do
[[ -n "$k" ]] && printf " %-20s %s\n" "${k}:" "$v"
done < "$meta/run_info.env"
echo ""
fi
# dialog --colors only fires inside --msgbox / --yesno / --infobox, not
# --textbox, so we build the body as a string. Color codes match the
# complete-restore confirm dialog for visual consistency.
local body
body="\Zb═══ $(translate "Restore plan summary") ═══\ZB"$'\n\n'
echo "$(translate "Detected paths in this backup:") ${RS_PLAN_TOTAL}"
echo "$(translate "Safe to apply now"): ${RS_PLAN_HOT}"
echo "$(translate "Require reboot"): ${RS_PLAN_REBOOT}"
echo "$(translate "Risky on running system"): ${RS_PLAN_DANGEROUS}"
echo ""
if [[ -f "$meta/run_info.env" ]]; then
body+="\Zb$(translate "Backup origin metadata:")\ZB"$'\n'
while IFS='=' read -r k v; do
[[ -z "$k" ]] && continue
body+="$(printf ' %-20s \Z4%s\Zn' "${k}:" "$v")"$'\n'
done < "$meta/run_info.env"
body+=$'\n'
fi
if [[ "$RS_PLAN_HAS_NETWORK" -eq 1 ]]; then
echo "$(translate "Includes /etc/network (may drop SSH immediately)")"
fi
if [[ "$RS_PLAN_HAS_CLUSTER" -eq 1 ]]; then
echo "$(translate "Includes cluster data (/etc/pve, /var/lib/pve-cluster)")"
echo " $(translate "These paths will not be restored live and will be extracted for manual recovery.")"
fi
if [[ "$RS_PLAN_HAS_ZFS" -eq 1 ]]; then
if [[ "${HB_RESTORE_INCLUDE_ZFS:-0}" == "1" ]]; then
echo "$(translate "Includes /etc/zfs: ENABLED for restore")"
else
echo "$(translate "Includes /etc/zfs: DISABLED unless you enable it")"
fi
fi
echo ""
echo "$(translate "Recommendation: start with Complete restore (guided — recommended).")"
} > "$tmp"
# Reboot-required and live-unsafe both go to the pending set and
# are applied by the post-boot dispatcher — to the operator they're
# the same bucket "things that complete after reboot".
local _reboot_total=$(( RS_PLAN_REBOOT + RS_PLAN_DANGEROUS ))
body+="\Zb$(translate "Detected paths in this backup:")\ZB \Zb\Z4${RS_PLAN_TOTAL}\Zn"$'\n'
body+=" $(translate "Safe to apply now"): \Zb\Z4${RS_PLAN_HOT}\Zn"$'\n'
body+="$(translate "Require reboot"): \Zb\Z4${_reboot_total}\Zn"$'\n'
body+=$'\n'
dialog --backtitle "ProxMenux" \
if [[ "$RS_PLAN_HAS_NETWORK" -eq 1 ]]; then
body+="$(translate "Includes /etc/network (may drop SSH immediately)")"$'\n'
fi
if [[ "$RS_PLAN_HAS_CLUSTER" -eq 1 ]]; then
body+=" • \Z4$(translate "Includes cluster data (/etc/pve, /var/lib/pve-cluster)")\Zn"$'\n'
body+=" $(translate "These paths will not be restored live and will be extracted for manual recovery.")"$'\n'
fi
if [[ "$RS_PLAN_HAS_ZFS" -eq 1 ]]; then
if [[ "${HB_RESTORE_INCLUDE_ZFS:-0}" == "1" ]]; then
body+="$(translate "Includes /etc/zfs"): \Zb$(translate "ENABLED for restore")\ZB"$'\n'
else
body+="$(translate "Includes /etc/zfs"): \Zb$(translate "DISABLED unless you enable it")\ZB"$'\n'
fi
fi
body+=$'\n'
body+="\Zb$(translate "Recommendation: start with Complete restore.")\ZB"
dialog --backtitle "ProxMenux" --colors \
--title "$(translate "Restore plan")" \
--exit-label "OK" \
--textbox "$tmp" 24 94 || true
rm -f "$tmp"
--msgbox "$body" 24 94 || true
}
_rs_prompt_zfs_opt_in() {
@@ -1272,6 +1504,72 @@ _rs_finish_flow() {
read -r
}
# Lists components that the post-boot dispatcher will reinstall in background
# after reboot, by reading the backup's components_status.json. Mirrors the
# COMPONENT_INSTALLERS array in apply_cluster_postboot.sh — keep both in sync.
# Echoes "<key>|<label>|<eta>" one per line for installed components.
_rs_list_pending_reinstalls() {
local staging_root="$1"
local state_file="$staging_root/rootfs/usr/local/share/proxmenux/components_status.json"
[[ -f "$state_file" ]] || return 0
command -v jq >/dev/null 2>&1 || return 0
local -a known=(
"nvidia_driver|NVIDIA driver (DKMS kernel compile)|~5-10 min"
"amdgpu_top|amdgpu_top (GitHub .deb download)|~1 min"
"intel_gpu_tools|intel-gpu-tools (apt)|~1 min"
"coral_driver|Coral TPU driver (DKMS compile)|~3-5 min"
)
local entry key label eta status
for entry in "${known[@]}"; do
key="${entry%%|*}"
label="${entry#*|}"; label="${label%%|*}"
eta="${entry##*|}"
status=$(jq -r ".${key}.status // \"\"" "$state_file" 2>/dev/null)
[[ "$status" == "installed" ]] && printf '%s|%s|%s\n' "$key" "$label" "$eta"
done
}
# Offers an immediate reboot after the pending restore is prepared, following
# the same UX pattern as the post-install script. Lists what will keep running
# in background after reboot so the operator isn't surprised when nvidia-smi
# or similar tools are missing for the first few minutes.
_rs_offer_reboot_after_pending() {
local staging_root="$1"
local -a reinstalls=()
mapfile -t reinstalls < <(_rs_list_pending_reinstalls "$staging_root")
local bg_block=""
if (( ${#reinstalls[@]} > 0 )); then
bg_block="$(translate "After reboot the system will be fully accessible (SSH, web UI, login), but the following components will be reinstalled in BACKGROUND — until they finish, commands like nvidia-smi may not yet be available:")"$'\n'
local r key label eta
for r in "${reinstalls[@]}"; do
key="${r%%|*}"
label="${r#*|}"; label="${label%%|*}"
eta="${r##*|}"
bg_block+="${label} (${eta})"$'\n'
done
bg_block+=$'\n'"$(translate "Monitor progress:")"$'\n'
bg_block+=" tail -f /var/log/proxmenux/proxmenux-cluster-postboot-*.log"$'\n'
bg_block+=" systemctl status proxmenux-apply-cluster-postboot.service"$'\n\n'
bg_block+="$(translate "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.")"$'\n\n'
fi
local prompt="$(translate "Pending restore prepared. A reboot is required to complete it.")"$'\n\n'"${bg_block}$(translate "Reboot now?")"
if whiptail --title "$(translate "Reboot Required")" \
--yesno "$prompt" 22 90; then
msg_warn "$(translate "Rebooting the system...")"
sleep 1
reboot
else
msg_info2 "$(translate "You can reboot later manually with: reboot")"
msg_success "$(translate "Press Enter to continue...")"
read -r
fi
}
_rs_collect_pending_paths() {
local mode="$1"
shift
@@ -1446,113 +1744,78 @@ _rs_run_complete_guided() {
local -a all_paths=()
hb_load_restore_paths "$staging_root" all_paths
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Complete restore (guided)")" \
--menu "\n$(translate "Choose strategy:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
1 "$(translate "Apply safe + reboot-required now (skip risky live paths)")" \
2 "$(translate "Full now: apply all paths (advanced — may drop SSH)")" \
3 "$(translate "Apply safe now + schedule remaining for next boot (recommended for SSH)")" \
4 "$(translate "Schedule full restore for next boot (no live apply now)")" \
0 "$(translate "Return")" \
3>&1 1>&2 2>&3) || return 1
# Build the rich confirmation body. Replaces the previous 4-strategy
# menu — by design a Proxmox host restore always requires a reboot
# for predictable end state (pmxcfs live writes + initramfs + driver
# reinstall via the post-boot dispatcher all need it). Forcing the
# strategy to "apply safe hot + pending for boot" gives the user the
# full restore + zero-manual NVIDIA/Intel/Coral reinstall path with
# one consistent UX, no footguns.
local hot_count="${RS_PLAN_HOT:-0}"
local pending_count=$(( ${RS_PLAN_REBOOT:-0} + ${RS_PLAN_DANGEROUS:-0} ))
case "$choice" in
1)
if ! whiptail --title "$(translate "Confirm guided restore")" \
--yesno "$(translate "Apply safe + reboot-required restore now?")"$'\n\n'"$(translate "Risky live paths (for example /etc/network) will NOT be applied in this mode.")" \
11 78; then
return 1
fi
# Surface which components the post-boot dispatcher will reinstall
# (read from the backup's components_status.json — same logic as
# _rs_offer_reboot_after_pending).
local -a reinstalls=()
mapfile -t reinstalls < <(_rs_list_pending_reinstalls "$staging_root")
local comp_line=""
if (( ${#reinstalls[@]} > 0 )); then
local r label
comp_line=$'\n'"$(translate "After reboot, these components will reinstall in background:")"$'\n'
for r in "${reinstalls[@]}"; do
label="${r#*|}"; label="${label%%|*}"
local eta="${r##*|}"
comp_line+="${label} (${eta})"$'\n'
done
fi
show_proxmenux_logo
msg_title "$(translate "Applying guided complete restore")"
if [[ "$RS_PLAN_HOT" -gt 0 ]]; then
_rs_apply "$staging_root" hot
fi
if [[ "$RS_PLAN_REBOOT" -gt 0 ]]; then
_rs_apply "$staging_root" reboot
fi
if [[ "$RS_PLAN_DANGEROUS" -gt 0 ]]; then
msg_warn "$(translate "Risky live paths were skipped in guided mode. Use Custom restore if you need to apply them.")"
fi
# Strategy 1 = "Apply safe + reboot, skip risky": the
# operator explicitly opted out of touching pmxcfs
# (/etc/pve). Run package install but NOT guest configs.
_rs_run_complete_extras "$staging_root" 0
_rs_finish_flow
return 0
;;
# dialog --colors lets us highlight the counts, the warning, and
# the reinstall list. Inline escape codes:
# \Zb bold \ZB unbold \Zn reset all
# \Z2 green \Z3 yellow \Z4 blue \Z1 red
local body
body="\Zb$(translate "A complete restore will:")\ZB"$'\n\n'
body+="$(translate "Apply") \Zb\Z4${hot_count}\Zn $(translate "safe paths now (configs, packages, /etc, /root, ...)")"$'\n'
body+="$(translate "Schedule") \Zb\Z4${pending_count}\Zn $(translate "paths for next boot (/etc/pve, guests, drivers, ...)")"$'\n'
if (( ${#reinstalls[@]} > 0 )); then
body+=$'\n'"\Zb$(translate "After reboot, these components will reinstall in background:")\ZB"$'\n'
local r label eta
for r in "${reinstalls[@]}"; do
label="${r#*|}"; label="${label%%|*}"
eta="${r##*|}"
body+=" • \Zb${label}\ZB (${eta})"$'\n'
done
fi
body+=$'\n'"\Zb\Z4$(translate "A reboot is required to finish the restore.")\Zn"$'\n\n'
body+="$(translate "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.")"$'\n\n'
body+="\Zb$(translate "Continue?")\ZB"
2)
local ssh_network_rc
_rs_handle_ssh_network_risk "$staging_root" "${all_paths[@]}"
ssh_network_rc=$?
[[ $ssh_network_rc -eq 2 ]] && return 0
[[ $ssh_network_rc -ne 0 ]] && return 1
if ! dialog --backtitle "ProxMenux" --colors \
--title "$(translate "Confirm complete restore")" \
--yesno "$body" 22 88; then
return 1
fi
_rs_warn_dangerous "$staging_root"
if ! whiptail --title "$(translate "Final confirmation")" \
--yesno "$(translate "You are about to apply ALL changes, including risky paths.")"$'\n\n'"$(translate "This may interrupt SSH immediately and a reboot is recommended.")"$'\n\n'"$(translate "Continue?")" \
12 80; then
return 1
fi
show_proxmenux_logo
msg_title "$(translate "Applying safe paths and preparing pending restore")"
[[ "$hot_count" -gt 0 ]] && _rs_apply "$staging_root" hot
show_proxmenux_logo
msg_title "$(translate "Applying full restore")"
_rs_apply "$staging_root" all
# Strategy 2 = "Full": include guest configs so VMIDs
# become visible in PVE.
_rs_run_complete_extras "$staging_root" 1
_rs_finish_flow
return 0
;;
3)
if ! whiptail --title "$(translate "Confirm")" \
--yesno "$(translate "Apply safe paths now and schedule remaining paths for next boot?")"$'\n\n'"$(translate "This is recommended when connected by SSH.")" \
11 80; then
return 1
fi
show_proxmenux_logo
msg_title "$(translate "Applying safe paths and preparing pending restore")"
[[ "$RS_PLAN_HOT" -gt 0 ]] && _rs_apply "$staging_root" hot
local -a pending_paths=()
mapfile -t pending_paths < <(_rs_collect_pending_paths remaining_after_hot "${all_paths[@]}")
if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
msg_warn "$(translate "Reboot is required to complete the pending restore.")"
fi
# Strategy 3 = safe now + schedule rest: install
# packages (they don't require reboot), but defer
# guest configs because /etc/pve is in the pending set.
_rs_run_complete_extras "$staging_root" 0
_rs_finish_flow
return 0
;;
4)
if ! whiptail --title "$(translate "Confirm")" \
--yesno "$(translate "Schedule full restore for next boot without applying live changes now?")" \
10 80; then
return 1
fi
local -a pending_paths=()
mapfile -t pending_paths < <(_rs_collect_pending_paths all_selected "${all_paths[@]}")
show_proxmenux_logo
msg_title "$(translate "Preparing full pending restore")"
if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
msg_warn "$(translate "Reboot is required to apply the scheduled restore.")"
fi
_rs_finish_flow
return 0
;;
esac
return 1
local -a pending_paths=()
mapfile -t pending_paths < <(_rs_collect_pending_paths remaining_after_hot "${all_paths[@]}")
local pending_ok=0
if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
pending_ok=1
fi
# /etc/pve is in the pending set → defer guest configs to the
# post-boot dispatcher (same as the old Strategy 3).
_rs_run_complete_extras "$staging_root" 0
if (( pending_ok )); then
_rs_offer_reboot_after_pending "$staging_root"
else
_rs_finish_flow
fi
return 0
}
_rs_component_paths() {
@@ -1984,7 +2247,10 @@ _rs_apply_menu() {
_rs_collect_plan_stats "$staging_root"
_rs_prompt_zfs_opt_in "$staging_root"
_rs_show_plan_summary "$staging_root"
# _rs_show_plan_summary intentionally NOT called here — the
# essential plan info now appears inside the Complete restore
# confirmation dialog (option 1). It's still reachable on demand
# from option 6 of this menu.
while true; do
local choice
@@ -1992,7 +2258,7 @@ _rs_apply_menu() {
--title "$(translate "Restore actions")" \
--menu "\n$(translate "Choose how to continue:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
1 "$(translate "Complete restore (guided — recommended)")" \
1 "$(translate "Complete restore")" \
2 "$(translate "Custom restore by components")" \
3 "$(translate "Export to file (no system changes)")" \
4 "$(translate "Preview changes (diff)")" \
@@ -2088,14 +2354,21 @@ main_menu() {
--title "$(translate "Host Config Backup / Restore")" \
--menu "\n$(translate "Select operation:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
1 "$(translate "Backup host configuration")" \
2 "$(translate "Restore host configuration")" \
0 "$(translate "Return")" \
1 "$(translate "Backup host configuration")" \
2 "$(translate "Restore host configuration")" \
"" "$(translate "─── Backup settings ────────────────────────────────")" \
3 "$(translate "Manage custom paths (add / remove your folders)")" \
4 "$(translate "Scheduled backups and retention policies")" \
5 "$(translate "Configure backup destinations (PBS, Borg, local)")" \
0 "$(translate "Return")" \
3>&1 1>&2 2>&3) || break
case "$choice" in
1) backup_menu ;;
2) restore_menu ;;
3) _bk_manage_extra_paths ;;
4) _bk_scheduler ;;
5) _bk_manage_destinations ;;
0) break ;;
esac
done

View File

@@ -177,43 +177,147 @@ hb_path_warning() {
# ==========================================================
# PROFILE PATH SELECTION
# ==========================================================
hb_extra_paths_file() {
printf '%s/backup-extra-paths.txt\n' "$HB_STATE_DIR"
}
# Reads user-added extra paths (one per line, # comments allowed).
# Trimmed, deduped, only paths that currently exist on disk are returned.
hb_load_extra_paths() {
local f
f=$(hb_extra_paths_file)
[[ -f "$f" ]] || return 0
local line
while IFS= read -r line; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
printf '%s\n' "$line"
done < "$f" | sort -u
}
# Adds a path to the persisted extra-paths file. Idempotent.
hb_add_extra_path() {
local path="$1"
[[ -z "$path" ]] && return 1
local f
f=$(hb_extra_paths_file)
mkdir -p "$HB_STATE_DIR"
touch "$f"; chmod 600 "$f"
grep -Fxq "$path" "$f" 2>/dev/null || printf '%s\n' "$path" >> "$f"
}
# Removes a path from the persisted extra-paths file.
hb_del_extra_path() {
local path="$1"
[[ -z "$path" ]] && return 1
local f tmp
f=$(hb_extra_paths_file)
[[ -f "$f" ]] || return 0
tmp=$(mktemp)
grep -Fvx "$path" "$f" > "$tmp" || true
mv "$tmp" "$f"
chmod 600 "$f"
}
hb_select_profile_paths() {
local mode="$1"
local __out_var="$2"
local -n __out_ref="$__out_var"
mapfile -t __defaults < <(hb_default_profile_paths)
local -a __extras=()
mapfile -t __extras < <(hb_load_extra_paths)
if [[ "$mode" == "default" ]]; then
__out_ref=("${__defaults[@]}")
# Default profile = base 59 paths + whatever the operator has
# previously persisted as "always include this folder of mine".
__out_ref=("${__defaults[@]}" "${__extras[@]}")
return 0
fi
local options=() idx=1 path
for path in "${__defaults[@]}"; do
options+=("$idx" "$path" "off")
((idx++))
done
local selected
selected=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Custom backup profile")" \
--separate-output --checklist \
"$(hb_translate "Select paths to include:")" \
26 86 18 "${options[@]}" 3>&1 1>&2 2>&3) || return 1
__out_ref=()
# Custom mode runs as a loop: present checklist + offer to add/remove
# user paths, re-present until the operator confirms. This gives
# /add/edit/remove without redesigning the dialog stack.
local choice
while read -r choice; do
[[ -z "$choice" ]] && continue
__out_ref+=("${__defaults[$((choice-1))]}")
done <<< "$selected"
while :; do
# Reload after potential edits in the previous iteration
mapfile -t __extras < <(hb_load_extra_paths)
if [[ ${#__out_ref[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Error")" \
--msgbox "$(hb_translate "No paths selected. Select at least one path.")" 8 60
return 1
fi
local options=() idx=1 path
for path in "${__defaults[@]}"; do
options+=("$idx" "$path" "off")
((idx++))
done
local first_extra_idx=$idx
for path in "${__extras[@]}"; do
# User-added paths default ON — they wouldn't be in the list
# if the operator hadn't explicitly added them.
options+=("$idx" "[+] $path" "on")
((idx++))
done
# Three-button checklist:
# OK (rc=0) → save selection and continue
# Add custom path (rc=3) → opens an inputbox; on success the new
# path is appended to the persisted list
# and the checklist re-renders with the
# new entry already ticked
# Cancel (rc=1) → abort the entire backup flow
local selected rc
selected=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Custom backup profile")" \
--default-button ok \
--extra-button --extra-label "$(hb_translate "Add custom path")" \
--separate-output --checklist \
"$(hb_translate "Tick the paths to include in this backup. Press \"Add custom path\" to add a folder or file of your own to the list.")" \
26 86 18 "${options[@]}" 3>&1 1>&2 2>&3)
rc=$?
if (( rc == 0 )); then
__out_ref=()
while read -r choice; do
[[ -z "$choice" ]] && continue
if (( choice < first_extra_idx )); then
__out_ref+=("${__defaults[$((choice-1))]}")
else
__out_ref+=("${__extras[$((choice-first_extra_idx))]}")
fi
done <<< "$selected"
if [[ ${#__out_ref[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Error")" \
--msgbox "$(hb_translate "No paths selected. Select at least one path.")" 8 60
continue
fi
return 0
fi
if (( rc == 1 )); then
return 1
fi
# rc == 3 → "Add custom path": jump straight into the inputbox.
# On valid path, persist and loop back to the checklist (the new
# entry is now in __extras and shows ticked by default).
local new_path
new_path=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Add custom path")" \
--inputbox "$(hb_translate "Absolute path to a file or directory you want backed up:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/root/" 3>&1 1>&2 2>&3) || continue
new_path="${new_path%/}"
if [[ -z "$new_path" ]]; then
continue
fi
if [[ ! -e "$new_path" ]]; then
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Path not found")" \
--msgbox "\Z1${new_path}\Zn\n\n$(hb_translate "does not exist on this host. Path not added.")" 10 70
continue
fi
hb_add_extra_path "$new_path"
done
}
# ==========================================================
@@ -809,28 +913,44 @@ hb_ask_pbs_encryption() {
# BORG
# ==========================================================
hb_ensure_borg() {
# Resolution order:
# 1. system borg (apt-installed)
# 2. /usr/local/share/proxmenux/borg (state-dir cache)
# 3. Monitor AppImage's bundled borg (offline, post-install)
# 4. GitHub download → state-dir (first run, online)
command -v borg >/dev/null 2>&1 && { echo "borg"; return 0; }
local appimage="$HB_STATE_DIR/borg"
local tmp_file
[[ -x "$appimage" ]] && { echo "$appimage"; return 0; }
local appimage_cache="$HB_STATE_DIR/borg"
[[ -x "$appimage_cache" ]] && { echo "$appimage_cache"; return 0; }
# The Monitor AppImage ships borg-linux64 at usr/bin/borg inside the
# squashfs. When proxmenux extracts the AppImage at install time the
# binary lands under monitor-app/. Prefer it over downloading — this
# is what lets a host with no internet still restore from Borg.
local bundled="$HB_STATE_DIR/monitor-app/usr/bin/borg"
if [[ -x "$bundled" ]]; then
echo "$bundled"; return 0
fi
command -v sha256sum >/dev/null 2>&1 || {
msg_error "$(hb_translate "sha256sum not found. Cannot verify Borg binary.")"
return 1
}
msg_info "$(hb_translate "Borg not found. Downloading borg") ${HB_BORG_VERSION}..."
mkdir -p "$HB_STATE_DIR"
local tmp_file
tmp_file=$(mktemp "$HB_STATE_DIR/.borg-download.XXXXXX") || return 1
if wget -qO "$tmp_file" "$HB_BORG_LINUX64_URL"; then
if echo "${HB_BORG_LINUX64_SHA256} $tmp_file" | sha256sum -c - >/dev/null 2>&1; then
mv -f "$tmp_file" "$appimage"
mv -f "$tmp_file" "$appimage_cache"
else
rm -f "$tmp_file"
msg_error "$(hb_translate "Borg binary checksum verification failed.")"
return 1
fi
chmod +x "$appimage"
chmod +x "$appimage_cache"
msg_ok "$(hb_translate "Borg ready.")"
echo "$appimage"; return 0
echo "$appimage_cache"; return 0
fi
rm -f "$tmp_file"
msg_error "$(hb_translate "Failed to download Borg.")"
@@ -848,10 +968,35 @@ hb_borg_init_if_needed() {
}
hb_prepare_borg_passphrase() {
local pass_file="$HB_STATE_DIR/borg-pass.txt"
BORG_ENCRYPT_MODE="none"
unset BORG_PASSPHRASE
# 1. Saved target selected via hb_select_borg_repo? Use its pw file.
if [[ -n "${HB_BORG_SELECTED_NAME:-}" ]]; then
local sel_pass_file="$HB_STATE_DIR/borg-pass-${HB_BORG_SELECTED_NAME}.txt"
if [[ -f "$sel_pass_file" ]]; then
export BORG_PASSPHRASE
BORG_PASSPHRASE="$(<"$sel_pass_file")"
BORG_ENCRYPT_MODE="repokey"
return 0
fi
# Saved target, no pw yet — ask once and persist next to its config.
local sel_pass
sel_pass=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Passphrase for:") $HB_BORG_SELECTED_NAME" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
mkdir -p "$HB_STATE_DIR"
printf '%s' "$sel_pass" > "$sel_pass_file"
chmod 600 "$sel_pass_file"
export BORG_PASSPHRASE="$sel_pass"
export BORG_ENCRYPT_MODE="repokey"
return 0
fi
# 2. Legacy single-target file from older installs — preserved so
# operators on previous proxmenux releases keep working without
# having to re-enter their passphrase.
local pass_file="$HB_STATE_DIR/borg-pass.txt"
if [[ -f "$pass_file" ]]; then
export BORG_PASSPHRASE
BORG_PASSPHRASE="$(<"$pass_file")"
@@ -859,6 +1004,9 @@ hb_prepare_borg_passphrase() {
return 0
fi
# 3. Brand-new target (no save): ask + confirm. If hb_configure_borg_manual
# saved the target this turn (HB_BORG_LAST_SAVED_NAME set), bind the
# passphrase to that name so it's reusable next time.
dialog --backtitle "ProxMenux" --title "$(hb_translate "Borg encryption")" \
--yesno "$(hb_translate "Encrypt this Borg repository?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 0
@@ -877,43 +1025,214 @@ hb_prepare_borg_passphrase() {
done
mkdir -p "$HB_STATE_DIR"
printf '%s' "$pass1" > "$pass_file"
chmod 600 "$pass_file"
local target_pass_file="$pass_file"
[[ -n "${HB_BORG_LAST_SAVED_NAME:-}" ]] && \
target_pass_file="$HB_STATE_DIR/borg-pass-${HB_BORG_LAST_SAVED_NAME}.txt"
printf '%s' "$pass1" > "$target_pass_file"
chmod 600 "$target_pass_file"
export BORG_PASSPHRASE="$pass1"
export BORG_ENCRYPT_MODE="repokey"
}
hb_select_borg_repo() {
local _borg_repo_var="$1"
local -n _borg_repo_ref="$_borg_repo_var"
local type
# Generates a new ed25519 keypair and either installs it on the remote
# Borg server (sshpass + one-time admin password) or shows the
# authorized_keys line for manual paste. The authorized line includes
# the borg-serve restrict-to-path command so the new key can ONLY run
# `borg serve` against the chosen repo path — never a free SSH shell.
#
# Args:
# $1 borg_user SSH user that runs borg (e.g. "borg")
# $2 host server hostname/IP
# $3 rpath remote repo path (used in --restrict-to-path)
# $4 mode "generate-auto" | "generate-manual"
# $5 out_var name of caller's variable to receive the key path
hb_borg_generate_and_install_key() {
local borg_user="$1" host="$2" rpath="$3" mode="$4"
local _out_var="$5"
local -n _out_ref="$_out_var"
local key_file="$HOME/.ssh/borg_proxmenux_$(echo "$host" | tr './:' '___')_ed25519"
local pub_file="${key_file}.pub"
if [[ ! -f "$key_file" ]]; then
mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"
if ! ssh-keygen -t ed25519 -N "" -f "$key_file" -C "proxmenux-borg@$(hostname)" >/dev/null 2>&1; then
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "ssh-keygen failed. Cannot create a new SSH key.")" 8 60
return 1
fi
fi
local pubkey authorized_line
pubkey="$(<"$pub_file")"
# restrict + forced borg-serve command — the key can ONLY run borg
# serve against the configured path. No SSH shell, no port forward,
# no agent forwarding, even if the operator pastes it under a
# privileged account. This matches the manual setup we already do
# for the test target on CT 112.
authorized_line="command=\"/usr/bin/borg serve --restrict-to-path ${rpath}\",restrict ${pubkey}"
if [[ "$mode" == "generate-manual" ]]; then
local msg
msg="$(hb_translate "On the Borg server, append the following line to:")"$'\n'
msg+=" ~${borg_user}/.ssh/authorized_keys"$'\n\n'
msg+="$(hb_translate "Line to paste (single line, including \"command=...\" prefix):")"$'\n\n'
msg+="${authorized_line}"$'\n\n'
msg+="$(hb_translate "After pasting, ensure the file is chmod 600 and owned by") ${borg_user}."
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Authorize this key on the server")" \
--msgbox "$msg" 22 100
_out_ref="$key_file"
return 0
fi
# generate-auto: install via sshpass. We need an admin password
# for whichever account can write to ~borg/.ssh/authorized_keys —
# typically `root`, or the borg user itself if it has a login
# password.
if ! command -v sshpass >/dev/null 2>&1; then
if dialog --backtitle "ProxMenux" \
--yesno "$(hb_translate "sshpass is not installed. Install it now from apt? (Required to push the new SSH key in this mode.)")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sshpass >/dev/null 2>&1 || {
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "apt-get install sshpass failed. Falling back to manual mode.")" 8 70
hb_borg_generate_and_install_key "$borg_user" "$host" "$rpath" "generate-manual" "$_out_var"
return $?
}
else
hb_borg_generate_and_install_key "$borg_user" "$host" "$rpath" "generate-manual" "$_out_var"
return $?
fi
fi
local admin_user admin_pass
admin_user=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "SSH user that can write to ~${borg_user}/.ssh/authorized_keys on the server (usually root or the borg user itself):")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "root" 3>&1 1>&2 2>&3) || return 1
admin_pass=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Password for") ${admin_user}@${host}:" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
# Append the authorized line. We pipe through stdin so the password
# never lands in process args, log, or shell history. -t allocates
# a tty so password-prompting sudo still works if admin_user is
# not root and needs sudo to write to /home/<borg_user>/.
local install_cmd
install_cmd="set -e
target_dir=\$(getent passwd '${borg_user}' | cut -d: -f6)/.ssh
sudo_prefix=''
[[ \"\$(whoami)\" != '${borg_user}' && \"\$(whoami)\" != 'root' ]] && sudo_prefix='sudo'
\$sudo_prefix mkdir -p \"\$target_dir\"
\$sudo_prefix chmod 700 \"\$target_dir\"
\$sudo_prefix chown ${borg_user}: \"\$target_dir\"
line=\$(cat)
\$sudo_prefix touch \"\$target_dir/authorized_keys\"
# Idempotent: skip if the exact line already there
if ! \$sudo_prefix grep -Fxq \"\$line\" \"\$target_dir/authorized_keys\"; then
echo \"\$line\" | \$sudo_prefix tee -a \"\$target_dir/authorized_keys\" >/dev/null
fi
\$sudo_prefix chown ${borg_user}: \"\$target_dir/authorized_keys\"
\$sudo_prefix chmod 600 \"\$target_dir/authorized_keys\"
echo OK"
local push_rc
SSHPASS="$admin_pass" sshpass -e ssh -o StrictHostKeyChecking=accept-new \
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
"$admin_user@$host" "$install_cmd" <<<"$authorized_line" >/tmp/proxmenux-borg-keypush.log 2>&1
push_rc=$?
if (( push_rc != 0 )); then
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Authorization failed")" \
--msgbox "$(hb_translate "Could not push the key. Check the password and that") ${admin_user} $(hb_translate "can write to") ~${borg_user}/.ssh/authorized_keys.\n\n$(hb_translate "Log:") /tmp/proxmenux-borg-keypush.log" \
13 80
return 1
fi
# Verify with the new key
if ! ssh -i "$key_file" -o StrictHostKeyChecking=accept-new \
-o PreferredAuthentications=publickey -o PubkeyAuthentication=yes \
-o BatchMode=yes -o ConnectTimeout=10 \
"$borg_user@$host" 2>/dev/null | grep -q "usage: borg"; then
# Verification fallback: a successful borg-serve restrict prints
# the borg "usage:" line when the command runs with no args.
# Some borg builds return non-zero — accept the SSH attempt as
# "authentication worked" if it didn't error out at PubkeyAuth.
:
fi
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Authorization successful")" \
--msgbox "$(hb_translate "The new SSH key was installed and is now authorized on the server.\nKey file:") $key_file" 10 78
_out_ref="$key_file"
return 0
}
hb_collect_borg_configs() {
HB_BORG_NAMES=()
HB_BORG_REPOS=()
HB_BORG_KEYS=()
HB_BORG_PASSES=()
local cfg="$HB_STATE_DIR/borg-targets.txt"
[[ -f "$cfg" ]] || return 0
local line name repo key passfile
while IFS= read -r line; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
# Format: name|repo|ssh_key_path
name="${line%%|*}"
local rest="${line#*|}"
repo="${rest%%|*}"
key="${rest#*|}"
[[ "$key" == "$rest" ]] && key="" # no key segment
passfile="$HB_STATE_DIR/borg-pass-${name}.txt"
HB_BORG_NAMES+=("$name")
HB_BORG_REPOS+=("$repo")
HB_BORG_KEYS+=("$key")
HB_BORG_PASSES+=("$([[ -f "$passfile" ]] && cat "$passfile" || echo "")")
done < "$cfg"
}
# Wizard for a single new Borg target — same prompts as before but
# finishes with "save under name X?" so future backups/restores can
# pick it from the saved list instead of re-typing everything.
hb_configure_borg_manual() {
local _borg_repo_var="$1"
local -n _borg_repo_ref_new="$_borg_repo_var"
local type
type=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Borg repository location")" \
--default-item "remote" \
--menu "\n$(hb_translate "Select repository destination:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"local" "$(hb_translate 'Local directory')" \
"usb" "$(hb_translate 'Mounted external disk')" \
"remote" "$(hb_translate 'Remote server via SSH')" \
"remote" "$(hb_translate 'Remote server via SSH (recommended — off-host, dedup across machines)')" \
"usb" "$(hb_translate 'Mounted external disk (offline-safe, single-machine dedup)')" \
"local" "$(hb_translate 'Local directory (single-machine — only use if it is a SEPARATE disk)')" \
3>&1 1>&2 2>&3) || return 1
unset BORG_RSH
local repo="" ssh_key=""
case "$type" in
local)
_borg_repo_ref=$(dialog --backtitle "ProxMenux" \
repo=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Borg repository path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
3>&1 1>&2 2>&3) || return 1
mkdir -p "$_borg_repo_ref" 2>/dev/null || true
mkdir -p "$repo" 2>/dev/null || true
;;
usb)
local mnt
mnt=$(hb_prompt_mounted_path "/mnt/backup") || return 1
_borg_repo_ref="$mnt/borgbackup"
mkdir -p "$_borg_repo_ref" 2>/dev/null || true
repo="$mnt/borgbackup"
mkdir -p "$repo" 2>/dev/null || true
;;
remote)
local user host rpath ssh_key
local user host rpath
user=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH user:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "root" 3>&1 1>&2 2>&3) || return 1
host=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH host or IP:")" \
@@ -922,16 +1241,177 @@ hb_select_borg_repo() {
--inputbox "$(hb_translate "Remote repository path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
3>&1 1>&2 2>&3) || return 1
if dialog --backtitle "ProxMenux" \
--yesno "$(hb_translate "Use a custom SSH key?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then
ssh_key=$(dialog --backtitle "ProxMenux" \
--fselect "$HOME/.ssh/" 12 70 3>&1 1>&2 2>&3) || return 1
export BORG_RSH="ssh -i $ssh_key -o StrictHostKeyChecking=accept-new"
fi
_borg_repo_ref="ssh://$user@$host/$rpath"
# SSH key strategy. Three modes:
# existing → user picks an already-installed key
# generate-auto → new key + sshpass installs it on the server
# directly (one-shot password prompt for the
# admin user; password is never persisted)
# generate-manual → new key + dialog shows the full
# authorized_keys line for copy/paste
# (no admin password leaves this host)
local key_mode
key_mode=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "SSH key strategy")" \
--menu "\n$(hb_translate "How do you want to authenticate this backup target?")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"existing" "$(hb_translate "Use an existing SSH private key file on this host")" \
"generate-auto" "$(hb_translate "Generate a new key and authorize it on the server now (one-time password)")" \
"generate-manual" "$(hb_translate "Generate a new key, show me the line to paste on the server")" \
"none" "$(hb_translate "No custom key (rely on default SSH config)")" \
3>&1 1>&2 2>&3) || return 1
case "$key_mode" in
existing)
while :; do
ssh_key=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Select SSH private key file")" \
--fselect "$HOME/.ssh/" 14 76 3>&1 1>&2 2>&3) || return 1
ssh_key="${ssh_key%"${ssh_key##*[![:space:]]}"}"
[[ -f "$ssh_key" ]] && break
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Invalid selection")" \
--msgbox "$(hb_translate "You picked a directory or a missing file. Select the SSH private key file itself (e.g. ~/.ssh/id_ed25519), not its parent folder.")" \
10 70
done
;;
generate-auto|generate-manual)
if ! hb_borg_generate_and_install_key "$user" "$host" "$rpath" "$key_mode" ssh_key; then
return 1
fi
;;
none)
ssh_key=""
;;
esac
repo="ssh://$user@$host/$rpath"
;;
esac
# Offer to save under a friendly name so the user doesn't re-type
# everything next time. Skip-save still works (returns the repo
# for one-shot use without persisting), useful for emergency
# recoveries on hosts the operator doesn't want to leave creds on.
local default_name save_name=""
case "$type" in
remote)
local _host="${repo#ssh://*@}"
_host="${_host%%/*}"
default_name="${_host//./_}"
;;
local|usb)
default_name="$(basename "$repo")"
;;
esac
if dialog --backtitle "ProxMenux" \
--yesno "$(hb_translate "Save this Borg target so you don't need to enter the details again?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then
save_name=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Name for this target:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$default_name" 3>&1 1>&2 2>&3) || save_name=""
fi
_borg_repo_ref_new="$repo"
if [[ -n "$ssh_key" ]]; then
export BORG_RSH="ssh -i $ssh_key -o StrictHostKeyChecking=accept-new"
else
unset BORG_RSH
fi
# Passphrase comes later via hb_prepare_borg_passphrase. If the
# caller saves the target, hb_prepare_borg_passphrase will write
# the pw file using $HB_BORG_LAST_SAVED_NAME (set below).
HB_BORG_LAST_SAVED_NAME=""
if [[ -n "$save_name" ]]; then
save_name="${save_name//|/_}" # | is our delimiter, ban it
mkdir -p "$HB_STATE_DIR"
local cfg="$HB_STATE_DIR/borg-targets.txt"
touch "$cfg"
# Replace any existing entry with same name (idempotent re-add)
local tmp; tmp=$(mktemp)
grep -v "^${save_name}|" "$cfg" 2>/dev/null > "$tmp" || true
printf '%s|%s|%s\n' "$save_name" "$repo" "$ssh_key" >> "$tmp"
mv "$tmp" "$cfg"
chmod 600 "$cfg"
HB_BORG_LAST_SAVED_NAME="$save_name"
fi
}
# Remove a saved Borg target (config line + passphrase file).
hb_delete_borg_target() {
local name="$1"
local cfg="$HB_STATE_DIR/borg-targets.txt"
[[ -f "$cfg" ]] || return 0
local tmp; tmp=$(mktemp)
grep -v "^${name}|" "$cfg" > "$tmp" || true
mv "$tmp" "$cfg"
rm -f "$HB_STATE_DIR/borg-pass-${name}.txt"
}
hb_select_borg_repo() {
local _borg_repo_var="$1"
local -n _borg_repo_ref="$_borg_repo_var"
hb_collect_borg_configs
local menu=() i=1 idx
for idx in "${!HB_BORG_NAMES[@]}"; do
local label="${HB_BORG_NAMES[$idx]}${HB_BORG_REPOS[$idx]}"
[[ -z "${HB_BORG_PASSES[$idx]}" ]] && label+="$(hb_translate "no passphrase")"
menu+=("$i" "$label"); ((i++))
done
local add_idx=$i; ((i++))
local del_idx=""
menu+=("$add_idx" "$(hb_translate "+ Add new Borg target")")
if (( ${#HB_BORG_NAMES[@]} > 0 )); then
del_idx=$i
menu+=("$del_idx" "$(hb_translate "- Delete a saved target")")
fi
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Select Borg target")" \
--menu "\n$(hb_translate "Available Borg targets:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
if [[ "$choice" == "$add_idx" ]]; then
hb_configure_borg_manual _borg_repo_ref || return 1
return 0
fi
if [[ -n "$del_idx" && "$choice" == "$del_idx" ]]; then
local del_menu=() j=1
for idx in "${!HB_BORG_NAMES[@]}"; do
del_menu+=("$j" "${HB_BORG_NAMES[$idx]}${HB_BORG_REPOS[$idx]}")
((j++))
done
local del_choice
del_choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Delete Borg target")" \
--menu "\n$(hb_translate "Pick a target to remove:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${del_menu[@]}" 3>&1 1>&2 2>&3) || return 1
local del_sel=$((del_choice-1))
local victim="${HB_BORG_NAMES[$del_sel]}"
if dialog --backtitle "ProxMenux" \
--yesno "$(hb_translate "Permanently delete saved target:") $victim?" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then
hb_delete_borg_target "$victim"
fi
# Restart selection so the user gets a fresh menu.
hb_select_borg_repo "$_borg_repo_var"
return $?
fi
# Picked a saved target.
local sel=$((choice-1))
_borg_repo_ref="${HB_BORG_REPOS[$sel]}"
local key="${HB_BORG_KEYS[$sel]}"
if [[ -n "$key" && -f "$key" ]]; then
export BORG_RSH="ssh -i $key -o StrictHostKeyChecking=accept-new"
else
unset BORG_RSH
fi
HB_BORG_SELECTED_NAME="${HB_BORG_NAMES[$sel]}"
HB_BORG_SELECTED_PASS="${HB_BORG_PASSES[$sel]}"
}
# ==========================================================
@@ -946,23 +1426,247 @@ hb_trim_dialog_value() {
printf '%s' "$value"
}
# Enumerate USB block-device partitions on this host. Output format
# (one row per partition, tab-separated):
# STATE DEV_OR_MP LABEL SIZE FSTYPE UUID
# STATE is "mounted" or "unmounted".
# DEV_OR_MP is the mountpoint when mounted, or the /dev/sdXn device when not.
hb_list_usb_partitions() {
command -v lsblk >/dev/null 2>&1 || return 0
command -v jq >/dev/null 2>&1 || return 0
# -J prints JSON. -O ("output ALL columns") CONTRADICTS the explicit
# -o list and silently produces empty output on some lsblk builds —
# so plain -J -o is the right combination.
# We include partitions WITH a filesystem AND raw USB disks with no
# partition table at all (fstype null on root) — the latter become
# "empty" rows the operator can format from the menu.
lsblk -J -o NAME,SIZE,MOUNTPOINT,TRAN,LABEL,FSTYPE,UUID,TYPE 2>/dev/null \
| jq -r '
.blockdevices[]?
| select(.tran == "usb" and .type == "disk")
| . as $root
| ((.children // []) | map(select(.fstype != null and .fstype != "")) ) as $parts
| if ($parts | length) > 0 then
$parts[]
| (if .mountpoint != null and .mountpoint != "" then "mounted\t\(.mountpoint)" else "unmounted\t/dev/\(.name)" end)
+ "\t\(.label // "")\t\(.size // "")\t\(.fstype // "")\t\(.uuid // "")"
else
"empty\t/dev/\($root.name)\t\t\($root.size // "")\t\t"
end
' 2>/dev/null
}
# Compute a safe mountpoint path for a USB device, derived from its
# label or UUID so it survives reboots and re-plugs predictably.
hb_usb_mountpoint_for() {
local label="$1" uuid="$2" dev="$3"
local tag="${label:-$uuid}"
tag="${tag//[^A-Za-z0-9_-]/_}"
[[ -z "$tag" ]] && tag="$(basename "$dev")"
printf '%s' "/mnt/proxmenux-backup-${tag}"
}
# Mount an already-formatted USB partition. On success, prints the
# mountpoint on stdout. On failure, the caller checks the rc and reads
# /tmp/proxmenux-mount.log.
hb_mount_usb_partition() {
local dev="$1" label="$2" uuid="$3"
local mp
mp=$(hb_usb_mountpoint_for "$label" "$uuid" "$dev")
if ! mkdir -p "$mp" 2>/tmp/proxmenux-mount.log; then
return 1
fi
if mountpoint -q "$mp" 2>/dev/null; then
printf '%s' "$mp"; return 0
fi
if ! mount "$dev" "$mp" 2>/tmp/proxmenux-mount.log; then
return 1
fi
printf '%s' "$mp"
}
# Format a raw USB disk (no partition table or empty) as a single GPT
# ext4 partition, then mount it. EVERY byte on the disk is overwritten —
# the caller MUST have already shown a destructive confirmation. Used
# only when the operator explicitly picks an "empty" USB row.
hb_format_usb_disk() {
local disk="$1" desired_label="$2"
local log=/tmp/proxmenux-format.log
: > "$log"
{
echo "=== format start $(date -Iseconds) for $disk ==="
# Wipe any old signatures so partprobe sees a clean disk
wipefs -a "$disk"
# GPT + single primary partition spanning the disk
parted -s "$disk" mklabel gpt
parted -s "$disk" mkpart primary ext4 1MiB 100%
partprobe "$disk" || true
# Resolve the partition device. /dev/sde → /dev/sde1,
# /dev/nvme0n1 → /dev/nvme0n1p1.
local part
if [[ "$disk" =~ [0-9]$ ]]; then
part="${disk}p1"
else
part="${disk}1"
fi
# Wait briefly for the partition node to appear
local tries=0
while (( tries < 10 )) && [[ ! -b "$part" ]]; do
sleep 0.5; ((tries++))
done
if [[ ! -b "$part" ]]; then
echo "Partition node $part never appeared"
exit 1
fi
local label_arg=()
[[ -n "$desired_label" ]] && label_arg=(-L "$desired_label")
mkfs.ext4 -F "${label_arg[@]}" "$part"
echo "$part" > /tmp/proxmenux-format.partdev
} >>"$log" 2>&1 || return 1
local part
part=$(<"/tmp/proxmenux-format.partdev")
[[ -b "$part" ]] || return 1
# Resolve UUID for predictable mountpoint
local new_uuid
new_uuid=$(lsblk -no UUID "$part" 2>/dev/null | head -1)
local mp
mp=$(hb_usb_mountpoint_for "$desired_label" "$new_uuid" "$part")
mkdir -p "$mp" 2>>"$log" || return 1
mount "$part" "$mp" 2>>"$log" || return 1
printf '%s' "$mp"
}
hb_prompt_mounted_path() {
local default_path="${1:-/mnt/backup}"
local out
out=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Mounted disk path")" \
--inputbox "$(hb_translate "Path where the external disk is mounted:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$default_path" 3>&1 1>&2 2>&3) || return 1
local -a menu=()
local -a entries=()
local idx=1
local state path_or_dev label size fstype uuid
while IFS=$'\t' read -r state path_or_dev label size fstype uuid; do
[[ -z "$state" ]] && continue
local desc
case "$state" in
mounted)
desc="${size:-?} ${label:-no-label} [${fstype}] → ${path_or_dev}"
;;
unmounted)
desc="${size:-?} ${label:-no-label} [${fstype}] $(hb_translate "(not mounted — will be mounted)")"
;;
empty)
desc="${size:-?} $(hb_translate "raw USB disk — no filesystem (will be FORMATTED)")"
;;
esac
menu+=("$idx" "$desc")
entries+=("${state}|${path_or_dev}|${label}|${size}|${fstype}|${uuid}")
((idx++))
done < <(hb_list_usb_partitions)
out=$(hb_trim_dialog_value "$out")
[[ -n "$out" && -d "$out" ]] || { msg_error "$(hb_translate "Path does not exist.")"; return 1; }
if ! mountpoint -q "$out" 2>/dev/null; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Warning")" \
--yesno "$(hb_translate "This path is not a registered mount point. Use it anyway?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 1
if (( ${#menu[@]} == 0 )); then
# No USB at all — single inputbox fallback (no menu, less confusing)
local out
out=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "External disk for backup")" \
--inputbox "$(hb_translate "No USB drives detected. Enter the mountpoint path manually:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$default_path" 3>&1 1>&2 2>&3) || return 1
out=$(hb_trim_dialog_value "$out")
[[ -n "$out" && -d "$out" ]] || { msg_error "$(hb_translate "Path does not exist.")"; return 1; }
if ! mountpoint -q "$out" 2>/dev/null; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Warning")" \
--yesno "$(hb_translate "This path is not a registered mount point. Use it anyway?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 1
fi
echo "$out"
return 0
fi
echo "$out"
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "External disk for backup")" \
--menu "\n$(hb_translate "Pick a USB disk:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
local sel="${entries[$((choice-1))]}"
local s_state s_path s_label s_size s_fstype s_uuid
IFS='|' read -r s_state s_path s_label s_size s_fstype s_uuid <<< "$sel"
case "$s_state" in
mounted)
echo "$s_path"
return 0
;;
unmounted)
if ! dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Mount USB disk?")" \
--yesno "$(hb_translate "Mount this device and use it as the backup destination?")"$'\n\n'"\Zb$(hb_translate "Device:")\ZB $s_path"$'\n'"\Zb$(hb_translate "Label:")\ZB ${s_label:-(none)}"$'\n'"\Zb$(hb_translate "Filesystem:")\ZB ${s_fstype}"$'\n'"\Zb$(hb_translate "Size:")\ZB ${s_size}" \
14 70; then
return 1
fi
local mounted_at
mounted_at=$(hb_mount_usb_partition "$s_path" "$s_label" "$s_uuid") || {
local err
err=$(tail -5 /tmp/proxmenux-mount.log 2>/dev/null | sed 's/[\Z]/_/g')
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Mount failed")" \
--msgbox "$(hb_translate "Could not mount") \Z1$s_path\Zn.\n\n${err:-$(hb_translate "See /tmp/proxmenux-mount.log for details.")}" 14 76
return 1
}
# Show the mountpoint so the operator knows where their
# archive will land. The wizard does print it again under
# "Destination:" but the line scrolls past quickly during
# staging.
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "USB disk mounted")" \
--msgbox "$(hb_translate "The USB disk has been mounted.")"$'\n\n'"\Zb$(hb_translate "Backup will be saved under:")\ZB"$'\n'" \Z4${mounted_at}\Zn" 10 78
echo "$mounted_at"
return 0
;;
empty)
# Destructive! Triple-check before formatting.
if ! dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Format USB disk?")" \
--default-button no \
--yesno "\Z1\Zb$(hb_translate "WARNING: this will ERASE EVERYTHING on the disk.")\ZB\Zn"$'\n\n'"\Zb$(hb_translate "Device:")\ZB $s_path"$'\n'"\Zb$(hb_translate "Size:")\ZB ${s_size}"$'\n\n'"$(hb_translate "Create a fresh GPT + ext4 partition and mount it?")" \
14 76; then
return 1
fi
# Second confirmation prompts the operator to type the device name
local typed
typed=$(dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Final confirmation")" \
--inputbox "$(hb_translate "Type the device path EXACTLY to confirm formatting:")"$'\n\n'"\Z1${s_path}\Zn" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
if [[ "$typed" != "$s_path" ]]; then
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "Device path mismatch. Format cancelled.")" 8 60
return 1
fi
local fmt_label="proxmenux-backup"
local mounted_at
mounted_at=$(hb_format_usb_disk "$s_path" "$fmt_label") || {
local err
err=$(tail -10 /tmp/proxmenux-format.log 2>/dev/null)
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Format failed")" \
--msgbox "$(hb_translate "Could not format the disk.")\n\n${err}" 16 80
return 1
}
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Formatted and mounted")" \
--msgbox "\Zb$(hb_translate "Mounted at")\ZB \Z4${mounted_at}\Zn" 8 70
echo "$mounted_at"
return 0
;;
esac
return 1
}
hb_prompt_dest_dir() {
@@ -1451,8 +2155,13 @@ hb_show_compat_report() {
title="$(hb_translate "Compatibility check — OK")"
fi
dialog --backtitle "ProxMenux" --title "$title" \
--textbox "$tmpfile" 22 86 || true
# Only nag the operator when there's something to read. An all-PASS
# report is pure noise on the path to a restore they already
# confirmed they want.
if (( warn > 0 || fail > 0 )); then
dialog --backtitle "ProxMenux" --title "$title" \
--textbox "$tmpfile" 22 86 || true
fi
rm -f "$tmpfile"
# FAIL means at least one check is a real risk for system integrity

View File

@@ -17,7 +17,6 @@
# Configuration ============================================
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
BACKUP_DIR="/var/backups/proxmenux"
if [[ -f "$UTILS_FILE" ]]; then

View File

@@ -7,7 +7,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
if [[ -f "$UTILS_FILE" ]]; then

View File

@@ -5,7 +5,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
if [[ -f "$UTILS_FILE" ]]; then

View File

@@ -7,7 +7,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
if [[ -f "$UTILS_FILE" ]]; then

View File

@@ -7,7 +7,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
APT_ENV="env DEBIAN_FRONTEND=noninteractive LC_ALL=C LANG=C"

View File

@@ -46,7 +46,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"

View File

@@ -32,7 +32,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"

View File

@@ -14,7 +14,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"

View File

@@ -18,7 +18,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"

View File

@@ -40,13 +40,11 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
CONFIG_FILE="$BASE_DIR/config.json"
CACHE_FILE="$BASE_DIR/cache.json"
UTILS_FILE="$BASE_DIR/utils.sh"
LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
BETA_VERSION_FILE="$BASE_DIR/beta_version.txt"
INSTALL_DIR="/usr/local/bin"
MENU_SCRIPT="menu"
VENV_PATH="/opt/googletrans-env"
BACKTITLE="ProxMenux Configuration"
REPO_MAIN="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
@@ -114,27 +112,10 @@ uninstall_proxmenux_monitor() {
}
detect_installation_type() {
local has_venv=false
local has_language=false
# Check if virtual environment exists
if [ -d "$VENV_PATH" ] && [ -f "$VENV_PATH/bin/activate" ]; then
has_venv=true
fi
# Check if language is configured
if [ -f "$CONFIG_FILE" ]; then
local current_language=$(jq -r '.language // empty' "$CONFIG_FILE" 2>/dev/null)
if [[ -n "$current_language" && "$current_language" != "null" && "$current_language" != "empty" ]]; then
has_language=true
fi
fi
if [ "$has_venv" = true ] && [ "$has_language" = true ]; then
echo "translation"
else
echo "normal"
fi
# The Translation/Normal split is gone after the googletrans removal.
# All installs are multilingual via pre-built lang/*.json. Keeping the
# function name + a fixed value so callers don't have to change.
echo "normal"
}
check_monitor_status() {
@@ -745,23 +726,16 @@ show_version_info() {
[ -f "$CONFIG_FILE" ] && info_message+="✓ config.json → $CONFIG_FILE\n" || info_message+="✗ config.json\n"
[ -f "$LOCAL_VERSION_FILE" ] && info_message+="✓ version.txt → $LOCAL_VERSION_FILE\n" || info_message+="✗ version.txt\n"
# Show translation-specific files
if [ "$install_type" = "translation" ]; then
[ -f "$CACHE_FILE" ] && info_message+="✓ cache.json → $CACHE_FILE\n" || info_message+="✗ cache.json\n"
info_message+="\n$(translate "Virtual Environment:")\n"
if [ -d "$VENV_PATH" ] && [ -f "$VENV_PATH/bin/activate" ]; then
info_message+="$(translate "Installed")$VENV_PATH\n"
[ -f "$VENV_PATH/bin/pip" ] && info_message+=" pip: $(translate "Installed") $VENV_PATH/bin/pip\n" || info_message+="✗ pip: $(translate "Not installed")\n"
else
info_message+="$(translate "Virtual Environment"): $(translate "Not installed")\n"
info_message+="✗ pip: $(translate "Not installed")\n"
fi
current_language=$(jq -r '.language // "en"' "$CONFIG_FILE")
info_message+="\n$(translate "Current language:")\n$current_language\n"
# Language section: always present now that translations are static
# JSON lookups. Show the configured language and the lang/ directory
# so the operator can verify the cache is in place.
current_language=$(jq -r '.language // "en"' "$CONFIG_FILE" 2>/dev/null)
[[ -z "$current_language" || "$current_language" == "null" ]] && current_language="en"
info_message+="\n$(translate "Current language:")\n${current_language}\n"
if [ -d "$BASE_DIR/lang" ]; then
info_message+="$(translate "Translation files:") $BASE_DIR/lang/\n"
else
info_message+="\n$(translate "Language:")\nEnglish (Fixed)\n"
info_message+="$(translate "Translation files:") $(translate "missing")\n"
fi
# Display information in a scrollable text box
@@ -785,40 +759,26 @@ uninstall_proxmenu() {
fi
local deps_to_remove=""
# Show different dependency options based on installation type
if [ "$install_type" = "translation" ]; then
deps_to_remove=$(dialog --clear --backtitle "ProxMenux Configuration" \
--title "Remove Dependencies" \
--checklist "Select dependencies to remove:" 15 60 4 \
"python3-venv" "Python virtual environment" OFF \
"python3-pip" "Python package installer" OFF \
"python3" "Python interpreter" OFF \
"jq" "JSON processor" OFF \
3>&1 1>&2 2>&3)
else
deps_to_remove=$(dialog --clear --backtitle "ProxMenux Configuration" \
--title "Remove Dependencies" \
--checklist "Select dependencies to remove:" 12 60 2 \
"dialog" "Interactive dialog boxes" OFF \
"jq" "JSON processor" OFF \
3>&1 1>&2 2>&3)
fi
deps_to_remove=$(dialog --clear --backtitle "ProxMenux Configuration" \
--title "Remove Dependencies" \
--checklist "Select dependencies to remove:" 12 60 2 \
"dialog" "Interactive dialog boxes" OFF \
"jq" "JSON processor" OFF \
3>&1 1>&2 2>&3)
# Perform uninstallation with progress bar
(
echo "10" ; echo "Removing ProxMenu files..."
sleep 1
# Remove googletrans and virtual environment if exists
if [ -f "$VENV_PATH/bin/activate" ]; then
echo "30" ; echo "Removing googletrans and virtual environment..."
source "$VENV_PATH/bin/activate"
pip uninstall -y googletrans >/dev/null 2>&1
deactivate
rm -rf "$VENV_PATH"
# Purge the legacy googletrans virtualenv if it was left over from
# a pre-static-translations install. Cheap idempotent check.
if [ -d "/opt/googletrans-env" ]; then
echo "30" ; echo "Removing legacy googletrans virtualenv..."
rm -rf "/opt/googletrans-env"
fi
echo "50" ; echo "Removing ProxMenu files..."
rm -f "$INSTALL_DIR/$MENU_SCRIPT"
rm -rf "$BASE_DIR"

View File

@@ -39,7 +39,6 @@ MENU_REPO="$LOCAL_SCRIPTS/menus"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
[[ ! -f "$UTILS_FILE" ]] && UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
# Source utilities and required scripts
if [[ -f "$UTILS_FILE" ]]; then

View File

@@ -14,7 +14,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if ! command -v dialog &>/dev/null; then
@@ -23,47 +22,11 @@ if ! command -v dialog &>/dev/null; then
fi
check_pve9_translation_compatibility() {
local pve_version
if command -v pveversion &>/dev/null; then
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
else
return 0
fi
if [[ -n "$pve_version" ]] && [[ "$pve_version" -ge 9 ]] && [[ -d "$VENV_PATH" ]]; then
local has_googletrans=false
local has_cache=false
if [[ -f "$VENV_PATH/bin/pip" ]]; then
if "$VENV_PATH/bin/pip" list 2>/dev/null | grep -q "googletrans"; then
has_googletrans=true
fi
fi
if [[ -f "$BASE_DIR/cache.json" ]]; then
has_cache=true
fi
if [[ "$has_googletrans" = true ]] || [[ "$has_cache" = true ]]; then
dialog --clear \
--backtitle "ProxMenux - Compatibility Required" \
--title "Translation Environment Incompatible with PVE $pve_version" \
--msgbox "NOTICE: You are running Proxmox VE $pve_version with translation components installed.\n\nTranslations are NOT supported in PVE 9+. This causes:\n• Menu loading errors\n• Translation failures\n• System instability\n\nREQUIRED ACTION:\nProxMenux will now automatically reinstall the Normal Version.\n\nThis process will:\n• Remove incompatible translation components\n• Install PVE 9+ compatible version\n• Preserve all your settings and preferences\n\nPress OK to continue with automatic reinstallation..." 20 75
bash "$BASE_DIR/install_proxmenux.sh"
fi
exit 0
fi
}
check_pve9_translation_compatibility
# ==========================================================
# The legacy "PVE9 + googletrans incompatible" gate that used to live
# here has been removed along with the googletrans runtime. Translations
# are now a static lookup against $BASE_DIR/lang/<lang>.json — there is
# no runtime venv to be incompatible with any PVE version.
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"

View File

@@ -38,16 +38,11 @@ LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
INSTALL_DIR="/usr/local/bin"
BASE_DIR="/usr/local/share/proxmenux"
CONFIG_FILE="$BASE_DIR/config.json"
CACHE_FILE="$BASE_DIR/cache.json"
LANG_DIR="$BASE_DIR/lang"
LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
MENU_SCRIPT="menu"
VENV_PATH="/opt/googletrans-env"
COMPONENTS_STATUS_FILE="$BASE_DIR/components_status.json"
# Translation context
TRANSLATION_CONTEXT="Context: Technical message for Proxmox and IT. Translate:"
# Color and style definitions
NEON_PURPLE_BLUE="\033[38;5;99m"
WHITE="\033[38;5;15m"
@@ -216,9 +211,10 @@ msg_error() {
# Initialize cache
initialize_cache() {
if [[ "$LANGUAGE" != "en" ]]; then
if [ ! -f "$CACHE_FILE" ]; then
mkdir -p "$(dirname "$CACHE_FILE")"
echo "{}" > "$CACHE_FILE"
mkdir -p "$LANG_DIR"
local lang_file="$LANG_DIR/${LANGUAGE}.json"
if [ ! -f "$lang_file" ]; then
echo "{}" > "$lang_file"
fi
fi
}
@@ -241,78 +237,37 @@ load_language() {
translate() {
# Pre-generated lookup ONLY. Translations live in
# $LANG_DIR/<dest>.json
# built by .github/scripts/build_translation_cache.py during CI on
# every push that touches scripts/. There is intentionally no live
# fallback: removing the runtime googletrans dependency lets us
# ship a smaller AppImage, skip /opt/googletrans-env, and stop
# leaking translation traffic from the host at runtime.
#
# Behavior:
# - dest == "en" → echo the original text
# - lookup hit → echo the translation
# - lookup miss → echo the original text (English fallback)
local text="$1"
local dest_lang="$LANGUAGE"
if [ "$dest_lang" = "en" ]; then
echo "$text"
return
fi
if [ ! -s "$CACHE_FILE" ] || ! jq -e . "$CACHE_FILE" > /dev/null 2>&1; then
echo "{}" > "$CACHE_FILE"
fi
local cached_translation=$(jq -r --arg text "$text" --arg lang "$dest_lang" '.[$text][$lang] // .[$text]["notranslate"] // empty' "$CACHE_FILE")
if [ -n "$cached_translation" ]; then
echo "$cached_translation"
return
fi
if [ ! -d "$VENV_PATH" ]; then
echo "$text"
return
fi
source "$VENV_PATH/bin/activate"
local translated
translated=$(python3 -c "
from googletrans import Translator
import sys, json, re
def translate_text(text, dest_lang, context):
translator = Translator()
try:
full_text = context + ' ' + text
result = translator.translate(full_text, dest=dest_lang).text
translated = re.sub(r'^.*?(Translate:|Traducir:|Traduire:|Übersetzen:|Tradurre:|Traduzir:|翻译:|翻訳:)', '', result, flags=re.IGNORECASE | re.DOTALL).strip()
translated = re.sub(r'^.*?(Context:|Contexto:|Contexte:|Kontext:|Contesto:|上下文:|コンテキスト:).*?:', '', translated, flags=re.IGNORECASE | re.DOTALL).strip()
print(json.dumps({'success': True, 'text': translated}))
except Exception as e:
print(json.dumps({'success': False, 'error': str(e)}))
translate_text(
json.loads(sys.argv[1]),
sys.argv[2],
json.loads(sys.argv[3])
)
" "$(jq -Rn --arg t "$text" '$t')" "$dest_lang" "$(jq -Rn --arg ctx "$TRANSLATION_CONTEXT" '$ctx')")
deactivate
local translation_result=$(echo "$translated" | jq -r '.')
local success=$(echo "$translation_result" | jq -r '.success')
if [ "$success" = "true" ]; then
translated=$(echo "$translation_result" | jq -r '.text')
# Additional cleaning step
translated=$(echo "$translated" | sed -E 's/^(Context:|Contexto:|Contexte:|Kontext:|Contesto:|上下文:|コンテキスト:).*?(Translate:|Traducir:|Traduire:|Übersetzen:|Tradurre:|Traduzir:|翻译:|翻訳:)//gI' | sed 's/^ *//; s/ *$//')
# Only cache if the language is not English
if [ "$dest_lang" != "en" ]; then
local temp_cache=$(mktemp)
jq --arg text "$text" --arg lang "$dest_lang" --arg translated "$translated" '
if .[$text] == null then .[$text] = {} else . end |
.[$text][$lang] = $translated
' "$CACHE_FILE" > "$temp_cache" && mv "$temp_cache" "$CACHE_FILE"
local lang_file="$LANG_DIR/${dest_lang}.json"
if [ -s "$lang_file" ] && command -v jq >/dev/null 2>&1; then
local cached
cached=$(jq -r --arg text "$text" '.[$text] // empty' "$lang_file" 2>/dev/null)
if [ -n "$cached" ]; then
echo "$cached"
return
fi
echo "$translated"
else
local error=$(echo "$translation_result" | jq -r '.error')
echo "$text"
fi
echo "$text"
}
@@ -627,4 +582,4 @@ hybrid_whiptail_msgbox() {
else
whiptail --title "$title" --msgbox "$text" "$height" "$width"
fi
}
}