From 7efe7658542456940ab80e930956046eb76ed52a Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 10:18:31 -0500 Subject: [PATCH 01/76] v2.initial upload --- README.md | 50 ++++++++++++++++++- defaults.inc | 41 +++++++++------ proxmenu-scripts.sh | 119 ++++++++++++++++++++++++-------------------- run.sh | 11 ++-- 4 files changed, 145 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index ad24098..e5dd876 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,49 @@ -# TA-Proxmenu - Proxmox Scripts for TA Use +# TA-ProxMenu - bash <(curl -sL https://go.scity.us/install-tapm) +Interactive installation and configuration helpers used when deploying Proxmox +VE environments. + +## Install + +Run as `root` on a Proxmox VE host: + +```bash +bash <(curl -fsSL https://go.scity.us/install-tapm) +``` + +The installed launcher loads the shared iDSSYS defaults, checks for updates, +and opens the interactive menu. + +## Direct actions + +The menu script also supports these direct actions: + +```text +pulse Install Pulse monitoring +rmm Install the ConnectWise RMM agent +omsa Install Dell OpenManage Server Administrator +glances Install Glances +acronis Install the Acronis agent +proxmenux Install or open ProxMenux +screenconnect Install the ScreenConnect agent +restart Restart local Proxmox services +cpu Detect and optionally apply a migration-safe CPU model +mm Toggle local HA maintenance mode +timeout Set VM shutdown timeouts +``` + +## Companion files + +Large installer artifacts used by this project are stored in the separate +`TAI/files` repository. The SentinelOne package filename and displayed version +are defined together in `defaults.inc`. + +## Runtime requirements + +- Proxmox VE and root privileges +- Bash, Git, curl, wget, and standard Debian package tools +- `/opt/idssys/defaults/default.inc` +- `/opt/idssys/defaults/colors.inc` + +The Keepalived deployment additionally requires a healthy, quorate Proxmox +cluster and passwordless root SSH between cluster nodes. diff --git a/defaults.inc b/defaults.inc index 70652dd..2c768bd 100755 --- a/defaults.inc +++ b/defaults.inc @@ -1,27 +1,39 @@ #!/usr/bin/env bash # TA-Proxmenu - Proxmox Setup Scripts for TA Use -action="$1" +action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-4' +VERS='2026.7.25-5' noupdate=' ' - -RNIP=$(ip addr show $(ip route | awk '/default/ { print $5 }') | grep "inet" | head -n 1 | awk '/inet/ {print $2}' | cut -d'/' -f1) - - -download_url="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso" -if curl -m 3 -s --head --request GET ${download_url} | grep "HTTP/2 200" > /dev/null; then - while redirect_url=$( curl -I -s -S -f -w "%{redirect_url}\\n" -o /dev/null "${download_url}" ); do - VIRTIO_DOWNLOAD_URL=${download_url}; url=${redirect_url}; [[ -z "$url" ]] && break - done +default_interface="$(ip route 2>/dev/null | awk '/^default/ { print $5; exit }')" +if [[ -n "$default_interface" ]]; then + RNIP="$(ip -4 -o addr show dev "$default_interface" scope global 2>/dev/null | awk '{ sub(/\\/.*/, "", $4); print $4; exit }')" else - VIRTIO_DOWNLOAD_URL="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/archive-virtio/virtio-win-0.1.285-1/virtio-win-0.1.285.iso" + RNIP="" fi -VIRTIO_FILE=${VIRTIO_DOWNLOAD_URL##*/} -[ -d /mnt/pve/PVE-Shared-Storage/template/iso ] && DLDIR=/mnt/pve/PVE-Shared-Storage/template/iso || DLDIR=/var/lib/vz/template/iso +VIRTIO_STABLE_URL="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso" +VIRTIO_FALLBACK_URL="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/archive-virtio/virtio-win-0.1.285-1/virtio-win-0.1.285.iso" +VIRTIO_DOWNLOAD_URL="$(curl --location --fail --silent --show-error --head \ + --connect-timeout 3 --max-time 10 --output /dev/null \ + --write-out '%{url_effective}' "$VIRTIO_STABLE_URL" 2>/dev/null)" + +if [[ -z "$VIRTIO_DOWNLOAD_URL" ]]; then + VIRTIO_DOWNLOAD_URL="$VIRTIO_FALLBACK_URL" +fi +VIRTIO_FILE="${VIRTIO_DOWNLOAD_URL##*/}" + +S1_VERSION='26_1_1_31' +S1_PACKAGE="SentinelAgent_linux_x86_64_v${S1_VERSION}.deb" +S1_DOWNLOAD_URL="https://git.scity.us/TAI/files/raw/branch/main/${S1_PACKAGE}" + +if [[ -d /mnt/pve/PVE-Shared-Storage/template/iso ]]; then + DLDIR='/mnt/pve/PVE-Shared-Storage/template/iso' +else + DLDIR='/var/lib/vz/template/iso' +fi @@ -32,4 +44,3 @@ VIRTIO_FILE=${VIRTIO_DOWNLOAD_URL##*/} # apt-get update # fi - diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index d8bfca4..850c682 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -2,16 +2,24 @@ # TA-Proxmenu - Proxmox Setup Scripts for TA Use -[ "${2}" != "q" ] && source /opt/idssys/defaults/colors.inc +[ "${2:-}" != "q" ] && source /opt/idssys/defaults/colors.inc source /opt/idssys/defaults/default.inc source /opt/idssys/ta-proxmenu/defaults.inc +ACTION_REQUESTED=0 +[[ -n "${action:-}" ]] && ACTION_REQUESTED=1 + +FINISH_ACTION() { + (( ACTION_REQUESTED == 1 )) && exit 0 + ENTER2CONTINUE +} + INSTALL_PULSE() { echo bash <(curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh) echo echo -e "\n${idsCL[Green]}Pulse has been installed${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE + FINISH_ACTION } INSTALL_ACRONIS() { @@ -20,14 +28,14 @@ INSTALL_ACRONIS() { [Nn]) MAIN_MENU;; * ) echo - cd /tmp + cd /tmp || return 1 wget "https://us5-cloud.acronis.com/bc/api/ams/links/agents/redirect?language=multi&channel=CURRENT&system=linux&architecture=64&productType=enterprise&login=010180ae-63c4-4495-bed0-4ec934c25af9&white_labeled=0" -O ./acronisinstall chmod +x ./acronisinstall ./acronisinstall rm -f ./acronisinstall echo echo -e "\n${idsCL[Green]}Acronis has been installed${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE + FINISH_ACTION ;; esac } @@ -42,14 +50,13 @@ INSTALL_PROXMENUX() { # systemctl disable --now proxmenux-monitor menu # echo -e "\n${idsCL[Green]}ProxMenux has been installed${idsCL[Default]}" -# [ ${action-x} ] && exit 0 || ENTER2CONTINUE # esac } PROXMENUX_POST_INSTALL() { PMFLDR='/usr/local/share/proxmenux/scripts/post_install' - [ ! -f ${PMFLDR}/customizable_post_install.sh ] && INSTALL_PROXMENUX - bash ${PMFLDR}/customizable_post_install.sh + [ ! -f "${PMFLDR}/customizable_post_install.sh" ] && INSTALL_PROXMENUX + bash "${PMFLDR}/customizable_post_install.sh" touch /opt/.PROXMENUX_POST_INSTALL [ -s /etc/apt/sources.list ] && cat /dev/null > /etc/apt/sources.list @@ -64,7 +71,7 @@ INSTALL_GLANCES() { echo apt install glances -y echo -e "\n${idsCL[Green]}Glances has been installed${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE + FINISH_ACTION esac } @@ -75,7 +82,8 @@ INSTALL_SCREENCONNECT() { * ) echo echo -en "\n${idsCL[LightYellow]}Paste the URL provided from the Build Installer: ${idsCL[Default]}" - read -e SCURL + read -r -e SCURL + [[ -n "$SCURL" ]] || { echo "No URL supplied."; FINISH_ACTION; return; } wget "${SCURL}" -O /tmp/scinstall dpkg -i /tmp/scinstall apt install --fix-broken -y @@ -84,7 +92,7 @@ INSTALL_SCREENCONNECT() { rm -f /tmp/scinstall systemctl disable --now proxmenux-monitor echo -e "\n${idsCL[Green]}ScreenConnect has been installed${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE + FINISH_ACTION esac } @@ -96,32 +104,41 @@ INSTALL_RMM() { echo echo -en "\n${idsCL[LightYellow]}Paste the Linux Server URL provided from the Download Agent screen: ${idsCL[Default]}" - read -e RMMURL + read -r -e RMMURL + [[ -n "$RMMURL" ]] || { echo "No URL supplied."; FINISH_ACTION; return; } wget "${RMMURL}" -O /tmp/rmminstall - TOKEN="$(echo ${RMMURL} | awk -F 'TKN' '{print $2}' | awk -F '/RUN' '{print $1}')" - CMD="TOKEN=${TOKEN} bash /tmp/rmminstall" - eval ${CMD} + if [[ "$RMMURL" != *TKN* || "$RMMURL" != */RUN* ]]; then + echo "Unable to extract the RMM token from the URL." + FINISH_ACTION + return + fi + TOKEN="${RMMURL#*TKN}" + TOKEN="${TOKEN%%/RUN*}" + [[ -n "$TOKEN" ]] || { echo "The RMM token is empty."; FINISH_ACTION; return; } + TOKEN="$TOKEN" bash /tmp/rmminstall systemctl restart ITSPlatform # rm -f /tmp/rmminstall echo -e "\n${idsCL[Green]}RMM has been installed${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE + FINISH_ACTION esac } INSTALL_S1() { echo echo -en "${idsCL[LightYellow]}Paste the customers SentinelOne Site Token: ${idsCL[Default]}" - read -e s1token - cd /tmp - wget "https://git.scity.us/TAI/files/raw/branch/main/SentinelAgent_linux_x86_64_v26_1_1_31.deb" - dpkg -i ./SentinelAgent_linux_x86_64*.deb - /opt/sentinelone/bin/sentinelctl management token set ${s1token} + read -r -e s1token + [[ -n "$s1token" ]] || { echo "No SentinelOne site token supplied."; FINISH_ACTION; return; } + cd /tmp || return 1 + rm -f "/tmp/${S1_PACKAGE}" + wget "$S1_DOWNLOAD_URL" -O "/tmp/${S1_PACKAGE}" + dpkg -i "/tmp/${S1_PACKAGE}" + /opt/sentinelone/bin/sentinelctl management token set "$s1token" /opt/sentinelone/bin/sentinelctl control start - rm -f ./SentinelAgent_linux_x86_64*.deb + rm -f "/tmp/${S1_PACKAGE}" echo -e "\n${idsCL[Green]}SentinelOne Agent has been installed. Make sure its added to a \"DETECT ONLY\" policy${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE + FINISH_ACTION } INSTALL_OMSA() { @@ -130,8 +147,8 @@ INSTALL_OMSA() { [Nn]) echo;; * ) echo - mkdir /tmp/omsa - cd /tmp/omsa + mkdir -p /tmp/omsa + cd /tmp/omsa || return 1 apt install -y gnupg libcurl4t64 libncurses6 libxslt1.1 libgpm2 libtinfo6 mkdir -p /etc/apt/keyrings wget -qO - https://linux.dell.com/repo/pgp_pubkeys/0x1285491434D8786F.asc | gpg --dearmor -o /etc/apt/keyrings/linux.dell.com.gpg @@ -165,25 +182,25 @@ INSTALL_OMSA() { dpkg -i libssl1.1_1.1.1w-0+deb11u1_amd64.deb apt install -y srvadmin-all /opt/dell/srvadmin/sbin/srvadmin-services.sh start - rm -Rf /tmp/omsa + rm -rf -- /tmp/omsa echo -e "\n${idsCL[Green]}Dell OMSA has been installed${idsCL[Default]}" echo -e "\n${idsCL[LightCyan]}Available at: ${idsCL[LightGreen]}https://${RNIP}:1311${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE + FINISH_ACTION esac } DOWNLOAD_VIRTIO() { echo -e "\n${idsCL[LightCyan]}Current \"Stable\" version available for download: ${idsCL[White]}${VIRTIO_FILE}${idsCL[Default]}" - if [ -f ${dldir}/${VIRTIO_FILE} ]; then + if [ -f "${DLDIR}/${VIRTIO_FILE}" ]; then echo -en "\n${idsCL[LightRed]}Removing existing download ... " - rm -f ${DLDIR}/${VIRTIO_FILE} + rm -f "${DLDIR}/${VIRTIO_FILE}" echo -e "${idsCL[Red]}Done${idsCL[Default]}" fi - wget -q -F -P ${DLDIR} ${VIRTIO_DOWNLOAD_URL} & + wget -q -P "$DLDIR" "$VIRTIO_DOWNLOAD_URL" & echo -e "\n${idsCL[LightCyan]}Downloading will continue in the background\n" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE + FINISH_ACTION } DETECT_CPU(){ @@ -211,7 +228,7 @@ DETECT_CPU(){ sed -i "/cpu:/c cpu: $(proxclmc --list-only)" /etc/pve/nodes/*/qemu-server/*.conf echo echo -e "\n${idsCL[Green]}All VM's have been reconfigured\n${idsCL[LightCyan]}This will require the VM's to be powered off and then turned back on in order to take effect${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE + FINISH_ACTION ;; *) echo;; esac @@ -233,7 +250,7 @@ RESTART_PVE_SERVICES(){ systemctl restart pve-cluster pvedaemon pvestatd pveproxy echo -e "${idsCL[Green]}Done${idsCL[Default]}" echo -e "\n${idsCL[Green]}This hosts Proxmox services have been restarted${idsCL[Default]}\n" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE + FINISH_ACTION ;; esac } @@ -250,7 +267,7 @@ SET_VM_SHUTDOWNTIMEOUT(){ *) echo "Updating all VM's shutdown timeout to 180 seconds..." sed -E -i 's/(down=)[0-9]+/\1180/g' /etc/pve/nodes/*/qemu-server/*.conf - [ ${action-x} ] && exit 0 || ENTER2CONTINUE + FINISH_ACTION ;; esac } @@ -272,7 +289,7 @@ MAINTENANCE_MODE(){ ha-manager crm-command node-maintenance enable $(hostname) & echo -e "\n${idsCL[Green]}This host will be entered into maintenance mode${idsCL[Default]}\n" fi - [ ${action-x} ] && exit 0 || ENTER2CONTINUE + FINISH_ACTION ;; esac } @@ -284,10 +301,10 @@ INSTALL_KEEPALIVE() { * ) echo - source /opt/idssys/ta-proxmenu/inc/deploy-proxmox-keepalived.sh + bash /opt/idssys/ta-proxmenu/inc/deploy-proxmox-keepalived.sh echo -e "\n${idsCL[Green]}Keepalive has been installed${idsCL[Default]}" - [ ${action-x} ] && exit 0 || ENTER2CONTINUE + FINISH_ACTION esac } @@ -311,16 +328,16 @@ MAIN_MENU() { echo echo -en "${idsCL[White]} [${idsCL[LightYellow]}0${idsCL[Default]}] ${idsCL[White]}Run Post-Install Script${idsCL[Default]}" - [ -f /opt/.PROXMENUX_POST_INSTALL ] && echo -e "${idsCL[Cyan]} - Has been ran prevously${idsCL[Default]}" || echo + [ -f /opt/.PROXMENUX_POST_INSTALL ] && echo -e "${idsCL[Cyan]} - Has been run previously${idsCL[Default]}" || echo echo -e "${idsCL[White]} [${idsCL[LightYellow]}1${idsCL[Default]}] ${idsCL[White]}Detect CPU-Arch for Live Migrations${idsCL[Default]}" - if ! echo ${CRES} | grep -i pulse &> /dev/null ; then + if ! grep -qi pulse <<< "$CRES"; then echo -e "${idsCL[White]} [${idsCL[LightYellow]}2${idsCL[Default]}] ${idsCL[White]}Install Pulse Monitoring${idsCL[Default]}" else echo -e "${idsCL[DarkGray]} [2] Pulse Monitoring is already installed${idsCL[Default]}" fi - if [ -f ${DLDIR}/${VIRTIO_FILE} ]; then + if [ -f "${DLDIR}/${VIRTIO_FILE}" ]; then echo -e "${idsCL[DarkGray]} [3] Current VirtIO drivers already downloaded to 'local' on this host${idsCL[Default]}" - elif [ -f ${DLDIR}/virtio*.iso ]; then + elif compgen -G "${DLDIR}/virtio*.iso" > /dev/null; then echo -e "${idsCL[White]} [${idsCL[LightYellow]}3${idsCL[Default]}] ${idsCL[LightGreen]}**${idsCL[White]}Download the available updated Win-VirtIO drivers to 'local' on this host${idsCL[Default]}" else echo -e "${idsCL[White]} [${idsCL[LightYellow]}3${idsCL[Default]}] ${idsCL[White]}Download the current Win-VirtIO drivers to 'local' on this host${idsCL[Default]}" @@ -330,7 +347,7 @@ MAIN_MENU() { else echo -e "${idsCL[DarkGray]} [4] Glances is already installed${idsCL[Default]}" fi - if [ "$(echo ${DPL} | awk '/srvadmin-all/ {print }'|wc -l)" -eq 0 ]; then + if ! grep -q 'srvadmin-all' <<< "$DPL"; then echo -e "${idsCL[White]} [${idsCL[LightYellow]}5${idsCL[Default]}] ${idsCL[White]}Install Dell OpenManage Server Administrator${idsCL[Default]}" else echo -e "${idsCL[DarkGray]} [5] Dell OMSA is already installed - ${idsCL[Cyan]}https://${RNIP}:1311" @@ -341,13 +358,13 @@ MAIN_MENU() { else echo -e "${idsCL[DarkGray]} [6] ConnectWise RMM Agent is already installed${idsCL[Default]}" fi - if [ "$(echo ${DPL} | awk '/cyberprotect/ {print }'|wc -l)" -eq 0 ]; then + if ! grep -q 'cyberprotect' <<< "$DPL"; then echo -e "${idsCL[White]} [${idsCL[LightYellow]}7${idsCL[Default]}] ${idsCL[White]}Install Acronis Backup Agent${idsCL[Default]}" else echo -e "${idsCL[DarkGray]} [7] Acronis Backup is already installed${idsCL[Default]}" fi - if [ "$(echo ${DPL} | awk '/sentinelagent/ {print }'|wc -l)" -eq 0 ]; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}8${idsCL[Default]}] ${idsCL[White]}Install SentinelOne Agent (v25_4_2_21)${idsCL[Default]}" + if ! grep -q 'sentinelagent' <<< "$DPL"; then + echo -e "${idsCL[White]} [${idsCL[LightYellow]}8${idsCL[Default]}] ${idsCL[White]}Install SentinelOne Agent (v${S1_VERSION//_/.})${idsCL[Default]}" else echo -e "${idsCL[DarkGray]} [8] SentinelOne is already installed${idsCL[Default]}" fi @@ -389,14 +406,14 @@ MAIN_MENU() { [Rr]) RESTART_PVE_SERVICES;; [Kk]) INSTALL_KEEPALIVE;; [Qq]) EXIT1; exit 0;; - *) echo -e "Thats an invaild option,\nplease select a valid option only."; sleep 1;; + *) echo -e "That's an invalid option.\nPlease select a valid option."; sleep 1;; esac done } -if [ ${action-x} ]; then - case $action in +if (( ACTION_REQUESTED == 1 )); then + case "$action" in pulse) INSTALL_PULSE;; rmm) INSTALL_RMM;; omsa) INSTALL_OMSA;; @@ -404,10 +421,10 @@ if [ ${action-x} ]; then acronis) INSTALL_ACRONIS;; proxmenux) [ ! -f /usr/local/bin/menu ] && INSTALL_PROXMENUX || /usr/local/bin/menu;; screenconnect) INSTALL_SCREENCONNECT;; - restart) RESTART_PVE_SERVICES ${2};; + restart) RESTART_PVE_SERVICES "${2:-}";; cpu) DETECT_CPU;; mm) MAINTENANCE_MODE;; - timeout) SET_VM_SHUTDOWNTIMEOUT ${2};; + timeout) SET_VM_SHUTDOWNTIMEOUT "${2:-}";; *) MAIN_MENU;; esac else @@ -416,7 +433,3 @@ fi exit 0 - - - - diff --git a/run.sh b/run.sh index d61597d..1a884b2 100755 --- a/run.sh +++ b/run.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # TA-Proxmenu preloader -[ "${2}" != "q" ] && source /opt/idssys/defaults/colors.inc +[ "${2:-}" != "q" ] && source /opt/idssys/defaults/colors.inc source /opt/idssys/defaults/default.inc source /opt/idssys/ta-proxmenu/defaults.inc -if [[ "${noupdate}" != *" ${1} "* ]] && [[ "${noupdate}" != *" ${2} "* ]]; then +if [[ "${noupdate}" != *" ${1:-} "* ]] && [[ "${noupdate}" != *" ${2:-} "* ]]; then if curl -m 3 -s --head --request GET https://git.scity.us | grep "HTTP/2 200" > /dev/null; then if [ "${1}" != "tapm" ]; then echo -en "${idsCL[LightCyan]}Checking for updates...${idsCL[Default]}" @@ -18,7 +18,7 @@ if [[ "${noupdate}" != *" ${1} "* ]] && [[ "${noupdate}" != *" ${2} "* ]]; then git clone https://git.scity.us/voltron/iDS-Defaults.git /opt/idssys/defaults else cd /opt/idssys/defaults - if [ "`git log --pretty=%H ...refs/heads/master^ | head -n 1`" != "`git ls-remote origin -h refs/heads/master |cut -f1`" ]; then + if [ "$(git rev-parse HEAD)" != "$(git ls-remote origin refs/heads/master | cut -f1)" ]; then if [ "${1}" != "tapm" ]; then echo -en "\e[1A"; echo -en "\e[0K\r${idsCL[LightCyan]}Updating iDSSYS-Defaults...${idsCL[Default]}" @@ -36,7 +36,7 @@ if [[ "${noupdate}" != *" ${1} "* ]] && [[ "${noupdate}" != *" ${2} "* ]]; then fi cd /opt/idssys/ta-proxmenu - if [ "`git log --pretty=%H ...refs/heads/main^ | head -n 1`" != "`git ls-remote origin -h refs/heads/main |cut -f1`" ]; then + if [ "$(git rev-parse HEAD)" != "$(git ls-remote origin refs/heads/main | cut -f1)" ]; then if [ "${1}" != "tapm" ]; then [ ${udtd} -eq 0 ] && echo -en "\e[1A"; echo -en "\e[0K\r${idsCL[LightCyan]}Updating TA-Proxmenu...${idsCL[Default]}" @@ -64,8 +64,7 @@ if [[ "${noupdate}" != *" ${1} "* ]] && [[ "${noupdate}" != *" ${2} "* ]]; then fi if [ "${1}" != "tapm" ] && [ "${1}" != "update" ] && [ "${1}" != "u" ]; then - /opt/idssys/ta-proxmenu/proxmenu-scripts.sh $1 $2 $3 $4 + /opt/idssys/ta-proxmenu/proxmenu-scripts.sh "${1:-}" "${2:-}" "${3:-}" "${4:-}" fi exit 0 - From 89ceeaa3248cdbadd984b44ca472b346ae5052d2 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 10:37:16 -0500 Subject: [PATCH 02/76] Update defaults.inc --- defaults.inc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/defaults.inc b/defaults.inc index 2c768bd..2ba23b8 100755 --- a/defaults.inc +++ b/defaults.inc @@ -9,7 +9,7 @@ noupdate=' ' default_interface="$(ip route 2>/dev/null | awk '/^default/ { print $5; exit }')" if [[ -n "$default_interface" ]]; then - RNIP="$(ip -4 -o addr show dev "$default_interface" scope global 2>/dev/null | awk '{ sub(/\\/.*/, "", $4); print $4; exit }')" + RNIP="$(ip -4 -o addr show dev "$default_interface" scope global 2>/dev/null | awk '{ sub(/\/.*/, "", $4); print $4; exit }')" else RNIP="" fi @@ -43,4 +43,3 @@ fi # wget -O /etc/apt/trusted.gpg.d/proxlb.asc https://repo.gyptazy.com/repository.gpg # apt-get update # fi - From 5cb4bf2902d1f5d0e2a6605f96ae66e375652615 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 10:40:14 -0500 Subject: [PATCH 03/76] Update run.sh --- run.sh | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/run.sh b/run.sh index 1a884b2..bce38d6 100755 --- a/run.sh +++ b/run.sh @@ -35,18 +35,27 @@ if [[ "${noupdate}" != *" ${1:-} "* ]] && [[ "${noupdate}" != *" ${2:-} "* ]]; t fi fi - cd /opt/idssys/ta-proxmenu - if [ "$(git rev-parse HEAD)" != "$(git ls-remote origin refs/heads/main | cut -f1)" ]; then + cd /opt/idssys/ta-proxmenu + current_branch="$(git branch --show-current)" + remote_commit="" + if [ -n "$current_branch" ]; then + remote_commit="$(git ls-remote origin "refs/heads/${current_branch}" | cut -f1)" + fi + + if [ -z "$current_branch" ]; then + echo -e "${idsCL[Red]}TA-Proxmenu is in a detached HEAD state; automatic updates were skipped${idsCL[Default]}" + elif [ -z "$remote_commit" ]; then + echo -e "${idsCL[Red]}Could not find branch '${current_branch}' on the TA-Proxmenu origin; automatic updates were skipped${idsCL[Default]}" + elif [ "$(git rev-parse HEAD)" != "$remote_commit" ]; then if [ "${1}" != "tapm" ]; then [ ${udtd} -eq 0 ] && echo -en "\e[1A"; - echo -en "\e[0K\r${idsCL[LightCyan]}Updating TA-Proxmenu...${idsCL[Default]}" + echo -en "\e[0K\r${idsCL[LightCyan]}Updating TA-Proxmenu (${current_branch})...${idsCL[Default]}" fi - git fetch origin main >/dev/null 2>&1 - git reset --hard origin/main >/dev/null 2>&1 + git fetch origin "$current_branch" >/dev/null 2>&1 + git reset --hard "origin/${current_branch}" >/dev/null 2>&1 git reflog expire --expire=now --all >/dev/null 2>&1 git repack -ad >/dev/null 2>&1 git prune >/dev/null 2>&1 - git pull >/dev/null 2>&1 if [ "${1}" != "tapm" ]; then source /opt/idssys/ta-proxmenu/defaults.inc From f06f3d88e03724ee963d700f76e0311f007f8518 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 10:41:00 -0500 Subject: [PATCH 04/76] Update defaults.inc --- defaults.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/defaults.inc b/defaults.inc index 2ba23b8..575796f 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-5' +VERS='2026.7.25-10' noupdate=' ' From f13ce07397cf1a83e40dc0824956d16a6a11ced5 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 10:49:24 -0500 Subject: [PATCH 05/76] update --- defaults.inc | 2 +- proxmenu-scripts.sh | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/defaults.inc b/defaults.inc index 575796f..6e4040c 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-10' +VERS='2026.7.25-11' noupdate=' ' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 850c682..1afe5f9 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -382,7 +382,12 @@ MAIN_MENU() { fi echo -e "${idsCL[White]} [${idsCL[LightYellow]}R${idsCL[Default]}] ${idsCL[White]}Restart Proxmox Services${idsCL[Default]}" echo - echo -e "${idsCL[White]} [${idsCL[LightYellow]}K${idsCL[Default]}] ${idsCL[White]}Install Keepalive on all Hosts${idsCL[Default]}" + if ! grep -q 'keepalived' <<< "$DPL"; then + echo -e "${idsCL[White]} [${idsCL[LightYellow]}K${idsCL[Default]}] ${idsCL[White]}Install Keepalive on all Hosts${idsCL[Default]}" + else + echo -e "${idsCL[DarkGray]} [K] Keepalive is already installed${idsCL[Default]}" + fi + echo echo -e "${idsCL[White]} [${idsCL[LightYellow]}Q${idsCL[Default]}] ${idsCL[White]}Quit${idsCL[Default]}" echo From dc48f1a8afedd645792c7bdee64e2e47b17122c9 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 11:07:14 -0500 Subject: [PATCH 06/76] setup new maintenance mode process --- defaults.inc | 2 +- inc/evacuate-proxmox-node.sh | 371 +++++++++++++++++++++++++++++++++++ proxmenu-scripts.sh | 15 +- 3 files changed, 381 insertions(+), 7 deletions(-) create mode 100755 inc/evacuate-proxmox-node.sh diff --git a/defaults.inc b/defaults.inc index 6e4040c..cf839e4 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-11' +VERS='2026.7.25-12' noupdate=' ' diff --git a/inc/evacuate-proxmox-node.sh b/inc/evacuate-proxmox-node.sh new file mode 100755 index 0000000..516dfba --- /dev/null +++ b/inc/evacuate-proxmox-node.sh @@ -0,0 +1,371 @@ +#!/usr/bin/env bash +set -u + +# Evacuate non-HA guests after the local Proxmox node enters HA maintenance. +# Guests using shared storage are migrated to one operator-selected node. +# Guests with local storage remain on this node and are gracefully shut down. + +HA_WAIT_SECONDS=300 +SHUTDOWN_TIMEOUT=180 +LOCAL_NODE="$(hostname -s)" + +log() { + printf '\n[%s] %s\n' "$(date '+%F %T')" "$*" +} + +warn() { + printf '\nWARNING: %s\n' "$*" >&2 +} + +die() { + printf '\nERROR: %s\n' "$*" >&2 + exit 1 +} + +command -v pvesh >/dev/null 2>&1 || die "pvesh is required." +command -v ha-manager >/dev/null 2>&1 || die "ha-manager is required." +command -v python3 >/dev/null 2>&1 || die "python3 is required." + +pvesh get /cluster/resources --type vm --output-format json >/dev/null 2>&1 || + die "Could not read cluster guest resources." +pvesh get /cluster/ha/resources --output-format json >/dev/null 2>&1 || + die "Could not read HA resources." +pvesh get /nodes --output-format json >/dev/null 2>&1 || + die "Could not read cluster node status." +pvesh get /storage --output-format json >/dev/null 2>&1 || + die "Could not read cluster storage configuration." + +get_local_guests() { + pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | + python3 -c ' +import json +import sys + +node = sys.argv[1] +for guest in json.load(sys.stdin): + if guest.get("node") != node or guest.get("type") not in ("qemu", "lxc"): + continue + print( + guest.get("vmid", ""), + guest.get("type", ""), + guest.get("status", "unknown"), + str(guest.get("name", "")).replace("\x1f", " "), + sep="\x1f", + ) +' "$LOCAL_NODE" +} + +get_ha_guest_ids() { + pvesh get /cluster/ha/resources --output-format json 2>/dev/null | + python3 -c ' +import json +import re +import sys + +for resource in json.load(sys.stdin): + sid = str(resource.get("sid") or resource.get("service") or "") + match = re.fullmatch(r"(?:vm|ct):(\d+)", sid) + if match: + print(match.group(1)) +' +} + +get_online_nodes() { + pvesh get /nodes --output-format json 2>/dev/null | + python3 -c ' +import json +import sys + +local_node = sys.argv[1] +for node in json.load(sys.stdin): + name = str(node.get("node", "")) + if name and name != local_node and node.get("status") == "online": + print(name) +' "$LOCAL_NODE" +} + +get_shared_storages() { + pvesh get /storage --output-format json 2>/dev/null | + python3 -c ' +import json +import sys + +for storage in json.load(sys.stdin): + if storage.get("shared"): + print(storage.get("storage", "")) +' +} + +guest_storage_scope() { + local guest_type="$1" + local vmid="$2" + local shared_csv="$3" + + pvesh get "/nodes/${LOCAL_NODE}/${guest_type}/${vmid}/config" \ + --output-format json 2>/dev/null | + python3 -c ' +import json +import re +import sys + +guest_type, shared_csv = sys.argv[1:3] +shared = {item for item in shared_csv.split(",") if item} +config = json.load(sys.stdin) + +if guest_type == "qemu": + disk_key = re.compile( + r"^(?:ide|sata|scsi|virtio|efidisk|tpmstate|unused)\d+$" + ) +else: + disk_key = re.compile(r"^(?:rootfs|mp\d+|unused\d+)$") + +local_reasons = [] +for key, raw_value in config.items(): + if not disk_key.match(key) or not isinstance(raw_value, str): + continue + + volume = raw_value.split(",", 1)[0] + if volume in ("none", "cdrom") or volume.startswith("none,"): + continue + if volume.startswith("/") or ":" not in volume: + local_reasons.append(f"{key}={volume}") + continue + + storage = volume.split(":", 1)[0] + if storage not in shared: + local_reasons.append(f"{key}={storage}") + +if local_reasons: + print("local\x1f" + ", ".join(local_reasons)) +else: + print("shared\x1f") +' "$guest_type" "$shared_csv" +} + +select_target_node() { + local -a nodes + local choice + local index + + mapfile -t nodes < <(get_online_nodes) + (( ${#nodes[@]} > 0 )) || die "No other online cluster node is available." + + printf '\nAvailable migration targets:\n\n' + for index in "${!nodes[@]}"; do + printf ' %d) %s\n' "$((index + 1))" "${nodes[$index]}" + done + + while true; do + printf '\n' + read -r -p "Select migration target [1-${#nodes[@]}]: " choice + if [[ "$choice" =~ ^[0-9]+$ ]] && + (( choice >= 1 && choice <= ${#nodes[@]} )); then + TARGET_NODE="${nodes[$((choice - 1))]}" + return + fi + printf 'Invalid selection.\n' >&2 + done +} + +wait_for_ha_evacuation() { + local deadline=$((SECONDS + HA_WAIT_SECONDS)) + local -a local_guests + local -a ha_ids + local -a remaining + local guest + local ha_id + + log "Waiting for HA-managed guests to leave ${LOCAL_NODE}." + + while true; do + mapfile -t local_guests < <(get_local_guests) + mapfile -t ha_ids < <(get_ha_guest_ids) + remaining=() + + for guest in "${local_guests[@]}"; do + for ha_id in "${ha_ids[@]}"; do + if [[ "${guest%%$'\x1f'*}" == "$ha_id" ]]; then + remaining+=("$guest") + break + fi + done + done + + (( ${#remaining[@]} == 0 )) && return + + if (( SECONDS >= deadline )); then + warn "HA evacuation did not finish within ${HA_WAIT_SECONDS} seconds." + printf 'HA-managed guests still assigned to %s:\n' "$LOCAL_NODE" >&2 + printf ' %s\n' "${remaining[@]}" >&2 + return 1 + fi + + printf '\r Waiting: %d HA guest(s) remain... ' "${#remaining[@]}" + sleep 5 + done +} + +migrate_guest() { + local vmid="$1" + local guest_type="$2" + local status="$3" + + if [[ "$guest_type" == "qemu" ]]; then + if [[ "$status" == "running" ]]; then + qm migrate "$vmid" "$TARGET_NODE" --online + else + qm migrate "$vmid" "$TARGET_NODE" + fi + else + if [[ "$status" == "running" ]]; then + pct migrate "$vmid" "$TARGET_NODE" --restart 1 \ + --timeout "$SHUTDOWN_TIMEOUT" + else + pct migrate "$vmid" "$TARGET_NODE" + fi + fi +} + +shutdown_guest() { + local vmid="$1" + local guest_type="$2" + + if [[ "$guest_type" == "qemu" ]]; then + qm shutdown "$vmid" --timeout "$SHUTDOWN_TIMEOUT" + else + pct shutdown "$vmid" --timeout "$SHUTDOWN_TIMEOUT" + fi +} + +guest_status() { + local vmid="$1" + local guest_type="$2" + + if [[ "$guest_type" == "qemu" ]]; then + qm status "$vmid" 2>/dev/null | awk '{ print $2 }' + else + pct status "$vmid" 2>/dev/null | awk '{ print $2 }' + fi +} + +if ! ha-manager status | + grep -F "$LOCAL_NODE" | + grep -q "maintenance mode"; then + die "${LOCAL_NODE} is not in HA maintenance mode." +fi + +wait_for_ha_evacuation || + die "Resolve the remaining HA guests before continuing the evacuation." + +select_target_node + +mapfile -t shared_storages < <(get_shared_storages) +shared_csv="$(IFS=,; echo "${shared_storages[*]}")" + +mapfile -t guests < <(get_local_guests) +if (( ${#guests[@]} == 0 )); then + log "No guests remain on ${LOCAL_NODE}." + exit 0 +fi + +declare -a shared_guests=() +declare -a local_guests=() + +for guest in "${guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name <<< "$guest" + storage_result="$(guest_storage_scope "$guest_type" "$vmid" "$shared_csv")" || + die "Could not inspect storage for ${guest_type} ${vmid}." + IFS=$'\x1f' read -r scope reason <<< "$storage_result" + + if [[ "$scope" == "shared" ]]; then + shared_guests+=("$guest") + else + local_guests+=("${guest}"$'\x1f'"${reason:-local storage}") + fi +done + +printf '\nEvacuation plan for %s:\n' "$LOCAL_NODE" +printf ' Migration target: %s\n' "$TARGET_NODE" +printf ' Shared-storage guests to migrate: %d\n' "${#shared_guests[@]}" +printf ' Local-storage guests to retain: %d\n' "${#local_guests[@]}" + +if (( ${#shared_guests[@]} > 0 )); then + printf '\nShared-storage guests:\n' + for guest in "${shared_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name <<< "$guest" + printf ' %-6s %-5s %-8s %s\n' "$vmid" "$guest_type" "$status" "$name" + done +fi + +if (( ${#local_guests[@]} > 0 )); then + printf '\nLocal-storage guests (will not migrate):\n' + for guest in "${local_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name reason <<< "$guest" + printf ' %-6s %-5s %-8s %-24s %s\n' \ + "$vmid" "$guest_type" "$status" "$name" "$reason" + done +fi + +printf '\n' +read -r -p "Proceed with shared-storage guest migration? [y/N] " answer +[[ "$answer" =~ ^[Yy]$ ]] || { echo "Evacuation cancelled."; exit 0; } + +declare -a migration_failures=() +for guest in "${shared_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name <<< "$guest" + log "Migrating ${guest_type} ${vmid} (${name:-unnamed}) to ${TARGET_NODE}." + if ! migrate_guest "$vmid" "$guest_type" "$status"; then + warn "Migration failed for ${guest_type} ${vmid}." + migration_failures+=("$guest") + fi +done + +declare -a running_local_guests=() +for guest in "${local_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name reason <<< "$guest" + status="$(guest_status "$vmid" "$guest_type")" + if [[ "$status" == "running" ]]; then + running_local_guests+=( + "${vmid}"$'\x1f'"${guest_type}"$'\x1f'"${status}"$'\x1f'"${name}"$'\x1f'"${reason}" + ) + fi +done + +if (( ${#running_local_guests[@]} > 0 )); then + printf '\nThe following local-storage guests remain running:\n' + for guest in "${running_local_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name reason <<< "$guest" + printf ' %-6s %-5s %-24s %s\n' "$vmid" "$guest_type" "$name" "$reason" + done + + printf '\n' + read -r -p "Gracefully shut down these local-storage guests? [y/N] " answer + if [[ "$answer" =~ ^[Yy]$ ]]; then + for guest in "${running_local_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name reason <<< "$guest" + log "Shutting down ${guest_type} ${vmid} (${name:-unnamed})." + shutdown_guest "$vmid" "$guest_type" || + warn "Graceful shutdown failed for ${guest_type} ${vmid}; it was not force-stopped." + done + else + warn "Local-storage guests were left running." + fi +fi + +mapfile -t final_guests < <(get_local_guests) + +printf '\nEvacuation summary:\n' +printf ' Migration failures: %d\n' "${#migration_failures[@]}" +printf ' Guests still assigned to %s: %d\n' "$LOCAL_NODE" "${#final_guests[@]}" + +if (( ${#migration_failures[@]} > 0 )); then + printf '\nFailed migrations (left unchanged; not shut down automatically):\n' + printf ' %s\n' "${migration_failures[@]}" +fi + +if (( ${#final_guests[@]} > 0 )); then + printf '\nGuests still assigned to %s:\n' "$LOCAL_NODE" + printf ' %s\n' "${final_guests[@]}" +fi + +printf '\nNo guest was force-stopped.\n' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 1afe5f9..52121bb 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -273,7 +273,10 @@ SET_VM_SHUTDOWNTIMEOUT(){ } MAINTENANCE_MODE(){ - if ha-manager status | grep $(hostname) | grep "maintenance mode" &> /dev/null; then + local local_node + local_node="$(hostname -s)" + + if ha-manager status | grep -F "$local_node" | grep -q "maintenance mode"; then echo -en "${idsCL[LightCyan]}Take the local host out of maintenance mode (Y/n)?${idsCL[Default]} " else echo -en "${idsCL[LightCyan]}Put the local host into maintenance mode (Y/n)?${idsCL[Default]} " @@ -282,12 +285,13 @@ MAINTENANCE_MODE(){ case "$choice" in [Nn]) echo;; *) echo - if ha-manager status | grep $(hostname) | grep "maintenance mode" &> /dev/null; then - ha-manager crm-command node-maintenance disable $(hostname) & + if ha-manager status | grep -F "$local_node" | grep -q "maintenance mode"; then + ha-manager crm-command node-maintenance disable "$local_node" echo -e "\n${idsCL[Green]}This host will be taken out of maintenance mode${idsCL[Default]}\n" else - ha-manager crm-command node-maintenance enable $(hostname) & + ha-manager crm-command node-maintenance enable "$local_node" echo -e "\n${idsCL[Green]}This host will be entered into maintenance mode${idsCL[Default]}\n" + bash /opt/idssys/ta-proxmenu/inc/evacuate-proxmox-node.sh fi FINISH_ACTION ;; @@ -375,7 +379,7 @@ MAIN_MENU() { echo -e "${idsCL[DarkGray]} [S] ScreenConnect is already installed${idsCL[Default]}" fi echo - if ha-manager status | grep $(hostname) | grep "maintenance mode" &> /dev/null; then + if ha-manager status | grep -F "$(hostname -s)" | grep -q "maintenance mode"; then echo -e "${idsCL[White]} [${idsCL[LightYellow]}M${idsCL[Default]}] ${idsCL[White]}Take Host out of Maintenance Mode${idsCL[Default]}" else echo -e "${idsCL[White]} [${idsCL[LightYellow]}M${idsCL[Default]}] ${idsCL[White]}Put Host into Maintenance Mode${idsCL[Default]}" @@ -437,4 +441,3 @@ else fi exit 0 - From f2a6bce8f828d1c0c5b97ba8565a502edad0586d Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 11:12:00 -0500 Subject: [PATCH 07/76] update --- defaults.inc | 2 +- inc/evacuate-proxmox-node.sh | 29 ++++++++++++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/defaults.inc b/defaults.inc index cf839e4..2b582ad 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-12' +VERS='2026.7.25-13' noupdate=' ' diff --git a/inc/evacuate-proxmox-node.sh b/inc/evacuate-proxmox-node.sh index 516dfba..2a893b4 100755 --- a/inc/evacuate-proxmox-node.sh +++ b/inc/evacuate-proxmox-node.sh @@ -6,6 +6,7 @@ set -u # Guests with local storage remain on this node and are gracefully shut down. HA_WAIT_SECONDS=300 +MAINTENANCE_WAIT_SECONDS=60 SHUTDOWN_TIMEOUT=180 LOCAL_NODE="$(hostname -s)" @@ -248,11 +249,29 @@ guest_status() { fi } -if ! ha-manager status | - grep -F "$LOCAL_NODE" | - grep -q "maintenance mode"; then - die "${LOCAL_NODE} is not in HA maintenance mode." -fi +wait_for_maintenance_mode() { + local deadline=$((SECONDS + MAINTENANCE_WAIT_SECONDS)) + + log "Waiting for ${LOCAL_NODE} to enter HA maintenance mode." + + while true; do + if ha-manager status | + grep -F "$LOCAL_NODE" | + grep -q "maintenance mode"; then + printf '\n' + return + fi + + if (( SECONDS >= deadline )); then + die "${LOCAL_NODE} did not enter HA maintenance mode within ${MAINTENANCE_WAIT_SECONDS} seconds." + fi + + printf '\r Waiting for HA maintenance mode... ' + sleep 2 + done +} + +wait_for_maintenance_mode wait_for_ha_evacuation || die "Resolve the remaining HA guests before continuing the evacuation." From cd01e190ef01e44e37404dbe2791ebd2db57d480 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 11:21:16 -0500 Subject: [PATCH 08/76] update --- defaults.inc | 2 +- inc/evacuate-proxmox-node.sh | 57 +++++++++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/defaults.inc b/defaults.inc index 2b582ad..e08a5c3 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-13' +VERS='2026.7.25-14' noupdate=' ' diff --git a/inc/evacuate-proxmox-node.sh b/inc/evacuate-proxmox-node.sh index 2a893b4..287ab9f 100755 --- a/inc/evacuate-proxmox-node.sh +++ b/inc/evacuate-proxmox-node.sh @@ -7,8 +7,12 @@ set -u HA_WAIT_SECONDS=300 MAINTENANCE_WAIT_SECONDS=60 +MAX_PARALLEL_MIGRATIONS=3 SHUTDOWN_TIMEOUT=180 LOCAL_NODE="$(hostname -s)" +MIGRATION_LOG_DIR="$(mktemp -d)" + +trap 'rm -rf "$MIGRATION_LOG_DIR"' EXIT log() { printf '\n[%s] %s\n' "$(date '+%F %T')" "$*" @@ -227,6 +231,39 @@ migrate_guest() { fi } +wait_for_migration_batch() { + local index + local pid + local guest + local log_file + local vmid + local guest_type + local status + local name + + for index in "${!batch_pids[@]}"; do + pid="${batch_pids[$index]}" + guest="${batch_guests[$index]}" + log_file="${batch_logs[$index]}" + IFS=$'\x1f' read -r vmid guest_type status name <<< "$guest" + + if wait "$pid"; then + log "Migration completed for ${guest_type} ${vmid} (${name:-unnamed})." + else + warn "Migration failed for ${guest_type} ${vmid} (${name:-unnamed})." + migration_failures+=("$guest") + fi + + if [[ -s "$log_file" ]]; then + sed 's/^/ /' "$log_file" + fi + done + + batch_pids=() + batch_guests=() + batch_logs=() +} + shutdown_guest() { local vmid="$1" local guest_type="$2" @@ -330,15 +367,27 @@ read -r -p "Proceed with shared-storage guest migration? [y/N] " answer [[ "$answer" =~ ^[Yy]$ ]] || { echo "Evacuation cancelled."; exit 0; } declare -a migration_failures=() +declare -a batch_pids=() +declare -a batch_guests=() +declare -a batch_logs=() + for guest in "${shared_guests[@]}"; do IFS=$'\x1f' read -r vmid guest_type status name <<< "$guest" - log "Migrating ${guest_type} ${vmid} (${name:-unnamed}) to ${TARGET_NODE}." - if ! migrate_guest "$vmid" "$guest_type" "$status"; then - warn "Migration failed for ${guest_type} ${vmid}." - migration_failures+=("$guest") + log "Starting migration for ${guest_type} ${vmid} (${name:-unnamed}) to ${TARGET_NODE}." + + log_file="${MIGRATION_LOG_DIR}/${guest_type}-${vmid}.log" + migrate_guest "$vmid" "$guest_type" "$status" >"$log_file" 2>&1 & + batch_pids+=("$!") + batch_guests+=("$guest") + batch_logs+=("$log_file") + + if (( ${#batch_pids[@]} >= MAX_PARALLEL_MIGRATIONS )); then + wait_for_migration_batch fi done +(( ${#batch_pids[@]} == 0 )) || wait_for_migration_batch + declare -a running_local_guests=() for guest in "${local_guests[@]}"; do IFS=$'\x1f' read -r vmid guest_type status name reason <<< "$guest" From cc83cce6676466efd5750927047a9a8c3f62e5c0 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 11:30:41 -0500 Subject: [PATCH 09/76] new menu --- README.md | 7 +- defaults.inc | 2 +- proxmenu-scripts.sh | 366 ++++++++++++++++++++++++++++++-------------- 3 files changed, 260 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index e5dd876..5c7b259 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,15 @@ rmm Install the ConnectWise RMM agent omsa Install Dell OpenManage Server Administrator glances Install Glances acronis Install the Acronis agent +post-install Run the ProxMenux post-install configuration proxmenux Install or open ProxMenux +virtio Download current VirtIO drivers +sentinelone Install the SentinelOne agent screenconnect Install the ScreenConnect agent restart Restart local Proxmox services cpu Detect and optionally apply a migration-safe CPU model -mm Toggle local HA maintenance mode -timeout Set VM shutdown timeouts +maintenance Toggle local HA maintenance mode +keepalived Deploy Keepalived across the cluster ``` ## Companion files diff --git a/defaults.inc b/defaults.inc index e08a5c3..037aaa2 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-14' +VERS='2026.7.25-15' noupdate=' ' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 52121bb..7ca5c3a 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -25,7 +25,7 @@ INSTALL_PULSE() { INSTALL_ACRONIS() { read -n 1 -p "Are you sure you wish to install Acronis (Y/n)?" choice case "$choice" in - [Nn]) MAIN_MENU;; + [Nn]) echo;; * ) echo cd /tmp || return 1 @@ -255,23 +255,6 @@ RESTART_PVE_SERVICES(){ esac } -SET_VM_SHUTDOWNTIMEOUT(){ - if [ "${1}" == "" ]; then - echo -en "${idsCL[LightCyan]}Would you like to set all VM's shutdown timeout to 180secs (Y/n)?${idsCL[Default]} " - read -n 1 choice - else - choice=${1} - fi - case "${choice}" in - [Nn]) echo;; - *) - echo "Updating all VM's shutdown timeout to 180 seconds..." - sed -E -i 's/(down=)[0-9]+/\1180/g' /etc/pve/nodes/*/qemu-server/*.conf - FINISH_ACTION - ;; - esac -} - MAINTENANCE_MODE(){ local local_node local_node="$(hostname -s)" @@ -314,108 +297,264 @@ INSTALL_KEEPALIVE() { -MAIN_MENU() { - echo -en "${idsCL[LightCyan]}Pulling host info ... " - CRES=$(pvesh get /cluster/resources) - DPL=$(dpkg -l) - echo -e "${idsCL[Green]}Done${idsCL[Default]}" - - while : - do - clear +MENU_HEADER() { + clear + echo + echo -e " ${idsCL[Green]}TA-ProxMenu - Proxmox Setup Scripts${idsCL[Default]} ${VERS}" + echo -e "${idsCL[Green]}---------------------------------------------------------------------------${idsCL[Default]}" + echo -e " Hostname: ${idsCL[Cyan]}$(hostname -s)${idsCL[Default]}" + echo -e " IP Address: ${idsCL[Cyan]}${RNIP:-Unavailable}${idsCL[Default]}" + echo -e "${idsCL[Green]}---------------------------------------------------------------------------${idsCL[Default]}" +} + +SELECT_MENU() { + local title="$1" + local labels_name="$2" + local values_name="$3" + local allow_back="${4:-1}" + local -n labels_ref="$labels_name" + local -n values_ref="$values_name" + local selected=0 + local key + local sequence + local index + + while true; do + MENU_HEADER echo - echo -e " ${idsCL[Green]}TA-Proxmenu - Proxmox Setup Scripts${idsCL[Default]} ${idsCL[Default]}${VERS}" - echo -e "${idsCL[Green]}---------------------------------------------------------------------------${idsCL[Default]}" - echo -e " Hostname: ${idsCL[Cyan]}$(hostname -s)${idsCL[Default]}" - echo -e " IP Address: ${idsCL[Cyan]}${RNIP}${idsCL[Default]}" - echo -e "${idsCL[Green]}---------------------------------------------------------------------------${idsCL[Default]}" + echo -e " ${idsCL[LightCyan]}${title}${idsCL[Default]}" echo - - echo -en "${idsCL[White]} [${idsCL[LightYellow]}0${idsCL[Default]}] ${idsCL[White]}Run Post-Install Script${idsCL[Default]}" - [ -f /opt/.PROXMENUX_POST_INSTALL ] && echo -e "${idsCL[Cyan]} - Has been run previously${idsCL[Default]}" || echo - echo -e "${idsCL[White]} [${idsCL[LightYellow]}1${idsCL[Default]}] ${idsCL[White]}Detect CPU-Arch for Live Migrations${idsCL[Default]}" - if ! grep -qi pulse <<< "$CRES"; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}2${idsCL[Default]}] ${idsCL[White]}Install Pulse Monitoring${idsCL[Default]}" - else - echo -e "${idsCL[DarkGray]} [2] Pulse Monitoring is already installed${idsCL[Default]}" - fi - if [ -f "${DLDIR}/${VIRTIO_FILE}" ]; then - echo -e "${idsCL[DarkGray]} [3] Current VirtIO drivers already downloaded to 'local' on this host${idsCL[Default]}" - elif compgen -G "${DLDIR}/virtio*.iso" > /dev/null; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}3${idsCL[Default]}] ${idsCL[LightGreen]}**${idsCL[White]}Download the available updated Win-VirtIO drivers to 'local' on this host${idsCL[Default]}" - else - echo -e "${idsCL[White]} [${idsCL[LightYellow]}3${idsCL[Default]}] ${idsCL[White]}Download the current Win-VirtIO drivers to 'local' on this host${idsCL[Default]}" - fi - if ! command -v glances &> /dev/null; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}4${idsCL[Default]}] ${idsCL[White]}Install Glances (CLI Monitor)${idsCL[Default]}" - else - echo -e "${idsCL[DarkGray]} [4] Glances is already installed${idsCL[Default]}" - fi - if ! grep -q 'srvadmin-all' <<< "$DPL"; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}5${idsCL[Default]}] ${idsCL[White]}Install Dell OpenManage Server Administrator${idsCL[Default]}" - else - echo -e "${idsCL[DarkGray]} [5] Dell OMSA is already installed - ${idsCL[Cyan]}https://${RNIP}:1311" - fi + + for index in "${!labels_ref[@]}"; do + if (( index == selected )); then + printf '\e[7m %d %-64s\e[0m\n' "$((index + 1))" "${labels_ref[$index]}" + else + printf ' %d %s\n' "$((index + 1))" "${labels_ref[$index]}" + fi + done + echo - if [ "$(systemctl is-active ITSPlatform)" != "active" ]; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}6${idsCL[Default]}] ${idsCL[White]}Install ConnectWise RMM Agent${idsCL[Default]}" + if (( allow_back == 1 )); then + echo " ↑/↓ Navigate Enter Select Number Quick Select ←/Esc/B Back Q Quit" else - echo -e "${idsCL[DarkGray]} [6] ConnectWise RMM Agent is already installed${idsCL[Default]}" - fi - if ! grep -q 'cyberprotect' <<< "$DPL"; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}7${idsCL[Default]}] ${idsCL[White]}Install Acronis Backup Agent${idsCL[Default]}" - else - echo -e "${idsCL[DarkGray]} [7] Acronis Backup is already installed${idsCL[Default]}" + echo " ↑/↓ Navigate Enter Select Number Quick Select Q Quit" fi - if ! grep -q 'sentinelagent' <<< "$DPL"; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}8${idsCL[Default]}] ${idsCL[White]}Install SentinelOne Agent (v${S1_VERSION//_/.})${idsCL[Default]}" + + IFS= read -rsn1 key + case "$key" in + "") + MENU_SELECTION="${values_ref[$selected]}" + return 0 + ;; + [1-9]) + index=$((10#$key - 1)) + if (( index < ${#values_ref[@]} )); then + MENU_SELECTION="${values_ref[$index]}" + return 0 + fi + ;; + [Qq]) + MENU_SELECTION="quit" + return 0 + ;; + [Bb]) + if (( allow_back == 1 )); then + MENU_SELECTION="back" + return 0 + fi + ;; + $'\e') + sequence="" + IFS= read -rsn2 -t 0.1 sequence || true + case "$sequence" in + "[A"|"OA") + (( selected = (selected - 1 + ${#labels_ref[@]}) % ${#labels_ref[@]} )) + ;; + "[B"|"OB") + (( selected = (selected + 1) % ${#labels_ref[@]} )) + ;; + "[C"|"OC") + MENU_SELECTION="${values_ref[$selected]}" + return 0 + ;; + "[D"|"OD"|"") + if (( allow_back == 1 )); then + MENU_SELECTION="back" + return 0 + fi + ;; + "[H") + selected=0 + ;; + "[F") + selected=$((${#labels_ref[@]} - 1)) + ;; + esac + ;; + esac + done +} + +SHOW_ABOUT() { + MENU_HEADER + echo + echo -e " ${idsCL[LightCyan]}About TA-ProxMenu${idsCL[Default]}" + echo + echo " Version: ${VERS}" + echo " Install: /opt/idssys/ta-proxmenu" + echo " Branch: $(git -C /opt/idssys/ta-proxmenu branch --show-current 2>/dev/null || echo unknown)" + echo + read -r -p " Press ENTER to return..." _ +} + +HOST_SETUP_MENU() { + local -a labels + local -a values=( + "post_install" + "cpu" + "virtio" + "glances" + "omsa" + ) + + while true; do + labels=("Run ProxMenux post-install configuration") + [ -f /opt/.PROXMENUX_POST_INSTALL ] && + labels[0]="Run ProxMenux post-install configuration (previously run)" + labels+=("Detect CPU model for live migrations") + + if [ -f "${DLDIR}/${VIRTIO_FILE}" ]; then + labels+=("VirtIO drivers (${VIRTIO_FILE} already downloaded)") + elif compgen -G "${DLDIR}/virtio*.iso" >/dev/null; then + labels+=("Download updated VirtIO drivers") else - echo -e "${idsCL[DarkGray]} [8] SentinelOne is already installed${idsCL[Default]}" + labels+=("Download current VirtIO drivers") fi - echo - if [ "$(systemctl is-active connectwise*)" != "active" ]; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}S${idsCL[Default]}] ${idsCL[White]}Install ScreenConnect Agent${idsCL[Default]}" - else - echo -e "${idsCL[DarkGray]} [S] ScreenConnect is already installed${idsCL[Default]}" - fi - echo + + command -v glances >/dev/null 2>&1 && + labels+=("Glances CLI monitor (installed)") || + labels+=("Install Glances CLI monitor") + + dpkg-query -W -f='${Status}' srvadmin-all 2>/dev/null | grep -q "install ok installed" && + labels+=("Dell OpenManage Server Administrator (installed)") || + labels+=("Install Dell OpenManage Server Administrator") + + SELECT_MENU "Host Setup" labels values + case "$MENU_SELECTION" in + post_install) PROXMENUX_POST_INSTALL;; + cpu) DETECT_CPU;; + virtio) DOWNLOAD_VIRTIO;; + glances) INSTALL_GLANCES;; + omsa) INSTALL_OMSA;; + back) return;; + quit) EXIT1; exit 0;; + esac + done +} + +MONITORING_MENU() { + local -a labels + local -a values=("pulse" "rmm" "acronis" "sentinelone" "screenconnect") + local cluster_resources + + while true; do + cluster_resources="$(pvesh get /cluster/resources 2>/dev/null)" + grep -qi pulse <<< "$cluster_resources" && + labels=("Pulse monitoring (installed)") || + labels=("Install Pulse monitoring") + + systemctl is-active --quiet ITSPlatform && + labels+=("ConnectWise RMM agent (installed)") || + labels+=("Install ConnectWise RMM agent") + + dpkg-query -W -f='${Status}' cyberprotect 2>/dev/null | grep -q "install ok installed" && + labels+=("Acronis backup agent (installed)") || + labels+=("Install Acronis backup agent") + + dpkg-query -W -f='${Status}' sentinelagent 2>/dev/null | grep -q "install ok installed" && + labels+=("SentinelOne agent (installed)") || + labels+=("Install SentinelOne agent v${S1_VERSION//_/.}") + + systemctl is-active --quiet 'connectwise*' && + labels+=("ScreenConnect agent (installed)") || + labels+=("Install ScreenConnect agent") + + SELECT_MENU "Monitoring & Agents" labels values + case "$MENU_SELECTION" in + pulse) INSTALL_PULSE;; + rmm) INSTALL_RMM;; + acronis) INSTALL_ACRONIS;; + sentinelone) INSTALL_S1;; + screenconnect) INSTALL_SCREENCONNECT;; + back) return;; + quit) EXIT1; exit 0;; + esac + done +} + +CLUSTER_MENU() { + local -a labels + local -a values=("maintenance" "restart" "keepalived") + + while true; do if ha-manager status | grep -F "$(hostname -s)" | grep -q "maintenance mode"; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}M${idsCL[Default]}] ${idsCL[White]}Take Host out of Maintenance Mode${idsCL[Default]}" + labels=("Take this host out of maintenance mode") else - echo -e "${idsCL[White]} [${idsCL[LightYellow]}M${idsCL[Default]}] ${idsCL[White]}Put Host into Maintenance Mode${idsCL[Default]}" + labels=("Put this host into maintenance mode and evacuate guests") fi - echo -e "${idsCL[White]} [${idsCL[LightYellow]}R${idsCL[Default]}] ${idsCL[White]}Restart Proxmox Services${idsCL[Default]}" - echo - if ! grep -q 'keepalived' <<< "$DPL"; then - echo -e "${idsCL[White]} [${idsCL[LightYellow]}K${idsCL[Default]}] ${idsCL[White]}Install Keepalive on all Hosts${idsCL[Default]}" - else - echo -e "${idsCL[DarkGray]} [K] Keepalive is already installed${idsCL[Default]}" - fi - - echo - echo -e "${idsCL[White]} [${idsCL[LightYellow]}Q${idsCL[Default]}] ${idsCL[White]}Quit${idsCL[Default]}" - echo - echo - echo -e -n "${idsCL[Yellow]}Enter ${idsCL[LightYellow]}option${idsCL[Yellow]} from above:${idsCL[Default]} " - read -n 1 opt - echo - case $opt in - [0]) PROXMENUX_POST_INSTALL;; - [1]) DETECT_CPU;; - [2]) INSTALL_PULSE;; - [3]) DOWNLOAD_VIRTIO;; - [4]) INSTALL_GLANCES;; - [5]) INSTALL_OMSA;; - [6]) INSTALL_RMM;; - [7]) INSTALL_ACRONIS;; - [8]) INSTALL_S1;; - - [Ss]) INSTALL_SCREENCONNECT;; - [Mm]) MAINTENANCE_MODE;; - [Rr]) RESTART_PVE_SERVICES;; - [Kk]) INSTALL_KEEPALIVE;; - [Qq]) EXIT1; exit 0;; - *) echo -e "That's an invalid option.\nPlease select a valid option."; sleep 1;; + + labels+=("Restart local Proxmox services") + dpkg-query -W -f='${Status}' keepalived 2>/dev/null | grep -q "install ok installed" && + labels+=("Deploy/reconfigure Keepalived (installed locally)") || + labels+=("Deploy Keepalived on all cluster hosts") + + SELECT_MENU "Cluster & Maintenance" labels values + case "$MENU_SELECTION" in + maintenance) MAINTENANCE_MODE;; + restart) RESTART_PVE_SERVICES;; + keepalived) INSTALL_KEEPALIVE;; + back) return;; + quit) EXIT1; exit 0;; + esac + done +} + +UTILITIES_MENU() { + local -a labels=("Check for script updates" "Version and installation information") + local -a values=("update" "about") + + while true; do + SELECT_MENU "Utilities" labels values + case "$MENU_SELECTION" in + update) + /opt/idssys/ta-proxmenu/run.sh update + read -r -p " Press ENTER to return..." _ + ;; + about) SHOW_ABOUT;; + back) return;; + quit) EXIT1; exit 0;; + esac + done +} + +MAIN_MENU() { + local -a labels=( + "Host Setup" + "Monitoring & Agents" + "Cluster & Maintenance" + "Utilities" + "Quit" + ) + local -a values=("host" "monitoring" "cluster" "utilities" "quit") + + while true; do + SELECT_MENU "Main Menu" labels values 0 + case "$MENU_SELECTION" in + host) HOST_SETUP_MENU;; + monitoring) MONITORING_MENU;; + cluster) CLUSTER_MENU;; + utilities) UTILITIES_MENU;; + quit) EXIT1; exit 0;; esac done } @@ -428,12 +567,15 @@ if (( ACTION_REQUESTED == 1 )); then omsa) INSTALL_OMSA;; glances) INSTALL_GLANCES;; acronis) INSTALL_ACRONIS;; + post-install|post_install) PROXMENUX_POST_INSTALL;; proxmenux) [ ! -f /usr/local/bin/menu ] && INSTALL_PROXMENUX || /usr/local/bin/menu;; + virtio) DOWNLOAD_VIRTIO;; + sentinelone|s1) INSTALL_S1;; screenconnect) INSTALL_SCREENCONNECT;; restart) RESTART_PVE_SERVICES "${2:-}";; cpu) DETECT_CPU;; - mm) MAINTENANCE_MODE;; - timeout) SET_VM_SHUTDOWNTIMEOUT "${2:-}";; + maintenance|mm) MAINTENANCE_MODE;; + keepalived) INSTALL_KEEPALIVE;; *) MAIN_MENU;; esac else From 376d029fc82d1e79ff55736d8e252081f3df4e7a Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 11:46:04 -0500 Subject: [PATCH 10/76] update --- README.md | 8 +- defaults.inc | 2 +- proxmenu-scripts.sh | 330 ++++++++++++++++++++++++++++++++++++++++---- run.sh | 138 +++++++++--------- 4 files changed, 380 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 5c7b259..48a9708 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,10 @@ Run as `root` on a Proxmox VE host: bash <(curl -fsSL https://go.scity.us/install-tapm) ``` -The installed launcher loads the shared iDSSYS defaults, checks for updates, -and opens the interactive menu. +The installed launcher loads the shared iDSSYS defaults and opens the +interactive menu. Update availability is checked in the background and cached; +updates are installed only when explicitly selected or requested with +`tapm update`. ## Direct actions @@ -29,7 +31,7 @@ proxmenux Install or open ProxMenux virtio Download current VirtIO drivers sentinelone Install the SentinelOne agent screenconnect Install the ScreenConnect agent -restart Restart local Proxmox services +restart Restart core local Proxmox management services cpu Detect and optionally apply a migration-safe CPU model maintenance Toggle local HA maintenance mode keepalived Deploy Keepalived across the cluster diff --git a/defaults.inc b/defaults.inc index 037aaa2..0bd7e01 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-15' +VERS='2026.7.25-16' noupdate=' ' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 7ca5c3a..01e4b0c 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -235,24 +235,88 @@ DETECT_CPU(){ } -RESTART_PVE_SERVICES(){ - if [ "${1}" == "" ]; then - echo -en "${idsCL[LightCyan]}Would you like to restart all Proxmox services on the local host (Y/n)?${idsCL[Default]} " - read -n 1 choice - else - choice=${1} - fi - case "${choice}" in - [Nn]) echo;; - *) echo - echo -en "\n${idsCL[Yellow]}Restarting services ... " - #systemctl restart pve-cluster pvedaemon pvestatd pveproxy pve-ha-lrm pve-ha-crm - systemctl restart pve-cluster pvedaemon pvestatd pveproxy - echo -e "${idsCL[Green]}Done${idsCL[Default]}" - echo -e "\n${idsCL[Green]}This hosts Proxmox services have been restarted${idsCL[Default]}\n" - FINISH_ACTION - ;; - esac +RESTART_SERVICE_GROUP() { + local description="$1" + shift + local -a services=("$@") + local service + local failed=0 + local choice + + if (( ${RESTART_ASSUME_YES:-0} == 1 )); then + choice="y" + else + echo -en "${idsCL[LightCyan]}Restart ${description} on this host (Y/n)?${idsCL[Default]} " + read -n 1 choice + echo + fi + [[ "$choice" =~ ^[Nn]$ ]] && return + + echo -e "\n${idsCL[Yellow]}Restarting ${description}...${idsCL[Default]}" + if ! systemctl restart "${services[@]}"; then + failed=1 + fi + + echo + for service in "${services[@]}"; do + if systemctl is-active --quiet "$service"; then + echo -e " ${idsCL[Green]}[active]${idsCL[Default]} ${service}" + else + echo -e " ${idsCL[Red]}[failed]${idsCL[Default]} ${service}" + failed=1 + fi + done + + if (( failed == 0 )); then + echo -e "\n${idsCL[Green]}Service restart completed successfully.${idsCL[Default]}" + else + echo -e "\n${idsCL[Red]}One or more services failed to restart.${idsCL[Default]}" + echo "Review: journalctl -u --since '-10 minutes'" + fi + + FINISH_ACTION +} + +RESTART_CLUSTER_FILESYSTEM() { + local choice + + echo -e "${idsCL[LightYellow]}This temporarily interrupts /etc/pve (pmxcfs) and cluster configuration access.${idsCL[Default]}" + echo -e "${idsCL[LightYellow]}It does not restart Corosync or the HA daemons.${idsCL[Default]}" + + if systemctl is-active --quiet corosync && + ! timeout 5 pvecm status 2>/dev/null | + grep -Eq '^Quorate:[[:space:]]+Yes[[:space:]]*$'; then + echo -e "\n${idsCL[Red]}The cluster is not quorate. pve-cluster will not be restarted.${idsCL[Default]}" + ENTER2CONTINUE + return + fi + + echo -en "\n${idsCL[LightCyan]}Restart pve-cluster on this host (y/N)?${idsCL[Default]} " + read -n 1 choice + echo + [[ "$choice" =~ ^[Yy]$ ]] || return + + if systemctl restart pve-cluster && + systemctl is-active --quiet pve-cluster && + timeout 15 bash -c 'until test -d /etc/pve/nodes; do sleep 1; done'; then + echo -e "\n${idsCL[Green]}pve-cluster restarted and /etc/pve is available.${idsCL[Default]}" + else + echo -e "\n${idsCL[Red]}pve-cluster did not recover normally.${idsCL[Default]}" + echo "Review: journalctl -u pve-cluster --since '-10 minutes'" + fi + + ENTER2CONTINUE +} + +RESTART_PVE_SERVICES() { + local requested_choice="${1:-}" + local RESTART_ASSUME_YES=0 + + [[ "$requested_choice" =~ ^[Nn]$ ]] && return + [[ "$requested_choice" =~ ^[Yy]$ ]] && RESTART_ASSUME_YES=1 + + RESTART_SERVICE_GROUP "core Proxmox management services" \ + pvedaemon pveproxy pvestatd pvescheduler } MAINTENANCE_MODE(){ @@ -295,12 +359,151 @@ INSTALL_KEEPALIVE() { esac } +UPDATE_CACHE_DIR='/var/cache/ta-proxmenu' +UPDATE_CACHE_FILE="${UPDATE_CACHE_DIR}/update-status" +UPDATE_CACHE_SECONDS=14400 +UPDATE_CHECK_PID='' +UPDATE_STATUS='unknown' +UPDATE_REMOTE_COMMIT='' + +WRITE_UPDATE_CACHE() { + local status="$1" + local branch="$2" + local local_commit="$3" + local remote_commit="$4" + local cache_temp + + mkdir -p "$UPDATE_CACHE_DIR" || return 1 + cache_temp="$(mktemp "${UPDATE_CACHE_DIR}/.update-status.XXXXXX")" || return 1 + printf '%s|%s|%s|%s|%s\n' \ + "$(date +%s)" "$branch" "$local_commit" "$remote_commit" "$status" \ + >"$cache_temp" + chmod 0644 "$cache_temp" + mv -f "$cache_temp" "$UPDATE_CACHE_FILE" +} + +UPDATE_CHECK_WORKER() { + local branch + local local_commit + local remote_commit + local status + + branch="$(git -C "$FOLDER" branch --show-current 2>/dev/null)" + local_commit="$(git -C "$FOLDER" rev-parse HEAD 2>/dev/null)" + + if [[ -z "$branch" || -z "$local_commit" ]]; then + WRITE_UPDATE_CACHE "unavailable" "$branch" "$local_commit" "" + return + fi + + remote_commit="$(git -C "$FOLDER" ls-remote origin "refs/heads/${branch}" 2>/dev/null | cut -f1)" + if [[ -z "$remote_commit" ]]; then + status="unavailable" + elif [[ "$local_commit" == "$remote_commit" ]]; then + status="current" + else + status="available" + fi + + WRITE_UPDATE_CACHE "$status" "$branch" "$local_commit" "$remote_commit" +} + +START_UPDATE_CHECK() { + if [[ -n "$UPDATE_CHECK_PID" ]] && + kill -0 "$UPDATE_CHECK_PID" 2>/dev/null; then + return + fi + UPDATE_CHECK_WORKER >/dev/null 2>&1 & + UPDATE_CHECK_PID="$!" +} + +LOAD_UPDATE_STATUS() { + local checked_at + local cached_branch + local cached_local + local cached_remote + local cached_status + local current_branch + local current_commit + local now + + UPDATE_STATUS='unknown' + UPDATE_REMOTE_COMMIT='' + current_branch="$(git -C "$FOLDER" branch --show-current 2>/dev/null)" + current_commit="$(git -C "$FOLDER" rev-parse HEAD 2>/dev/null)" + now="$(date +%s)" + + if [[ -r "$UPDATE_CACHE_FILE" ]]; then + IFS='|' read -r checked_at cached_branch cached_local cached_remote cached_status \ + <"$UPDATE_CACHE_FILE" + if [[ "$checked_at" =~ ^[0-9]+$ ]] && + (( now - checked_at < UPDATE_CACHE_SECONDS )) && + [[ "$cached_branch" == "$current_branch" ]] && + [[ "$cached_local" == "$current_commit" ]] && + [[ "$cached_status" =~ ^(current|available|unavailable)$ ]]; then + UPDATE_STATUS="$cached_status" + UPDATE_REMOTE_COMMIT="$cached_remote" + return + fi + fi + + UPDATE_STATUS='checking' + START_UPDATE_CHECK +} + +FORCE_UPDATE_CHECK() { + local attempts=0 + + rm -f "$UPDATE_CACHE_FILE" + UPDATE_CHECK_PID='' + START_UPDATE_CHECK + + echo -en "${idsCL[LightCyan]}Checking current branch for updates" + while (( attempts < 20 )); do + sleep 0.5 + LOAD_UPDATE_STATUS + [[ "$UPDATE_STATUS" != "checking" ]] && break + echo -n "." + ((attempts++)) + done + echo -e "${idsCL[Default]}" + + case "$UPDATE_STATUS" in + available) + echo -e "${idsCL[LightYellow]}An update is available for the current branch.${idsCL[Default]}" + ;; + current) + echo -e "${idsCL[Green]}TA-ProxMenu is current.${idsCL[Default]}" + ;; + *) + echo -e "${idsCL[Red]}The update status could not be determined.${idsCL[Default]}" + ;; + esac + + ENTER2CONTINUE +} + MENU_HEADER() { + local version_display + + LOAD_UPDATE_STATUS + case "$UPDATE_STATUS" in + available) + version_display="${idsCL[LightYellow]}${VERS} ** UPDATE AVAILABLE **${idsCL[Default]}" + ;; + checking) + version_display="${idsCL[LightCyan]}${VERS} (checking for updates)${idsCL[Default]}" + ;; + *) + version_display="${idsCL[White]}${VERS}${idsCL[Default]}" + ;; + esac + clear echo - echo -e " ${idsCL[Green]}TA-ProxMenu - Proxmox Setup Scripts${idsCL[Default]} ${VERS}" + echo -e " ${idsCL[Green]}TA-ProxMenu - Proxmox Setup Scripts${idsCL[Default]} ${version_display}" echo -e "${idsCL[Green]}---------------------------------------------------------------------------${idsCL[Default]}" echo -e " Hostname: ${idsCL[Cyan]}$(hostname -s)${idsCL[Default]}" echo -e " IP Address: ${idsCL[Cyan]}${RNIP:-Unavailable}${idsCL[Default]}" @@ -340,7 +543,12 @@ SELECT_MENU() { echo " ↑/↓ Navigate Enter Select Number Quick Select Q Quit" fi - IFS= read -rsn1 key + key="" + if [[ "$UPDATE_STATUS" == "checking" ]]; then + IFS= read -rsn1 -t 1 key || continue + else + IFS= read -rsn1 key + fi case "$key" in "") MENU_SELECTION="${values_ref[$selected]}" @@ -494,7 +702,7 @@ MONITORING_MENU() { CLUSTER_MENU() { local -a labels - local -a values=("maintenance" "restart" "keepalived") + local -a values=("maintenance" "services" "keepalived") while true; do if ha-manager status | grep -F "$(hostname -s)" | grep -q "maintenance mode"; then @@ -503,7 +711,7 @@ CLUSTER_MENU() { labels=("Put this host into maintenance mode and evacuate guests") fi - labels+=("Restart local Proxmox services") + labels+=("Proxmox service recovery") dpkg-query -W -f='${Status}' keepalived 2>/dev/null | grep -q "install ok installed" && labels+=("Deploy/reconfigure Keepalived (installed locally)") || labels+=("Deploy Keepalived on all cluster hosts") @@ -511,7 +719,7 @@ CLUSTER_MENU() { SELECT_MENU "Cluster & Maintenance" labels values case "$MENU_SELECTION" in maintenance) MAINTENANCE_MODE;; - restart) RESTART_PVE_SERVICES;; + services) SERVICE_RECOVERY_MENU;; keepalived) INSTALL_KEEPALIVE;; back) return;; quit) EXIT1; exit 0;; @@ -519,17 +727,81 @@ CLUSTER_MENU() { done } -UTILITIES_MENU() { - local -a labels=("Check for script updates" "Version and installation information") - local -a values=("update" "about") +SERVICE_RECOVERY_MENU() { + local -a labels=( + "Restart Web UI and API (pveproxy, pvedaemon)" + "Restart statistics collection (pvestatd)" + "Restart task scheduler (pvescheduler)" + "Restart core management services" + "Restart cluster filesystem (pve-cluster)" + ) + local -a values=("web" "statistics" "scheduler" "core" "clusterfs") while true; do + SELECT_MENU "Proxmox Service Recovery" labels values + case "$MENU_SELECTION" in + web) + RESTART_SERVICE_GROUP "Web UI and API services" pveproxy pvedaemon + ;; + statistics) + RESTART_SERVICE_GROUP "statistics collection" pvestatd + ;; + scheduler) + RESTART_SERVICE_GROUP "task scheduler" pvescheduler + ;; + core) + RESTART_PVE_SERVICES + ;; + clusterfs) + RESTART_CLUSTER_FILESYSTEM + ;; + back) return;; + quit) EXIT1; exit 0;; + esac + done +} + +UTILITIES_MENU() { + local -a labels + local -a values=("install_update" "check_update" "about") + local choice + + while true; do + LOAD_UPDATE_STATUS + case "$UPDATE_STATUS" in + available) + labels=("Install available update") + ;; + current) + labels=("TA-ProxMenu is current") + ;; + checking) + labels=("Update check in progress") + ;; + *) + labels=("Update status unavailable") + ;; + esac + labels+=("Check again now" "Version and installation information") + SELECT_MENU "Utilities" labels values case "$MENU_SELECTION" in - update) - /opt/idssys/ta-proxmenu/run.sh update - read -r -p " Press ENTER to return..." _ + install_update) + if [[ "$UPDATE_STATUS" != "available" ]]; then + echo -e "\n${idsCL[LightCyan]}No available update is currently detected.${idsCL[Default]}" + ENTER2CONTINUE + continue + fi + echo -en "\n${idsCL[LightCyan]}Install the update for the current branch (y/N)?${idsCL[Default]} " + read -n 1 choice + echo + if [[ "$choice" =~ ^[Yy]$ ]] && + /opt/idssys/ta-proxmenu/run.sh update; then + exec /opt/idssys/ta-proxmenu/run.sh + fi + ENTER2CONTINUE ;; + check_update) FORCE_UPDATE_CHECK;; about) SHOW_ABOUT;; back) return;; quit) EXIT1; exit 0;; diff --git a/run.sh b/run.sh index bce38d6..7b93878 100755 --- a/run.sh +++ b/run.sh @@ -1,79 +1,87 @@ #!/usr/bin/env bash -# TA-Proxmenu preloader +# TA-ProxMenu preloader [ "${2:-}" != "q" ] && source /opt/idssys/defaults/colors.inc source /opt/idssys/defaults/default.inc source /opt/idssys/ta-proxmenu/defaults.inc -if [[ "${noupdate}" != *" ${1:-} "* ]] && [[ "${noupdate}" != *" ${2:-} "* ]]; then - if curl -m 3 -s --head --request GET https://git.scity.us | grep "HTTP/2 200" > /dev/null; then - if [ "${1}" != "tapm" ]; then - echo -en "${idsCL[LightCyan]}Checking for updates...${idsCL[Default]}" - echo "" - udtd=0 - fi +UPDATE_REPOSITORY() { + local repository="$1" + local branch="$2" + local label="$3" + local local_commit + local remote_commit - if [ "${1}" != "tapm" ]; then - if [ ! -d /opt/idssys/defaults ]; then - git clone https://git.scity.us/voltron/iDS-Defaults.git /opt/idssys/defaults - else - cd /opt/idssys/defaults - if [ "$(git rev-parse HEAD)" != "$(git ls-remote origin refs/heads/master | cut -f1)" ]; then - if [ "${1}" != "tapm" ]; then - echo -en "\e[1A"; - echo -en "\e[0K\r${idsCL[LightCyan]}Updating iDSSYS-Defaults...${idsCL[Default]}" - udtd=1 - fi - git fetch origin master >/dev/null 2>&1 - git reset --hard origin/master >/dev/null 2>&1 - git reflog expire --expire=now --all >/dev/null 2>&1 - git repack -ad >/dev/null 2>&1 - git prune >/dev/null 2>&1 - git pull >/dev/null 2>&1 - [ "${1}" != "tapm" ] && echo -e "${idsCL[Green]}Done${idsCL[Default]}" - fi - fi - fi + cd "$repository" || return 1 + local_commit="$(git rev-parse HEAD 2>/dev/null)" || return 1 + remote_commit="$(git ls-remote origin "refs/heads/${branch}" | cut -f1)" - cd /opt/idssys/ta-proxmenu - current_branch="$(git branch --show-current)" - remote_commit="" - if [ -n "$current_branch" ]; then - remote_commit="$(git ls-remote origin "refs/heads/${current_branch}" | cut -f1)" + if [ -z "$remote_commit" ]; then + echo -e "${idsCL[Red]}Could not find branch '${branch}' for ${label}${idsCL[Default]}" + return 1 fi + if [ "$local_commit" = "$remote_commit" ]; then + echo -e "${idsCL[Green]}${label} is current (${branch})${idsCL[Default]}" + return 0 + fi + + echo -en "${idsCL[LightCyan]}Updating ${label} (${branch})...${idsCL[Default]}" + if git fetch origin "$branch" >/dev/null 2>&1 && + git reset --hard "origin/${branch}" >/dev/null 2>&1; then + echo -e " ${idsCL[Green]}Done${idsCL[Default]}" + return 0 + fi + + echo -e " ${idsCL[Red]}Failed${idsCL[Default]}" + return 1 +} + +INSTALL_UPDATES() { + local current_branch + local update_failed=0 + + echo -e "${idsCL[LightCyan]}Checking for updates...${idsCL[Default]}" + if ! curl --fail --silent --show-error --head \ + --connect-timeout 3 --max-time 10 https://git.scity.us >/dev/null; then + echo -e "${idsCL[Red]}Could not connect to git.scity.us${idsCL[Default]}" + return 1 + fi + + UPDATE_REPOSITORY /opt/idssys/defaults master "iDSSYS Defaults" || + update_failed=1 + + current_branch="$(git -C /opt/idssys/ta-proxmenu branch --show-current)" if [ -z "$current_branch" ]; then - echo -e "${idsCL[Red]}TA-Proxmenu is in a detached HEAD state; automatic updates were skipped${idsCL[Default]}" - elif [ -z "$remote_commit" ]; then - echo -e "${idsCL[Red]}Could not find branch '${current_branch}' on the TA-Proxmenu origin; automatic updates were skipped${idsCL[Default]}" - elif [ "$(git rev-parse HEAD)" != "$remote_commit" ]; then - if [ "${1}" != "tapm" ]; then - [ ${udtd} -eq 0 ] && echo -en "\e[1A"; - echo -en "\e[0K\r${idsCL[LightCyan]}Updating TA-Proxmenu (${current_branch})...${idsCL[Default]}" - fi - git fetch origin "$current_branch" >/dev/null 2>&1 - git reset --hard "origin/${current_branch}" >/dev/null 2>&1 - git reflog expire --expire=now --all >/dev/null 2>&1 - git repack -ad >/dev/null 2>&1 - git prune >/dev/null 2>&1 - - if [ "${1}" != "tapm" ]; then - source /opt/idssys/ta-proxmenu/defaults.inc - # echo -en "\e[1A"; - # echo -e "\e[0K\r ${idsCL[Green]}Updated to v${VERS}${idsCL[Default]}" - echo -e " ${idsCL[Green]}Updated to v${VERS}${idsCL[Default]}\n" - fi - elif [ "${1}" != "tapm" ] && [ ${udtd} -eq 0 ]; then - echo -e "\e[1A\e[0K\r ${idsCL[Green]}No updates available${idsCL[Default]}\n" - fi + echo -e "${idsCL[Red]}TA-ProxMenu is in a detached HEAD state; update skipped${idsCL[Default]}" + update_failed=1 + else + UPDATE_REPOSITORY /opt/idssys/ta-proxmenu "$current_branch" "TA-ProxMenu" || + update_failed=1 + fi - else - echo -e "${idsCL[Red]}Could not connect to 'git.scity.us' for updates${idsCL[Default]}" - fi -fi + rm -f /var/cache/ta-proxmenu/update-status 2>/dev/null || true -if [ "${1}" != "tapm" ] && [ "${1}" != "update" ] && [ "${1}" != "u" ]; then - /opt/idssys/ta-proxmenu/proxmenu-scripts.sh "${1:-}" "${2:-}" "${3:-}" "${4:-}" -fi + if (( update_failed == 0 )); then + source /opt/idssys/ta-proxmenu/defaults.inc + echo -e "\n${idsCL[Green]}Update check complete. Installed version: ${VERS}${idsCL[Default]}" + return 0 + fi -exit 0 + return 1 +} + +case "${1:-}" in + update|u) + INSTALL_UPDATES + exit $? + ;; + tapm) + exit 0 + ;; +esac + +/opt/idssys/ta-proxmenu/proxmenu-scripts.sh \ + "${1:-}" "${2:-}" "${3:-}" "${4:-}" + +exit $? From ec038e908812029b55fae6a2d1576ac9a242ad83 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 11:48:08 -0500 Subject: [PATCH 11/76] update --- README.md | 8 +++ defaults.inc | 2 +- proxmenu-scripts.sh | 124 +++++++++++++++++++++++++++++++++++++++++++- run.sh | 63 ++++++++++++++++++++++ 4 files changed, 194 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 48a9708..ce90cde 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,14 @@ interactive menu. Update availability is checked in the background and cached; updates are installed only when explicitly selected or requested with `tapm update`. +The required iDSSYS Defaults repository is handled separately: it is +automatically refreshed before launch when its last successful check is more +than four hours old. If the remote is unavailable, the installed copy is used. + +The Utilities menu can safely switch TA-ProxMenu between branches published on +its Git origin. Branch switching is refused when the installed repository has +local changes. + ## Direct actions The menu script also supports these direct actions: diff --git a/defaults.inc b/defaults.inc index 0bd7e01..fe8ca86 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-16' +VERS='2026.7.25-17' noupdate=' ' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 01e4b0c..5c6a496 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -761,9 +761,124 @@ SERVICE_RECOVERY_MENU() { done } +SWITCH_SCRIPT_BRANCH() { + local target_branch="$1" + local current_branch + local choice + + current_branch="$(git -C "$FOLDER" branch --show-current 2>/dev/null)" + if [[ "$target_branch" == "$current_branch" ]]; then + echo -e "\n${idsCL[Green]}TA-ProxMenu is already using '${target_branch}'.${idsCL[Default]}" + ENTER2CONTINUE + return + fi + + if [[ -n "$(git -C "$FOLDER" status --porcelain --untracked-files=normal)" ]]; then + echo -e "\n${idsCL[Red]}The installed TA-ProxMenu repository has local changes.${idsCL[Default]}" + echo "Branch switching was refused to protect those files." + echo + git -C "$FOLDER" status --short + ENTER2CONTINUE + return + fi + + echo -en "\n${idsCL[LightCyan]}Switch TA-ProxMenu from '${current_branch}' to '${target_branch}' (y/N)?${idsCL[Default]} " + read -n 1 choice + echo + [[ "$choice" =~ ^[Yy]$ ]] || return + + if ! timeout 30 git -C "$FOLDER" fetch origin \ + "refs/heads/${target_branch}:refs/remotes/origin/${target_branch}"; then + echo -e "${idsCL[Red]}Failed to fetch '${target_branch}' from origin.${idsCL[Default]}" + ENTER2CONTINUE + return + fi + + if git -C "$FOLDER" show-ref --verify --quiet "refs/heads/${target_branch}"; then + git -C "$FOLDER" switch "$target_branch" || { + ENTER2CONTINUE + return + } + else + git -C "$FOLDER" switch --create "$target_branch" \ + --track "origin/${target_branch}" || { + ENTER2CONTINUE + return + } + fi + + if ! git -C "$FOLDER" reset --hard "origin/${target_branch}"; then + echo -e "${idsCL[Red]}Failed to synchronize '${target_branch}' with origin.${idsCL[Default]}" + ENTER2CONTINUE + return + fi + + rm -f "$UPDATE_CACHE_FILE" + echo -e "\n${idsCL[Green]}Now using TA-ProxMenu branch '${target_branch}'.${idsCL[Default]}" + sleep 1 + exec /opt/idssys/ta-proxmenu/run.sh +} + +BRANCH_MANAGEMENT_MENU() { + local -a labels=() + local -a values=() + local branch + local branch_version + local current_branch + + MENU_HEADER + echo + echo -e " ${idsCL[LightCyan]}Refreshing remote branch list...${idsCL[Default]}" + + if ! timeout 30 git -C "$FOLDER" fetch origin \ + '+refs/heads/*:refs/remotes/origin/*' --prune >/dev/null 2>&1; then + echo -e "\n${idsCL[Red]}Could not retrieve branches from origin.${idsCL[Default]}" + ENTER2CONTINUE + return + fi + + current_branch="$(git -C "$FOLDER" branch --show-current 2>/dev/null)" + while IFS= read -r branch; do + [[ -n "$branch" && "$branch" != "HEAD" ]] || continue + branch_version="$( + git -C "$FOLDER" show "refs/remotes/origin/${branch}:defaults.inc" \ + 2>/dev/null | + awk -F"'" '/^VERS=/{ print $2; exit }' + )" + + if [[ "$branch" == "$current_branch" ]]; then + labels+=("${branch} (current, version ${branch_version:-unknown})") + else + labels+=("${branch} (version ${branch_version:-unknown})") + fi + values+=("branch:${branch}") + done < <( + git -C "$FOLDER" for-each-ref \ + --format='%(refname:strip=3)' refs/remotes/origin | + sort -V + ) + + if (( ${#labels[@]} == 0 )); then + echo -e "\n${idsCL[Red]}No remote branches were found.${idsCL[Default]}" + ENTER2CONTINUE + return + fi + + while true; do + SELECT_MENU "Git Branch Management" labels values + case "$MENU_SELECTION" in + branch:*) + SWITCH_SCRIPT_BRANCH "${MENU_SELECTION#branch:}" + ;; + back) return;; + quit) EXIT1; exit 0;; + esac + done +} + UTILITIES_MENU() { local -a labels - local -a values=("install_update" "check_update" "about") + local -a values=("install_update" "check_update" "branches" "about") local choice while true; do @@ -782,7 +897,11 @@ UTILITIES_MENU() { labels=("Update status unavailable") ;; esac - labels+=("Check again now" "Version and installation information") + labels+=( + "Check again now" + "Git branch management" + "Version and installation information" + ) SELECT_MENU "Utilities" labels values case "$MENU_SELECTION" in @@ -802,6 +921,7 @@ UTILITIES_MENU() { ENTER2CONTINUE ;; check_update) FORCE_UPDATE_CHECK;; + branches) BRANCH_MANAGEMENT_MENU;; about) SHOW_ABOUT;; back) return;; quit) EXIT1; exit 0;; diff --git a/run.sh b/run.sh index 7b93878..357543e 100755 --- a/run.sh +++ b/run.sh @@ -1,6 +1,69 @@ #!/usr/bin/env bash # TA-ProxMenu preloader +DEFAULTS_REPOSITORY='/opt/idssys/defaults' +DEFAULTS_CACHE_DIR='/var/cache/ta-proxmenu' +DEFAULTS_CHECK_FILE="${DEFAULTS_CACHE_DIR}/defaults-last-check" +DEFAULTS_CHECK_SECONDS=14400 + +AUTO_UPDATE_DEFAULTS() { + local checked_at=0 + local local_commit + local now + local remote_commit + + mkdir -p "$DEFAULTS_CACHE_DIR" 2>/dev/null || true + now="$(date +%s)" + [[ -r "$DEFAULTS_CHECK_FILE" ]] && read -r checked_at <"$DEFAULTS_CHECK_FILE" + + if [[ "$checked_at" =~ ^[0-9]+$ ]] && + (( now - checked_at < DEFAULTS_CHECK_SECONDS )); then + return + fi + + exec 9>"${DEFAULTS_CACHE_DIR}/defaults-update.lock" || return + flock -n 9 || return + + # Another process may have completed the update while this one waited. + checked_at=0 + [[ -r "$DEFAULTS_CHECK_FILE" ]] && read -r checked_at <"$DEFAULTS_CHECK_FILE" + if [[ "$checked_at" =~ ^[0-9]+$ ]] && + (( now - checked_at < DEFAULTS_CHECK_SECONDS )); then + return + fi + + if [[ ! -d "${DEFAULTS_REPOSITORY}/.git" ]]; then + echo "iDSSYS Defaults is missing; restoring it from origin..." + mkdir -p /opt/idssys + if ! timeout 60 git clone \ + https://git.scity.us/voltron/iDS-Defaults.git "$DEFAULTS_REPOSITORY"; then + echo "WARNING: Unable to restore iDSSYS Defaults." >&2 + return + fi + else + if ! timeout 20 git -C "$DEFAULTS_REPOSITORY" fetch origin master \ + >/dev/null 2>&1; then + echo "WARNING: Unable to check iDSSYS Defaults; using the installed copy." >&2 + return + fi + + local_commit="$(git -C "$DEFAULTS_REPOSITORY" rev-parse HEAD)" + remote_commit="$(git -C "$DEFAULTS_REPOSITORY" rev-parse origin/master)" + if [[ "$local_commit" != "$remote_commit" ]]; then + echo "Updating required iDSSYS Defaults..." + if ! git -C "$DEFAULTS_REPOSITORY" reset --hard origin/master \ + >/dev/null 2>&1; then + echo "WARNING: Unable to update iDSSYS Defaults; using the installed copy." >&2 + return + fi + fi + fi + + date +%s >"$DEFAULTS_CHECK_FILE" +} + +AUTO_UPDATE_DEFAULTS + [ "${2:-}" != "q" ] && source /opt/idssys/defaults/colors.inc source /opt/idssys/defaults/default.inc source /opt/idssys/ta-proxmenu/defaults.inc From bdd69fbfff3e530217bdc394c373ea3d94ad785d Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 12:08:57 -0500 Subject: [PATCH 12/76] update --- defaults.inc | 2 +- proxmenu-scripts.sh | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/defaults.inc b/defaults.inc index fe8ca86..a88bbc9 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-17' +VERS='2026.7.25-18' noupdate=' ' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 5c6a496..9525e1b 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -82,9 +82,11 @@ INSTALL_SCREENCONNECT() { * ) echo echo -en "\n${idsCL[LightYellow]}Paste the URL provided from the Build Installer: ${idsCL[Default]}" - read -r -e SCURL + read -r -s SCURL + echo [[ -n "$SCURL" ]] || { echo "No URL supplied."; FINISH_ACTION; return; } wget "${SCURL}" -O /tmp/scinstall + unset SCURL dpkg -i /tmp/scinstall apt install --fix-broken -y apt remove "connectwis*" -y > /dev/null 2>&1 @@ -104,18 +106,22 @@ INSTALL_RMM() { echo echo -en "\n${idsCL[LightYellow]}Paste the Linux Server URL provided from the Download Agent screen: ${idsCL[Default]}" - read -r -e RMMURL + read -r -s RMMURL + echo [[ -n "$RMMURL" ]] || { echo "No URL supplied."; FINISH_ACTION; return; } wget "${RMMURL}" -O /tmp/rmminstall if [[ "$RMMURL" != *TKN* || "$RMMURL" != */RUN* ]]; then echo "Unable to extract the RMM token from the URL." + unset RMMURL FINISH_ACTION return fi TOKEN="${RMMURL#*TKN}" TOKEN="${TOKEN%%/RUN*}" + unset RMMURL [[ -n "$TOKEN" ]] || { echo "The RMM token is empty."; FINISH_ACTION; return; } TOKEN="$TOKEN" bash /tmp/rmminstall + unset TOKEN systemctl restart ITSPlatform # rm -f /tmp/rmminstall @@ -127,13 +133,15 @@ INSTALL_RMM() { INSTALL_S1() { echo echo -en "${idsCL[LightYellow]}Paste the customers SentinelOne Site Token: ${idsCL[Default]}" - read -r -e s1token + read -r -s s1token + echo [[ -n "$s1token" ]] || { echo "No SentinelOne site token supplied."; FINISH_ACTION; return; } cd /tmp || return 1 rm -f "/tmp/${S1_PACKAGE}" wget "$S1_DOWNLOAD_URL" -O "/tmp/${S1_PACKAGE}" dpkg -i "/tmp/${S1_PACKAGE}" /opt/sentinelone/bin/sentinelctl management token set "$s1token" + unset s1token /opt/sentinelone/bin/sentinelctl control start rm -f "/tmp/${S1_PACKAGE}" From 22da8f43d24365302619d483f9672749d843b281 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 13:12:16 -0500 Subject: [PATCH 13/76] adding deployment site --- README.md | 9 +++-- defaults.inc | 5 ++- proxmenu-scripts.sh | 92 +++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 96 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ce90cde..cf55475 100644 --- a/README.md +++ b/README.md @@ -47,14 +47,15 @@ keepalived Deploy Keepalived across the cluster ## Companion files -Large installer artifacts used by this project are stored in the separate -`TAI/files` repository. The SentinelOne package filename and displayed version -are defined together in `defaults.inc`. +Large installer artifacts used by this project are stored in the private +`TAI/files` package registry. SentinelOne installation requires a temporary +deployment code from TAPM Deployment Access; the private Gitea credential is +never stored on or returned to a Proxmox host. ## Runtime requirements - Proxmox VE and root privileges -- Bash, Git, curl, wget, and standard Debian package tools +- Bash, Git, curl, wget, Python 3, and standard Debian package tools - `/opt/idssys/defaults/default.inc` - `/opt/idssys/defaults/colors.inc` diff --git a/defaults.inc b/defaults.inc index a88bbc9..770baa2 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-18' +VERS='2026.7.25-19' noupdate=' ' @@ -27,7 +27,8 @@ VIRTIO_FILE="${VIRTIO_DOWNLOAD_URL##*/}" S1_VERSION='26_1_1_31' S1_PACKAGE="SentinelAgent_linux_x86_64_v${S1_VERSION}.deb" -S1_DOWNLOAD_URL="https://git.scity.us/TAI/files/raw/branch/main/${S1_PACKAGE}" +S1_BROKER_URL='https://tapm.scity.us' +S1_BROKER_PACKAGE='sentinelone-linux' if [[ -d /mnt/pve/PVE-Shared-Storage/template/iso ]]; then DLDIR='/mnt/pve/PVE-Shared-Storage/template/iso' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 9525e1b..882bdea 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -131,15 +131,99 @@ INSTALL_RMM() { } INSTALL_S1() { + local deploycode exchange_response host_fingerprint + local -a s1_access + echo + if ! command -v python3 >/dev/null 2>&1; then + echo -e "${idsCL[LightRed]}Python 3 is required to request the protected installer.${idsCL[Default]}" + FINISH_ACTION + return + fi + echo -en "${idsCL[LightYellow]}Paste the TAPM deployment code: ${idsCL[Default]}" + read -r -s deploycode + echo + deploycode="${deploycode^^}" + if [[ ! "$deploycode" =~ ^TAPM-[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$ ]]; then + unset deploycode + echo -e "${idsCL[LightRed]}The TAPM deployment code is not valid.${idsCL[Default]}" + FINISH_ACTION + return + fi + + host_fingerprint="$(sha256sum /etc/machine-id | cut -d' ' -f1)" + exchange_response="$( + TAPM_CODE="$deploycode" TAPM_FINGERPRINT="$host_fingerprint" TAPM_HOSTNAME="$(hostname)" \ + python3 -c 'import json, os, sys; json.dump({"code": os.environ["TAPM_CODE"], "host_fingerprint": os.environ["TAPM_FINGERPRINT"], "hostname": os.environ["TAPM_HOSTNAME"]}, sys.stdout)' | + curl --fail --silent --show-error \ + --header 'Content-Type: application/json' \ + --data-binary @- "${S1_BROKER_URL}/api/v1/exchange" + )" || { + unset deploycode host_fingerprint exchange_response + echo -e "${idsCL[LightRed]}The protected SentinelOne installer could not be authorized.${idsCL[Default]}" + FINISH_ACTION + return + } + unset deploycode host_fingerprint + + mapfile -t s1_access < <( + printf '%s' "$exchange_response" | + S1_SLUG="$S1_BROKER_PACKAGE" python3 -c ' +import json, os, sys +data = json.load(sys.stdin) +package = next((item for item in data.get("packages", []) if item.get("slug") == os.environ["S1_SLUG"]), None) +if not package: + raise SystemExit(1) +print(data.get("session_token", "")) +print(package.get("download_url", "")) +print(package.get("sha256", "")) +' + ) || true + unset exchange_response + if [[ ${#s1_access[@]} -ne 3 || -z "${s1_access[0]}" || + ! "${s1_access[2]}" =~ ^[0-9a-fA-F]{64}$ ]]; then + unset s1_access + echo -e "${idsCL[LightRed]}SentinelOne is not included in this deployment authorization.${idsCL[Default]}" + FINISH_ACTION + return + fi + + rm -f "/tmp/${S1_PACKAGE}" + if ! printf 'header = "Authorization: Bearer %s"\n' "${s1_access[0]}" | + curl --fail --location --show-error --config - \ + --output "/tmp/${S1_PACKAGE}" "${s1_access[1]}"; then + unset s1_access + rm -f "/tmp/${S1_PACKAGE}" + echo -e "${idsCL[LightRed]}The SentinelOne installer download failed.${idsCL[Default]}" + FINISH_ACTION + return + fi + if [[ "$(sha256sum "/tmp/${S1_PACKAGE}" | cut -d' ' -f1)" != "${s1_access[2],,}" ]]; then + unset s1_access + rm -f "/tmp/${S1_PACKAGE}" + echo -e "${idsCL[LightRed]}The SentinelOne installer checksum did not match. The file was removed.${idsCL[Default]}" + FINISH_ACTION + return + fi + unset s1_access + echo -en "${idsCL[LightYellow]}Paste the customers SentinelOne Site Token: ${idsCL[Default]}" read -r -s s1token echo - [[ -n "$s1token" ]] || { echo "No SentinelOne site token supplied."; FINISH_ACTION; return; } + [[ -n "$s1token" ]] || { + rm -f "/tmp/${S1_PACKAGE}" + echo "No SentinelOne site token supplied." + FINISH_ACTION + return + } cd /tmp || return 1 - rm -f "/tmp/${S1_PACKAGE}" - wget "$S1_DOWNLOAD_URL" -O "/tmp/${S1_PACKAGE}" - dpkg -i "/tmp/${S1_PACKAGE}" + if ! dpkg -i "/tmp/${S1_PACKAGE}"; then + unset s1token + rm -f "/tmp/${S1_PACKAGE}" + echo -e "${idsCL[LightRed]}SentinelOne installation failed.${idsCL[Default]}" + FINISH_ACTION + return + fi /opt/sentinelone/bin/sentinelctl management token set "$s1token" unset s1token /opt/sentinelone/bin/sentinelctl control start From bb781e648c74bacbe2739c49c1a37a906448116c Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 14:51:42 -0500 Subject: [PATCH 14/76] update --- README.md | 8 ++- defaults.inc | 7 +- proxmenu-scripts.sh | 168 ++++++++++++++++++++++++++++---------------- 3 files changed, 116 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index cf55475..bf00873 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,11 @@ keepalived Deploy Keepalived across the cluster ## Companion files Large installer artifacts used by this project are stored in the private -`TAI/files` package registry. SentinelOne installation requires a temporary -deployment code from TAPM Deployment Access; the private Gitea credential is -never stored on or returned to a Proxmox host. +`TAI/files` package registry. SentinelOne, RMM, Acronis, and ScreenConnect +installation require a temporary deployment code from TAPM Deployment Access. +The private Gitea credentials are never stored on or returned to a Proxmox +host. SentinelOne package versions can be updated through the deployment +portal without changing ProxMenu. ## Runtime requirements diff --git a/defaults.inc b/defaults.inc index 770baa2..abb25cc 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-19' +VERS='2026.7.25-20' noupdate=' ' @@ -25,10 +25,9 @@ if [[ -z "$VIRTIO_DOWNLOAD_URL" ]]; then fi VIRTIO_FILE="${VIRTIO_DOWNLOAD_URL##*/}" -S1_VERSION='26_1_1_31' -S1_PACKAGE="SentinelAgent_linux_x86_64_v${S1_VERSION}.deb" -S1_BROKER_URL='https://tapm.scity.us' +TAPM_BROKER_URL='https://tapm.scity.us' S1_BROKER_PACKAGE='sentinelone-linux' +S1_PACKAGE='tapm-sentinelone.deb' if [[ -d /mnt/pve/PVE-Shared-Storage/template/iso ]]; then DLDIR='/mnt/pve/PVE-Shared-Storage/template/iso' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 882bdea..3825af8 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -14,6 +14,91 @@ FINISH_ACTION() { ENTER2CONTINUE } +TAPM_CLEAR_AUTHORIZATION() { + unset TAPM_SESSION_TOKEN TAPM_PACKAGE_URL TAPM_PACKAGE_SHA256 TAPM_PACKAGE_VERSION +} + +TAPM_AUTHORIZE() { + local required_action="${1:-}" + local required_package="${2:-}" + local authorization_label="${3:-this installation}" + local deploycode exchange_response host_fingerprint + local -a access + + TAPM_CLEAR_AUTHORIZATION + if ! command -v python3 >/dev/null 2>&1; then + echo -e "${idsCL[LightRed]}Python 3 is required to authorize ${authorization_label}.${idsCL[Default]}" + return 1 + fi + echo + echo -en "${idsCL[LightYellow]}Paste the TAPM deployment code: ${idsCL[Default]}" + read -r -s deploycode + echo + deploycode="${deploycode^^}" + if [[ ! "$deploycode" =~ ^TAPM-[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$ ]]; then + unset deploycode + echo -e "${idsCL[LightRed]}The TAPM deployment code is not valid.${idsCL[Default]}" + return 1 + fi + + host_fingerprint="$(sha256sum /etc/machine-id | cut -d' ' -f1)" + exchange_response="$( + TAPM_CODE="$deploycode" TAPM_FINGERPRINT="$host_fingerprint" TAPM_HOSTNAME="$(hostname)" \ + TAPM_REQUESTED_ACTION="$required_action" TAPM_REQUESTED_PACKAGE="$required_package" \ + python3 -c 'import json, os, sys; json.dump({"code": os.environ["TAPM_CODE"], "host_fingerprint": os.environ["TAPM_FINGERPRINT"], "hostname": os.environ["TAPM_HOSTNAME"], "requested_action": os.environ["TAPM_REQUESTED_ACTION"], "requested_package": os.environ["TAPM_REQUESTED_PACKAGE"]}, sys.stdout)' | + curl --fail --silent --show-error \ + --header 'Content-Type: application/json' \ + --data-binary @- "${TAPM_BROKER_URL}/api/v1/exchange" + )" || { + unset deploycode host_fingerprint exchange_response + echo -e "${idsCL[LightRed]}TAPM could not authorize ${authorization_label}.${idsCL[Default]}" + return 1 + } + unset deploycode host_fingerprint + + mapfile -t access < <( + printf '%s' "$exchange_response" | + TAPM_REQUIRED_ACTION="$required_action" TAPM_REQUIRED_PACKAGE="$required_package" \ + python3 -c ' +import json, os, sys +data = json.load(sys.stdin) +required_action = os.environ["TAPM_REQUIRED_ACTION"] +required_package = os.environ["TAPM_REQUIRED_PACKAGE"] +if required_action and required_action not in data.get("actions", []): + raise SystemExit(1) +package = None +if required_package: + package = next((item for item in data.get("packages", []) if item.get("slug") == required_package), None) + if not package: + raise SystemExit(1) +print(data.get("session_token", "")) +print(package.get("download_url", "") if package else "") +print(package.get("sha256", "") if package else "") +print(package.get("version", "") if package else "") +' + ) || true + unset exchange_response + + if [[ ${#access[@]} -ne 4 || -z "${access[0]}" ]]; then + unset access + echo -e "${idsCL[LightRed]}This deployment code does not authorize ${authorization_label}.${idsCL[Default]}" + return 1 + fi + if [[ -n "$required_package" && + ( -z "${access[1]}" || ! "${access[2]}" =~ ^[0-9a-fA-F]{64}$ ) ]]; then + unset access + echo -e "${idsCL[LightRed]}The authorized package metadata is incomplete.${idsCL[Default]}" + return 1 + fi + + TAPM_SESSION_TOKEN="${access[0]}" + TAPM_PACKAGE_URL="${access[1]}" + TAPM_PACKAGE_SHA256="${access[2],,}" + TAPM_PACKAGE_VERSION="${access[3]}" + unset access + return 0 +} + INSTALL_PULSE() { echo bash <(curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh) @@ -28,6 +113,11 @@ INSTALL_ACRONIS() { [Nn]) echo;; * ) echo + if ! TAPM_AUTHORIZE "install-acronis" "" "Acronis installation"; then + FINISH_ACTION + return + fi + TAPM_CLEAR_AUTHORIZATION cd /tmp || return 1 wget "https://us5-cloud.acronis.com/bc/api/ams/links/agents/redirect?language=multi&channel=CURRENT&system=linux&architecture=64&productType=enterprise&login=010180ae-63c4-4495-bed0-4ec934c25af9&white_labeled=0" -O ./acronisinstall chmod +x ./acronisinstall @@ -81,6 +171,11 @@ INSTALL_SCREENCONNECT() { [Nn]) echo;; * ) echo + if ! TAPM_AUTHORIZE "install-screenconnect" "" "ScreenConnect installation"; then + FINISH_ACTION + return + fi + TAPM_CLEAR_AUTHORIZATION echo -en "\n${idsCL[LightYellow]}Paste the URL provided from the Build Installer: ${idsCL[Default]}" read -r -s SCURL echo @@ -104,6 +199,11 @@ INSTALL_RMM() { [Nn]) echo;; * ) echo + if ! TAPM_AUTHORIZE "install-rmm" "" "RMM installation"; then + FINISH_ACTION + return + fi + TAPM_CLEAR_AUTHORIZATION echo -en "\n${idsCL[LightYellow]}Paste the Linux Server URL provided from the Download Agent screen: ${idsCL[Default]}" read -r -s RMMURL @@ -131,81 +231,29 @@ INSTALL_RMM() { } INSTALL_S1() { - local deploycode exchange_response host_fingerprint - local -a s1_access - - echo - if ! command -v python3 >/dev/null 2>&1; then - echo -e "${idsCL[LightRed]}Python 3 is required to request the protected installer.${idsCL[Default]}" - FINISH_ACTION - return - fi - echo -en "${idsCL[LightYellow]}Paste the TAPM deployment code: ${idsCL[Default]}" - read -r -s deploycode - echo - deploycode="${deploycode^^}" - if [[ ! "$deploycode" =~ ^TAPM-[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$ ]]; then - unset deploycode - echo -e "${idsCL[LightRed]}The TAPM deployment code is not valid.${idsCL[Default]}" - FINISH_ACTION - return - fi - - host_fingerprint="$(sha256sum /etc/machine-id | cut -d' ' -f1)" - exchange_response="$( - TAPM_CODE="$deploycode" TAPM_FINGERPRINT="$host_fingerprint" TAPM_HOSTNAME="$(hostname)" \ - python3 -c 'import json, os, sys; json.dump({"code": os.environ["TAPM_CODE"], "host_fingerprint": os.environ["TAPM_FINGERPRINT"], "hostname": os.environ["TAPM_HOSTNAME"]}, sys.stdout)' | - curl --fail --silent --show-error \ - --header 'Content-Type: application/json' \ - --data-binary @- "${S1_BROKER_URL}/api/v1/exchange" - )" || { - unset deploycode host_fingerprint exchange_response - echo -e "${idsCL[LightRed]}The protected SentinelOne installer could not be authorized.${idsCL[Default]}" - FINISH_ACTION - return - } - unset deploycode host_fingerprint - - mapfile -t s1_access < <( - printf '%s' "$exchange_response" | - S1_SLUG="$S1_BROKER_PACKAGE" python3 -c ' -import json, os, sys -data = json.load(sys.stdin) -package = next((item for item in data.get("packages", []) if item.get("slug") == os.environ["S1_SLUG"]), None) -if not package: - raise SystemExit(1) -print(data.get("session_token", "")) -print(package.get("download_url", "")) -print(package.get("sha256", "")) -' - ) || true - unset exchange_response - if [[ ${#s1_access[@]} -ne 3 || -z "${s1_access[0]}" || - ! "${s1_access[2]}" =~ ^[0-9a-fA-F]{64}$ ]]; then - unset s1_access - echo -e "${idsCL[LightRed]}SentinelOne is not included in this deployment authorization.${idsCL[Default]}" + if ! TAPM_AUTHORIZE "" "$S1_BROKER_PACKAGE" "SentinelOne installation"; then FINISH_ACTION return fi rm -f "/tmp/${S1_PACKAGE}" - if ! printf 'header = "Authorization: Bearer %s"\n' "${s1_access[0]}" | + if ! printf 'header = "Authorization: Bearer %s"\n' "$TAPM_SESSION_TOKEN" | curl --fail --location --show-error --config - \ - --output "/tmp/${S1_PACKAGE}" "${s1_access[1]}"; then - unset s1_access + --output "/tmp/${S1_PACKAGE}" "$TAPM_PACKAGE_URL"; then + TAPM_CLEAR_AUTHORIZATION rm -f "/tmp/${S1_PACKAGE}" echo -e "${idsCL[LightRed]}The SentinelOne installer download failed.${idsCL[Default]}" FINISH_ACTION return fi - if [[ "$(sha256sum "/tmp/${S1_PACKAGE}" | cut -d' ' -f1)" != "${s1_access[2],,}" ]]; then - unset s1_access + if [[ "$(sha256sum "/tmp/${S1_PACKAGE}" | cut -d' ' -f1)" != "$TAPM_PACKAGE_SHA256" ]]; then + TAPM_CLEAR_AUTHORIZATION rm -f "/tmp/${S1_PACKAGE}" echo -e "${idsCL[LightRed]}The SentinelOne installer checksum did not match. The file was removed.${idsCL[Default]}" FINISH_ACTION return fi - unset s1_access + TAPM_CLEAR_AUTHORIZATION echo -en "${idsCL[LightYellow]}Paste the customers SentinelOne Site Token: ${idsCL[Default]}" read -r -s s1token @@ -773,7 +821,7 @@ MONITORING_MENU() { dpkg-query -W -f='${Status}' sentinelagent 2>/dev/null | grep -q "install ok installed" && labels+=("SentinelOne agent (installed)") || - labels+=("Install SentinelOne agent v${S1_VERSION//_/.}") + labels+=("Install SentinelOne agent") systemctl is-active --quiet 'connectwise*' && labels+=("ScreenConnect agent (installed)") || From 377be371c52a08d1cc03ebc8917931abb8471410 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 15:08:44 -0500 Subject: [PATCH 15/76] Update defaults.inc --- defaults.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/defaults.inc b/defaults.inc index abb25cc..0e31202 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-20' +VERS='2026.7.25-21' noupdate=' ' From 707cd24d8d5ace5faa63a7d7873625090d7111b0 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 15:14:27 -0500 Subject: [PATCH 16/76] Update defaults.inc --- defaults.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/defaults.inc b/defaults.inc index 0e31202..72419bf 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-21' +VERS='2026.7.25-23' noupdate=' ' From dc1cf6a86354edcc45d07db78169fcec8649a5e7 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 15:18:46 -0500 Subject: [PATCH 17/76] Update defaults.inc --- defaults.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/defaults.inc b/defaults.inc index 72419bf..a2575d6 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-23' +VERS='2026.7.25-24' noupdate=' ' From 2f4f45f64fcf79ba14aee8dc2d51d85d41c3c3d1 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 15:25:33 -0500 Subject: [PATCH 18/76] Update defaults.inc --- defaults.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/defaults.inc b/defaults.inc index a2575d6..b79bf44 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-24' +VERS='2026.7.25-25' noupdate=' ' From 8696de15591b898c8a9036166cf040dce0093e5a Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 15:30:15 -0500 Subject: [PATCH 19/76] Update defaults.inc --- defaults.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/defaults.inc b/defaults.inc index b79bf44..4074c70 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-25' +VERS='2026.7.25-27' noupdate=' ' From 601457f9d08a21eff92c17b00d8ea856963ab885 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 15:35:51 -0500 Subject: [PATCH 20/76] update --- defaults.inc | 2 +- proxmenu-scripts.sh | 180 +++++++++++++++++--------------------------- 2 files changed, 72 insertions(+), 110 deletions(-) diff --git a/defaults.inc b/defaults.inc index 4074c70..2897e59 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-27' +VERS='2026.7.25-28' noupdate=' ' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 3825af8..1001d90 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -108,39 +108,26 @@ INSTALL_PULSE() { } INSTALL_ACRONIS() { - read -n 1 -p "Are you sure you wish to install Acronis (Y/n)?" choice - case "$choice" in - [Nn]) echo;; - * ) - echo - if ! TAPM_AUTHORIZE "install-acronis" "" "Acronis installation"; then - FINISH_ACTION - return - fi - TAPM_CLEAR_AUTHORIZATION - cd /tmp || return 1 - wget "https://us5-cloud.acronis.com/bc/api/ams/links/agents/redirect?language=multi&channel=CURRENT&system=linux&architecture=64&productType=enterprise&login=010180ae-63c4-4495-bed0-4ec934c25af9&white_labeled=0" -O ./acronisinstall - chmod +x ./acronisinstall - ./acronisinstall - rm -f ./acronisinstall - echo - echo -e "\n${idsCL[Green]}Acronis has been installed${idsCL[Default]}" - FINISH_ACTION - ;; - esac + echo + if ! TAPM_AUTHORIZE "install-acronis" "" "Acronis installation"; then + FINISH_ACTION + return + fi + TAPM_CLEAR_AUTHORIZATION + cd /tmp || return 1 + wget "https://us5-cloud.acronis.com/bc/api/ams/links/agents/redirect?language=multi&channel=CURRENT&system=linux&architecture=64&productType=enterprise&login=010180ae-63c4-4495-bed0-4ec934c25af9&white_labeled=0" -O ./acronisinstall + chmod +x ./acronisinstall + ./acronisinstall + rm -f ./acronisinstall + echo + echo -e "\n${idsCL[Green]}Acronis has been installed${idsCL[Default]}" + FINISH_ACTION } INSTALL_PROXMENUX() { -# read -n 1 -p "Are you sure you wish to install ProxMenux (Y/n)?" choice -# case "$choice" in -# [Nn]) MAIN_MENU;; -# * ) -# echo - bash -c "$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/main/install_proxmenux.sh)" + bash -c "$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/main/install_proxmenux.sh)" # systemctl disable --now proxmenux-monitor - menu -# echo -e "\n${idsCL[Green]}ProxMenux has been installed${idsCL[Default]}" -# esac + menu } PROXMENUX_POST_INSTALL() { @@ -154,80 +141,65 @@ PROXMENUX_POST_INSTALL() { } INSTALL_GLANCES() { - read -n 1 -p "Are you sure you wish to install Glances (Y/n)?" choice - case "$choice" in - [Nn]) echo;; - * ) - echo - apt install glances -y - echo -e "\n${idsCL[Green]}Glances has been installed${idsCL[Default]}" - FINISH_ACTION - esac + echo + apt install glances -y + echo -e "\n${idsCL[Green]}Glances has been installed${idsCL[Default]}" + FINISH_ACTION } INSTALL_SCREENCONNECT() { - read -n 1 -p "Are you sure you wish to install ScreenConnect (Y/n)?" choice - case "$choice" in - [Nn]) echo;; - * ) - echo - if ! TAPM_AUTHORIZE "install-screenconnect" "" "ScreenConnect installation"; then - FINISH_ACTION - return - fi - TAPM_CLEAR_AUTHORIZATION - echo -en "\n${idsCL[LightYellow]}Paste the URL provided from the Build Installer: ${idsCL[Default]}" - read -r -s SCURL - echo - [[ -n "$SCURL" ]] || { echo "No URL supplied."; FINISH_ACTION; return; } - wget "${SCURL}" -O /tmp/scinstall - unset SCURL - dpkg -i /tmp/scinstall - apt install --fix-broken -y - apt remove "connectwis*" -y > /dev/null 2>&1 - dpkg -i /tmp/scinstall - rm -f /tmp/scinstall - systemctl disable --now proxmenux-monitor - echo -e "\n${idsCL[Green]}ScreenConnect has been installed${idsCL[Default]}" + echo + if ! TAPM_AUTHORIZE "install-screenconnect" "" "ScreenConnect installation"; then FINISH_ACTION - esac + return + fi + TAPM_CLEAR_AUTHORIZATION + echo -en "\n${idsCL[LightYellow]}Paste the URL provided from the Build Installer: ${idsCL[Default]}" + read -r -s SCURL + echo + [[ -n "$SCURL" ]] || { echo "No URL supplied."; FINISH_ACTION; return; } + wget "${SCURL}" -O /tmp/scinstall + unset SCURL + dpkg -i /tmp/scinstall + apt install --fix-broken -y + apt remove "connectwis*" -y > /dev/null 2>&1 + dpkg -i /tmp/scinstall + rm -f /tmp/scinstall + systemctl disable --now proxmenux-monitor + echo -e "\n${idsCL[Green]}ScreenConnect has been installed${idsCL[Default]}" + FINISH_ACTION } INSTALL_RMM() { - read -n 1 -p "Are you sure you wish to install RMM (Y/n)?" choice - case "$choice" in - [Nn]) echo;; - * ) - echo - if ! TAPM_AUTHORIZE "install-rmm" "" "RMM installation"; then - FINISH_ACTION - return - fi - TAPM_CLEAR_AUTHORIZATION - - echo -en "\n${idsCL[LightYellow]}Paste the Linux Server URL provided from the Download Agent screen: ${idsCL[Default]}" - read -r -s RMMURL - echo - [[ -n "$RMMURL" ]] || { echo "No URL supplied."; FINISH_ACTION; return; } - wget "${RMMURL}" -O /tmp/rmminstall - if [[ "$RMMURL" != *TKN* || "$RMMURL" != */RUN* ]]; then - echo "Unable to extract the RMM token from the URL." - unset RMMURL - FINISH_ACTION - return - fi - TOKEN="${RMMURL#*TKN}" - TOKEN="${TOKEN%%/RUN*}" - unset RMMURL - [[ -n "$TOKEN" ]] || { echo "The RMM token is empty."; FINISH_ACTION; return; } - TOKEN="$TOKEN" bash /tmp/rmminstall - unset TOKEN - systemctl restart ITSPlatform - # rm -f /tmp/rmminstall - - echo -e "\n${idsCL[Green]}RMM has been installed${idsCL[Default]}" + echo + if ! TAPM_AUTHORIZE "install-rmm" "" "RMM installation"; then FINISH_ACTION - esac + return + fi + TAPM_CLEAR_AUTHORIZATION + + echo -en "\n${idsCL[LightYellow]}Paste the Linux Server URL provided from the Download Agent screen: ${idsCL[Default]}" + read -r -s RMMURL + echo + [[ -n "$RMMURL" ]] || { echo "No URL supplied."; FINISH_ACTION; return; } + wget "${RMMURL}" -O /tmp/rmminstall + if [[ "$RMMURL" != *TKN* || "$RMMURL" != */RUN* ]]; then + echo "Unable to extract the RMM token from the URL." + unset RMMURL + FINISH_ACTION + return + fi + TOKEN="${RMMURL#*TKN}" + TOKEN="${TOKEN%%/RUN*}" + unset RMMURL + [[ -n "$TOKEN" ]] || { echo "The RMM token is empty."; FINISH_ACTION; return; } + TOKEN="$TOKEN" bash /tmp/rmminstall + unset TOKEN + systemctl restart ITSPlatform + # rm -f /tmp/rmminstall + + echo -e "\n${idsCL[Green]}RMM has been installed${idsCL[Default]}" + FINISH_ACTION } INSTALL_S1() { @@ -282,10 +254,6 @@ INSTALL_S1() { } INSTALL_OMSA() { - read -n 1 -p "Are you sure you wish to install Dell OpenManage Administrator (Y/n)?" choice - case "$choice" in - [Nn]) echo;; - * ) echo mkdir -p /tmp/omsa cd /tmp/omsa || return 1 @@ -327,7 +295,6 @@ INSTALL_OMSA() { echo -e "\n${idsCL[Green]}Dell OMSA has been installed${idsCL[Default]}" echo -e "\n${idsCL[LightCyan]}Available at: ${idsCL[LightGreen]}https://${RNIP}:1311${idsCL[Default]}" FINISH_ACTION - esac } DOWNLOAD_VIRTIO() { @@ -486,17 +453,12 @@ MAINTENANCE_MODE(){ } INSTALL_KEEPALIVE() { - read -n 1 -p "Are you sure you wish to install Keepalive on all Hosts (Y/n)?" choice - case "$choice" in - [Nn]) echo;; - * ) - echo + echo - bash /opt/idssys/ta-proxmenu/inc/deploy-proxmox-keepalived.sh + bash /opt/idssys/ta-proxmenu/inc/deploy-proxmox-keepalived.sh - echo -e "\n${idsCL[Green]}Keepalive has been installed${idsCL[Default]}" - FINISH_ACTION - esac + echo -e "\n${idsCL[Green]}Keepalive has been installed${idsCL[Default]}" + FINISH_ACTION } UPDATE_CACHE_DIR='/var/cache/ta-proxmenu' From b324d31273d5f6e3cc0555c7f756f656111fe43d Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 16:39:46 -0500 Subject: [PATCH 21/76] update --- defaults.inc | 2 +- proxmenu-scripts.sh | 363 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 306 insertions(+), 59 deletions(-) diff --git a/defaults.inc b/defaults.inc index 2897e59..5b8ec01 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-28' +VERS='2026.7.25-29' noupdate=' ' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 1001d90..7ec57bb 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -14,6 +14,101 @@ FINISH_ACTION() { ENTER2CONTINUE } +FINISH_FAILED_ACTION() { + (( ACTION_REQUESTED == 1 )) && exit 1 + ENTER2CONTINUE +} + +declare -a TAPM_TEMP_DIRS=() +TAPM_TEMP_DIR='' + +TAPM_CLEAN_TEMP_DIR() { + local temp_dir="${1:-}" + + if [[ "$temp_dir" == /tmp/ta-proxmenu-* && -d "$temp_dir" ]]; then + rm -rf -- "$temp_dir" + fi +} + +TAPM_CLEAN_ALL_TEMP_DIRS() { + local temp_dir + + for temp_dir in "${TAPM_TEMP_DIRS[@]}"; do + TAPM_CLEAN_TEMP_DIR "$temp_dir" + done +} + +TAPM_CREATE_TEMP_DIR() { + local label="${1:-installer}" + + TAPM_TEMP_DIR="$(mktemp -d "/tmp/ta-proxmenu-${label}.XXXXXX")" || { + echo -e "${idsCL[LightRed]}Unable to create a temporary installer directory.${idsCL[Default]}" + return 1 + } + if ! chmod 0700 "$TAPM_TEMP_DIR"; then + TAPM_CLEAN_TEMP_DIR "$TAPM_TEMP_DIR" + echo -e "${idsCL[LightRed]}Unable to secure the temporary installer directory.${idsCL[Default]}" + return 1 + fi + TAPM_TEMP_DIRS+=("$TAPM_TEMP_DIR") +} + +TAPM_VALID_HTTPS_URL() { + local url="${1:-}" + + [[ "$url" == https://* && + "$url" != *$'\n'* && + "$url" != *$'\r'* && + "$url" != *'"'* && + "$url" != *\\* && + "$url" != *[[:space:]]* ]] +} + +TAPM_DOWNLOAD_HTTPS() { + local url="$1" + local destination="$2" + local label="${3:-Installer}" + + if ! TAPM_VALID_HTTPS_URL "$url"; then + echo -e "${idsCL[LightRed]}${label} requires a valid HTTPS URL.${idsCL[Default]}" + return 1 + fi + + if ! printf 'url = "%s"\n' "$url" | + curl --fail --location --silent --show-error \ + --proto '=https' --proto-redir '=https' \ + --output "$destination" --config -; then + echo -e "${idsCL[LightRed]}${label} download failed.${idsCL[Default]}" + return 1 + fi + + if [[ ! -s "$destination" ]]; then + echo -e "${idsCL[LightRed]}${label} download was empty.${idsCL[Default]}" + return 1 + fi +} + +TAPM_PACKAGE_INSTALLED() { + dpkg-query -W -f='${Status}' "$1" 2>/dev/null | + grep -q '^install ok installed$' +} + +TAPM_WAIT_FOR_SERVICE() { + local service="$1" + local attempts="${2:-30}" + local attempt=0 + + while (( attempt < attempts )); do + systemctl is-active --quiet "$service" && return 0 + sleep 1 + ((attempt++)) + done + + return 1 +} + +trap TAPM_CLEAN_ALL_TEMP_DIRS EXIT + TAPM_CLEAR_AUTHORIZATION() { unset TAPM_SESSION_TOKEN TAPM_PACKAGE_URL TAPM_PACKAGE_SHA256 TAPM_PACKAGE_VERSION } @@ -79,7 +174,8 @@ print(package.get("version", "") if package else "") ) || true unset exchange_response - if [[ ${#access[@]} -ne 4 || -z "${access[0]}" ]]; then + if [[ ${#access[@]} -ne 4 || + ! "${access[0]}" =~ ^[A-Za-z0-9._~-]+$ ]]; then unset access echo -e "${idsCL[LightRed]}This deployment code does not authorize ${authorization_label}.${idsCL[Default]}" return 1 @@ -100,34 +196,92 @@ print(package.get("version", "") if package else "") } INSTALL_PULSE() { + local installer + local temp_dir + echo - bash <(curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh) + if ! TAPM_CREATE_TEMP_DIR pulse; then + FINISH_FAILED_ACTION + return + fi + temp_dir="$TAPM_TEMP_DIR" + installer="${temp_dir}/install.sh" + + if ! TAPM_DOWNLOAD_HTTPS \ + 'https://github.com/rcourtman/Pulse/releases/latest/download/install.sh' \ + "$installer" 'Pulse installer' || + ! bash "$installer"; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}Pulse installation failed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + TAPM_CLEAN_TEMP_DIR "$temp_dir" echo - echo -e "\n${idsCL[Green]}Pulse has been installed${idsCL[Default]}" + echo -e "\n${idsCL[Green]}Pulse installer completed successfully.${idsCL[Default]}" FINISH_ACTION } INSTALL_ACRONIS() { + local installer + local temp_dir + local url='https://us5-cloud.acronis.com/bc/api/ams/links/agents/redirect?language=multi&channel=CURRENT&system=linux&architecture=64&productType=enterprise&login=010180ae-63c4-4495-bed0-4ec934c25af9&white_labeled=0' + echo if ! TAPM_AUTHORIZE "install-acronis" "" "Acronis installation"; then - FINISH_ACTION + FINISH_FAILED_ACTION return fi TAPM_CLEAR_AUTHORIZATION - cd /tmp || return 1 - wget "https://us5-cloud.acronis.com/bc/api/ams/links/agents/redirect?language=multi&channel=CURRENT&system=linux&architecture=64&productType=enterprise&login=010180ae-63c4-4495-bed0-4ec934c25af9&white_labeled=0" -O ./acronisinstall - chmod +x ./acronisinstall - ./acronisinstall - rm -f ./acronisinstall + + if ! TAPM_CREATE_TEMP_DIR acronis; then + FINISH_FAILED_ACTION + return + fi + temp_dir="$TAPM_TEMP_DIR" + installer="${temp_dir}/acronisinstall" + + if ! TAPM_DOWNLOAD_HTTPS "$url" "$installer" 'Acronis installer' || + ! chmod 0700 "$installer" || + ! (cd "$temp_dir" && ./acronisinstall) || + ! TAPM_PACKAGE_INSTALLED cyberprotect; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}Acronis installation failed or could not be verified.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + TAPM_CLEAN_TEMP_DIR "$temp_dir" echo - echo -e "\n${idsCL[Green]}Acronis has been installed${idsCL[Default]}" + echo -e "\n${idsCL[Green]}Acronis has been installed and verified.${idsCL[Default]}" FINISH_ACTION } INSTALL_PROXMENUX() { - bash -c "$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/main/install_proxmenux.sh)" - # systemctl disable --now proxmenux-monitor - menu + local installer + local temp_dir + + if ! TAPM_CREATE_TEMP_DIR proxmenux; then + FINISH_FAILED_ACTION + return + fi + temp_dir="$TAPM_TEMP_DIR" + installer="${temp_dir}/install.sh" + + if ! TAPM_DOWNLOAD_HTTPS \ + 'https://raw.githubusercontent.com/MacRimi/ProxMenux/main/install_proxmenux.sh' \ + "$installer" 'ProxMenux installer' || + ! bash "$installer" || + [[ ! -x /usr/local/bin/menu ]]; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}ProxMenux installation failed or could not be verified.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + TAPM_CLEAN_TEMP_DIR "$temp_dir" + /usr/local/bin/menu } PROXMENUX_POST_INSTALL() { @@ -142,38 +296,79 @@ PROXMENUX_POST_INSTALL() { INSTALL_GLANCES() { echo - apt install glances -y - echo -e "\n${idsCL[Green]}Glances has been installed${idsCL[Default]}" + if ! DEBIAN_FRONTEND=noninteractive apt-get install glances -y || + ! TAPM_PACKAGE_INSTALLED glances; then + echo -e "\n${idsCL[LightRed]}Glances installation failed or could not be verified.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + echo -e "\n${idsCL[Green]}Glances has been installed and verified.${idsCL[Default]}" FINISH_ACTION } INSTALL_SCREENCONNECT() { + local SCURL='' + local installer + local temp_dir + echo if ! TAPM_AUTHORIZE "install-screenconnect" "" "ScreenConnect installation"; then - FINISH_ACTION + FINISH_FAILED_ACTION return fi TAPM_CLEAR_AUTHORIZATION echo -en "\n${idsCL[LightYellow]}Paste the URL provided from the Build Installer: ${idsCL[Default]}" read -r -s SCURL echo - [[ -n "$SCURL" ]] || { echo "No URL supplied."; FINISH_ACTION; return; } - wget "${SCURL}" -O /tmp/scinstall + [[ -n "$SCURL" ]] || { echo "No URL supplied."; FINISH_FAILED_ACTION; return; } + if ! TAPM_CREATE_TEMP_DIR screenconnect; then + unset SCURL + FINISH_FAILED_ACTION + return + fi + temp_dir="$TAPM_TEMP_DIR" + installer="${temp_dir}/screenconnect.deb" + + if ! TAPM_DOWNLOAD_HTTPS "$SCURL" "$installer" 'ScreenConnect installer'; then + unset SCURL + TAPM_CLEAN_TEMP_DIR "$temp_dir" + FINISH_FAILED_ACTION + return + fi unset SCURL - dpkg -i /tmp/scinstall - apt install --fix-broken -y - apt remove "connectwis*" -y > /dev/null 2>&1 - dpkg -i /tmp/scinstall - rm -f /tmp/scinstall - systemctl disable --now proxmenux-monitor - echo -e "\n${idsCL[Green]}ScreenConnect has been installed${idsCL[Default]}" + + if ! dpkg -i "$installer"; then + if ! DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}ScreenConnect dependency installation failed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + fi + DEBIAN_FRONTEND=noninteractive apt-get remove 'connectwis*' -y >/dev/null 2>&1 || true + if ! dpkg -i "$installer" || + ! TAPM_WAIT_FOR_SERVICE 'connectwise*'; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}ScreenConnect installation failed or its service is not active.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + TAPM_CLEAN_TEMP_DIR "$temp_dir" + systemctl disable --now proxmenux-monitor >/dev/null 2>&1 || true + echo -e "\n${idsCL[Green]}ScreenConnect has been installed and verified.${idsCL[Default]}" FINISH_ACTION } INSTALL_RMM() { + local RMMURL='' + local TOKEN='' + local installer + local temp_dir + echo if ! TAPM_AUTHORIZE "install-rmm" "" "RMM installation"; then - FINISH_ACTION + FINISH_FAILED_ACTION return fi TAPM_CLEAR_AUTHORIZATION @@ -181,48 +376,100 @@ INSTALL_RMM() { echo -en "\n${idsCL[LightYellow]}Paste the Linux Server URL provided from the Download Agent screen: ${idsCL[Default]}" read -r -s RMMURL echo - [[ -n "$RMMURL" ]] || { echo "No URL supplied."; FINISH_ACTION; return; } - wget "${RMMURL}" -O /tmp/rmminstall + [[ -n "$RMMURL" ]] || { echo "No URL supplied."; FINISH_FAILED_ACTION; return; } if [[ "$RMMURL" != *TKN* || "$RMMURL" != */RUN* ]]; then echo "Unable to extract the RMM token from the URL." unset RMMURL - FINISH_ACTION + FINISH_FAILED_ACTION return fi TOKEN="${RMMURL#*TKN}" TOKEN="${TOKEN%%/RUN*}" - unset RMMURL - [[ -n "$TOKEN" ]] || { echo "The RMM token is empty."; FINISH_ACTION; return; } - TOKEN="$TOKEN" bash /tmp/rmminstall - unset TOKEN - systemctl restart ITSPlatform - # rm -f /tmp/rmminstall + if [[ -z "$TOKEN" ]]; then + unset RMMURL TOKEN + echo "The RMM token is empty." + FINISH_FAILED_ACTION + return + fi - echo -e "\n${idsCL[Green]}RMM has been installed${idsCL[Default]}" + if ! TAPM_CREATE_TEMP_DIR rmm; then + unset RMMURL TOKEN + FINISH_FAILED_ACTION + return + fi + temp_dir="$TAPM_TEMP_DIR" + installer="${temp_dir}/rmminstall" + if ! TAPM_DOWNLOAD_HTTPS "$RMMURL" "$installer" 'RMM installer'; then + unset RMMURL TOKEN + TAPM_CLEAN_TEMP_DIR "$temp_dir" + FINISH_FAILED_ACTION + return + fi + unset RMMURL + + if ! TOKEN="$TOKEN" bash "$installer"; then + unset TOKEN + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}RMM installation failed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + unset TOKEN + TAPM_CLEAN_TEMP_DIR "$temp_dir" + + if ! systemctl restart ITSPlatform || + ! TAPM_WAIT_FOR_SERVICE ITSPlatform; then + echo -e "${idsCL[LightRed]}RMM installed, but the ITSPlatform service is not active.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + echo -e "\n${idsCL[Green]}RMM has been installed and verified.${idsCL[Default]}" FINISH_ACTION } INSTALL_S1() { + local installer + local s1token='' + local temp_dir + if ! TAPM_AUTHORIZE "" "$S1_BROKER_PACKAGE" "SentinelOne installation"; then - FINISH_ACTION + FINISH_FAILED_ACTION return fi - rm -f "/tmp/${S1_PACKAGE}" - if ! printf 'header = "Authorization: Bearer %s"\n' "$TAPM_SESSION_TOKEN" | - curl --fail --location --show-error --config - \ - --output "/tmp/${S1_PACKAGE}" "$TAPM_PACKAGE_URL"; then + if ! TAPM_CREATE_TEMP_DIR sentinelone; then TAPM_CLEAR_AUTHORIZATION - rm -f "/tmp/${S1_PACKAGE}" - echo -e "${idsCL[LightRed]}The SentinelOne installer download failed.${idsCL[Default]}" - FINISH_ACTION + FINISH_FAILED_ACTION return fi - if [[ "$(sha256sum "/tmp/${S1_PACKAGE}" | cut -d' ' -f1)" != "$TAPM_PACKAGE_SHA256" ]]; then + temp_dir="$TAPM_TEMP_DIR" + installer="${temp_dir}/${S1_PACKAGE}" + + if ! TAPM_VALID_HTTPS_URL "$TAPM_PACKAGE_URL"; then TAPM_CLEAR_AUTHORIZATION - rm -f "/tmp/${S1_PACKAGE}" + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}The authorized SentinelOne package URL is not valid HTTPS.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + if ! printf 'header = "Authorization: Bearer %s"\nurl = "%s"\n' \ + "$TAPM_SESSION_TOKEN" "$TAPM_PACKAGE_URL" | + curl --fail --location --silent --show-error --config - \ + --proto '=https' --proto-redir '=https' \ + --output "$installer"; then + TAPM_CLEAR_AUTHORIZATION + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}The SentinelOne installer download failed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + if [[ ! -s "$installer" || + "$(sha256sum "$installer" | cut -d' ' -f1)" != "$TAPM_PACKAGE_SHA256" ]]; then + TAPM_CLEAR_AUTHORIZATION + TAPM_CLEAN_TEMP_DIR "$temp_dir" echo -e "${idsCL[LightRed]}The SentinelOne installer checksum did not match. The file was removed.${idsCL[Default]}" - FINISH_ACTION + FINISH_FAILED_ACTION return fi TAPM_CLEAR_AUTHORIZATION @@ -231,25 +478,25 @@ INSTALL_S1() { read -r -s s1token echo [[ -n "$s1token" ]] || { - rm -f "/tmp/${S1_PACKAGE}" + TAPM_CLEAN_TEMP_DIR "$temp_dir" echo "No SentinelOne site token supplied." - FINISH_ACTION + FINISH_FAILED_ACTION return } - cd /tmp || return 1 - if ! dpkg -i "/tmp/${S1_PACKAGE}"; then + if ! dpkg -i "$installer" || + ! /opt/sentinelone/bin/sentinelctl management token set "$s1token" || + ! /opt/sentinelone/bin/sentinelctl control start || + ! TAPM_PACKAGE_INSTALLED sentinelagent; then unset s1token - rm -f "/tmp/${S1_PACKAGE}" - echo -e "${idsCL[LightRed]}SentinelOne installation failed.${idsCL[Default]}" - FINISH_ACTION + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}SentinelOne installation failed or could not be verified.${idsCL[Default]}" + FINISH_FAILED_ACTION return fi - /opt/sentinelone/bin/sentinelctl management token set "$s1token" unset s1token - /opt/sentinelone/bin/sentinelctl control start - rm -f "/tmp/${S1_PACKAGE}" + TAPM_CLEAN_TEMP_DIR "$temp_dir" - echo -e "\n${idsCL[Green]}SentinelOne Agent has been installed. Make sure its added to a \"DETECT ONLY\" policy${idsCL[Default]}" + echo -e "\n${idsCL[Green]}SentinelOne Agent has been installed and verified. Make sure it is added to a \"DETECT ONLY\" policy.${idsCL[Default]}" FINISH_ACTION } From bd005495ce77c2e79b0e7cc0eeda42e1d5ee45a6 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 16:47:09 -0500 Subject: [PATCH 22/76] update --- README.md | 5 +- defaults.inc | 2 +- inc/git-update.inc | 77 ++++++++++++++++++++++++++++ proxmenu-scripts.sh | 97 ++++++++++++++++++++++++++++------- run.sh | 120 +++++++++++++++++++++++++++++++++----------- 5 files changed, 250 insertions(+), 51 deletions(-) create mode 100644 inc/git-update.inc diff --git a/README.md b/README.md index bf00873..2da264a 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,13 @@ updates are installed only when explicitly selected or requested with The required iDSSYS Defaults repository is handled separately: it is automatically refreshed before launch when its last successful check is more than four hours old. If the remote is unavailable, the installed copy is used. +Updates are fast-forward-only. Local file changes, local-only commits, and +diverged histories are preserved and reported instead of being overwritten. The Utilities menu can safely switch TA-ProxMenu between branches published on its Git origin. Branch switching is refused when the installed repository has -local changes. +local changes or when the destination branch has commits that would be +discarded. ## Direct actions diff --git a/defaults.inc b/defaults.inc index 5b8ec01..ba32347 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-29' +VERS='2026.7.25-30' noupdate=' ' diff --git a/inc/git-update.inc b/inc/git-update.inc new file mode 100644 index 0000000..b8dc921 --- /dev/null +++ b/inc/git-update.inc @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Shared non-destructive Git update helpers for TA-ProxMenu. + +TAPM_GIT_WORKTREE_DIRTY() { + local repository="$1" + + [[ -n "$(git -C "$repository" status --porcelain --untracked-files=normal 2>/dev/null)" ]] +} + +TAPM_GIT_FETCH_BRANCH() { + local repository="$1" + local branch="$2" + local timeout_seconds="${3:-30}" + + timeout "$timeout_seconds" git -C "$repository" fetch --prune origin \ + "+refs/heads/${branch}:refs/remotes/origin/${branch}" +} + +TAPM_GIT_RELATION() { + local repository="$1" + local local_ref="$2" + local remote_ref="$3" + local local_commit + local remote_commit + + local_commit="$(git -C "$repository" rev-parse --verify "${local_ref}^{commit}" 2>/dev/null)" || + return 1 + remote_commit="$(git -C "$repository" rev-parse --verify "${remote_ref}^{commit}" 2>/dev/null)" || + return 1 + + if [[ "$local_commit" == "$remote_commit" ]]; then + printf 'current\n' + elif git -C "$repository" merge-base --is-ancestor "$local_commit" "$remote_commit"; then + printf 'behind\n' + elif git -C "$repository" merge-base --is-ancestor "$remote_commit" "$local_commit"; then + printf 'ahead\n' + else + printf 'diverged\n' + fi +} + +TAPM_GIT_BRANCH_STATE() { + local repository="$1" + local branch="$2" + local current_branch + + [[ -d "${repository}/.git" ]] || { + printf 'unavailable\n' + return 1 + } + + current_branch="$(git -C "$repository" branch --show-current 2>/dev/null)" + if [[ -z "$current_branch" ]]; then + printf 'detached\n' + return 0 + fi + if [[ "$current_branch" != "$branch" ]]; then + printf 'wrong-branch\n' + return 0 + fi + if TAPM_GIT_WORKTREE_DIRTY "$repository"; then + printf 'dirty\n' + return 0 + fi + + TAPM_GIT_RELATION "$repository" HEAD "refs/remotes/origin/${branch}" || { + printf 'unavailable\n' + return 1 + } +} + +TAPM_GIT_FAST_FORWARD() { + local repository="$1" + local branch="$2" + + git -C "$repository" merge --ff-only "refs/remotes/origin/${branch}" +} diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 7ec57bb..3739d7f 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -5,6 +5,7 @@ [ "${2:-}" != "q" ] && source /opt/idssys/defaults/colors.inc source /opt/idssys/defaults/default.inc source /opt/idssys/ta-proxmenu/defaults.inc +source /opt/idssys/ta-proxmenu/inc/git-update.inc ACTION_REQUESTED=0 [[ -n "${action:-}" ]] && ACTION_REQUESTED=1 @@ -744,16 +745,18 @@ UPDATE_CHECK_WORKER() { WRITE_UPDATE_CACHE "unavailable" "$branch" "$local_commit" "" return fi - - remote_commit="$(git -C "$FOLDER" ls-remote origin "refs/heads/${branch}" 2>/dev/null | cut -f1)" - if [[ -z "$remote_commit" ]]; then - status="unavailable" - elif [[ "$local_commit" == "$remote_commit" ]]; then - status="current" - else - status="available" + if TAPM_GIT_WORKTREE_DIRTY "$FOLDER"; then + WRITE_UPDATE_CACHE "dirty" "$branch" "$local_commit" "" + return fi + if ! TAPM_GIT_FETCH_BRANCH "$FOLDER" "$branch" 30 >/dev/null 2>&1; then + WRITE_UPDATE_CACHE "unavailable" "$branch" "$local_commit" "" + return + fi + remote_commit="$(git -C "$FOLDER" rev-parse "refs/remotes/origin/${branch}" 2>/dev/null)" + status="$(TAPM_GIT_BRANCH_STATE "$FOLDER" "$branch")" + WRITE_UPDATE_CACHE "$status" "$branch" "$local_commit" "$remote_commit" } @@ -774,12 +777,14 @@ LOAD_UPDATE_STATUS() { local cached_status local current_branch local current_commit + local current_dirty=0 local now UPDATE_STATUS='unknown' UPDATE_REMOTE_COMMIT='' current_branch="$(git -C "$FOLDER" branch --show-current 2>/dev/null)" current_commit="$(git -C "$FOLDER" rev-parse HEAD 2>/dev/null)" + TAPM_GIT_WORKTREE_DIRTY "$FOLDER" && current_dirty=1 now="$(date +%s)" if [[ -r "$UPDATE_CACHE_FILE" ]]; then @@ -789,7 +794,9 @@ LOAD_UPDATE_STATUS() { (( now - checked_at < UPDATE_CACHE_SECONDS )) && [[ "$cached_branch" == "$current_branch" ]] && [[ "$cached_local" == "$current_commit" ]] && - [[ "$cached_status" =~ ^(current|available|unavailable)$ ]]; then + [[ "$cached_status" =~ ^(current|behind|ahead|diverged|dirty|unavailable)$ ]] && + { [[ "$cached_status" == "dirty" && "$current_dirty" -eq 1 ]] || + [[ "$cached_status" != "dirty" && "$current_dirty" -eq 0 ]]; }; then UPDATE_STATUS="$cached_status" UPDATE_REMOTE_COMMIT="$cached_remote" return @@ -818,12 +825,21 @@ FORCE_UPDATE_CHECK() { echo -e "${idsCL[Default]}" case "$UPDATE_STATUS" in - available) + behind) echo -e "${idsCL[LightYellow]}An update is available for the current branch.${idsCL[Default]}" ;; current) echo -e "${idsCL[Green]}TA-ProxMenu is current.${idsCL[Default]}" ;; + ahead) + echo -e "${idsCL[LightYellow]}The current branch has local commits not on origin.${idsCL[Default]}" + ;; + diverged) + echo -e "${idsCL[LightRed]}The current branch has diverged from origin; automatic update is disabled.${idsCL[Default]}" + ;; + dirty) + echo -e "${idsCL[LightYellow]}The TA-ProxMenu repository has local file changes.${idsCL[Default]}" + ;; *) echo -e "${idsCL[Red]}The update status could not be determined.${idsCL[Default]}" ;; @@ -839,9 +855,18 @@ MENU_HEADER() { LOAD_UPDATE_STATUS case "$UPDATE_STATUS" in - available) + behind) version_display="${idsCL[LightYellow]}${VERS} ** UPDATE AVAILABLE **${idsCL[Default]}" ;; + ahead) + version_display="${idsCL[LightYellow]}${VERS} ** LOCAL COMMITS **${idsCL[Default]}" + ;; + diverged) + version_display="${idsCL[LightRed]}${VERS} ** BRANCH DIVERGED **${idsCL[Default]}" + ;; + dirty) + version_display="${idsCL[LightYellow]}${VERS} ** LOCAL CHANGES **${idsCL[Default]}" + ;; checking) version_display="${idsCL[LightCyan]}${VERS} (checking for updates)${idsCL[Default]}" ;; @@ -1114,6 +1139,7 @@ SWITCH_SCRIPT_BRANCH() { local target_branch="$1" local current_branch local choice + local target_state='' current_branch="$(git -C "$FOLDER" branch --show-current 2>/dev/null)" if [[ "$target_branch" == "$current_branch" ]]; then @@ -1136,14 +1162,36 @@ SWITCH_SCRIPT_BRANCH() { echo [[ "$choice" =~ ^[Yy]$ ]] || return - if ! timeout 30 git -C "$FOLDER" fetch origin \ - "refs/heads/${target_branch}:refs/remotes/origin/${target_branch}"; then + if ! TAPM_GIT_FETCH_BRANCH "$FOLDER" "$target_branch" 30; then echo -e "${idsCL[Red]}Failed to fetch '${target_branch}' from origin.${idsCL[Default]}" ENTER2CONTINUE return fi if git -C "$FOLDER" show-ref --verify --quiet "refs/heads/${target_branch}"; then + target_state="$( + TAPM_GIT_RELATION "$FOLDER" "refs/heads/${target_branch}" \ + "refs/remotes/origin/${target_branch}" + )" || { + echo -e "${idsCL[Red]}Could not compare local and remote '${target_branch}'.${idsCL[Default]}" + ENTER2CONTINUE + return + } + case "$target_state" in + ahead) + echo -e "${idsCL[LightYellow]}The local '${target_branch}' branch has commits not on origin.${idsCL[Default]}" + echo "Branch switching was refused to preserve those commits." + ENTER2CONTINUE + return + ;; + diverged) + echo -e "${idsCL[LightRed]}The local '${target_branch}' branch has diverged from origin.${idsCL[Default]}" + echo "Branch switching was refused; manual Git review is required." + ENTER2CONTINUE + return + ;; + esac + git -C "$FOLDER" switch "$target_branch" || { ENTER2CONTINUE return @@ -1156,10 +1204,12 @@ SWITCH_SCRIPT_BRANCH() { } fi - if ! git -C "$FOLDER" reset --hard "origin/${target_branch}"; then - echo -e "${idsCL[Red]}Failed to synchronize '${target_branch}' with origin.${idsCL[Default]}" - ENTER2CONTINUE - return + if [[ "$target_state" == "behind" ]]; then + if ! TAPM_GIT_FAST_FORWARD "$FOLDER" "$target_branch"; then + echo -e "${idsCL[Red]}Failed to fast-forward '${target_branch}'; no commits were discarded.${idsCL[Default]}" + ENTER2CONTINUE + return + fi fi rm -f "$UPDATE_CACHE_FILE" @@ -1233,12 +1283,21 @@ UTILITIES_MENU() { while true; do LOAD_UPDATE_STATUS case "$UPDATE_STATUS" in - available) + behind) labels=("Install available update") ;; current) labels=("TA-ProxMenu is current") ;; + ahead) + labels=("Local branch is ahead of origin") + ;; + diverged) + labels=("Local branch has diverged from origin") + ;; + dirty) + labels=("Local repository has uncommitted changes") + ;; checking) labels=("Update check in progress") ;; @@ -1255,7 +1314,7 @@ UTILITIES_MENU() { SELECT_MENU "Utilities" labels values case "$MENU_SELECTION" in install_update) - if [[ "$UPDATE_STATUS" != "available" ]]; then + if [[ "$UPDATE_STATUS" != "behind" ]]; then echo -e "\n${idsCL[LightCyan]}No available update is currently detected.${idsCL[Default]}" ENTER2CONTINUE continue diff --git a/run.sh b/run.sh index 357543e..ff7aaf6 100755 --- a/run.sh +++ b/run.sh @@ -6,11 +6,13 @@ DEFAULTS_CACHE_DIR='/var/cache/ta-proxmenu' DEFAULTS_CHECK_FILE="${DEFAULTS_CACHE_DIR}/defaults-last-check" DEFAULTS_CHECK_SECONDS=14400 +source /opt/idssys/ta-proxmenu/inc/git-update.inc + AUTO_UPDATE_DEFAULTS() { local checked_at=0 - local local_commit + local current_branch local now - local remote_commit + local state mkdir -p "$DEFAULTS_CACHE_DIR" 2>/dev/null || true now="$(date +%s)" @@ -41,22 +43,46 @@ AUTO_UPDATE_DEFAULTS() { return fi else - if ! timeout 20 git -C "$DEFAULTS_REPOSITORY" fetch origin master \ + current_branch="$(git -C "$DEFAULTS_REPOSITORY" branch --show-current 2>/dev/null)" + if [[ "$current_branch" != "master" ]]; then + echo "WARNING: iDSSYS Defaults is not on master; automatic update skipped." >&2 + date +%s >"$DEFAULTS_CHECK_FILE" + return + fi + if TAPM_GIT_WORKTREE_DIRTY "$DEFAULTS_REPOSITORY"; then + echo "WARNING: iDSSYS Defaults has local changes; automatic update skipped." >&2 + date +%s >"$DEFAULTS_CHECK_FILE" + return + fi + if ! TAPM_GIT_FETCH_BRANCH "$DEFAULTS_REPOSITORY" master 20 \ >/dev/null 2>&1; then echo "WARNING: Unable to check iDSSYS Defaults; using the installed copy." >&2 return fi - local_commit="$(git -C "$DEFAULTS_REPOSITORY" rev-parse HEAD)" - remote_commit="$(git -C "$DEFAULTS_REPOSITORY" rev-parse origin/master)" - if [[ "$local_commit" != "$remote_commit" ]]; then - echo "Updating required iDSSYS Defaults..." - if ! git -C "$DEFAULTS_REPOSITORY" reset --hard origin/master \ - >/dev/null 2>&1; then + state="$(TAPM_GIT_BRANCH_STATE "$DEFAULTS_REPOSITORY" master)" + case "$state" in + behind) + echo "Updating required iDSSYS Defaults..." + if ! TAPM_GIT_FAST_FORWARD "$DEFAULTS_REPOSITORY" master \ + >/dev/null 2>&1; then + echo "WARNING: Unable to update iDSSYS Defaults; using the installed copy." >&2 + return + fi + ;; + current) + ;; + ahead) + echo "WARNING: iDSSYS Defaults has local commits not on origin; automatic update skipped." >&2 + ;; + diverged) + echo "WARNING: iDSSYS Defaults has diverged from origin; automatic update skipped." >&2 + ;; + *) echo "WARNING: Unable to update iDSSYS Defaults; using the installed copy." >&2 return - fi - fi + ;; + esac fi date +%s >"$DEFAULTS_CHECK_FILE" @@ -72,36 +98,67 @@ UPDATE_REPOSITORY() { local repository="$1" local branch="$2" local label="$3" - local local_commit - local remote_commit + local current_branch + local state - cd "$repository" || return 1 - local_commit="$(git rev-parse HEAD 2>/dev/null)" || return 1 - remote_commit="$(git ls-remote origin "refs/heads/${branch}" | cut -f1)" - - if [ -z "$remote_commit" ]; then - echo -e "${idsCL[Red]}Could not find branch '${branch}' for ${label}${idsCL[Default]}" + if [[ ! -d "${repository}/.git" ]]; then + echo -e "${idsCL[Red]}${label} is not a Git repository.${idsCL[Default]}" return 1 fi - if [ "$local_commit" = "$remote_commit" ]; then - echo -e "${idsCL[Green]}${label} is current (${branch})${idsCL[Default]}" - return 0 + current_branch="$(git -C "$repository" branch --show-current 2>/dev/null)" + if [[ -z "$current_branch" ]]; then + echo -e "${idsCL[Red]}${label} is in a detached HEAD state; update skipped.${idsCL[Default]}" + return 1 + fi + if [[ "$current_branch" != "$branch" ]]; then + echo -e "${idsCL[Red]}${label} is on '${current_branch}', not '${branch}'; update skipped.${idsCL[Default]}" + return 1 + fi + if TAPM_GIT_WORKTREE_DIRTY "$repository"; then + echo -e "${idsCL[LightYellow]}${label} has local changes; update skipped to preserve them.${idsCL[Default]}" + git -C "$repository" status --short + return 1 fi - echo -en "${idsCL[LightCyan]}Updating ${label} (${branch})...${idsCL[Default]}" - if git fetch origin "$branch" >/dev/null 2>&1 && - git reset --hard "origin/${branch}" >/dev/null 2>&1; then - echo -e " ${idsCL[Green]}Done${idsCL[Default]}" - return 0 + if ! TAPM_GIT_FETCH_BRANCH "$repository" "$branch" 30 >/dev/null 2>&1; then + echo -e "${idsCL[Red]}Could not fetch branch '${branch}' for ${label}.${idsCL[Default]}" + return 1 fi - echo -e " ${idsCL[Red]}Failed${idsCL[Default]}" - return 1 + state="$(TAPM_GIT_BRANCH_STATE "$repository" "$branch")" + case "$state" in + current) + echo -e "${idsCL[Green]}${label} is current (${branch}).${idsCL[Default]}" + return 0 + ;; + behind) + echo -en "${idsCL[LightCyan]}Updating ${label} (${branch})...${idsCL[Default]}" + if TAPM_GIT_FAST_FORWARD "$repository" "$branch" >/dev/null 2>&1; then + echo -e " ${idsCL[Green]}Done${idsCL[Default]}" + return 0 + fi + echo -e " ${idsCL[Red]}Failed; local files were preserved.${idsCL[Default]}" + return 1 + ;; + ahead) + echo -e "${idsCL[LightYellow]}${label} is ahead of origin; its local commits were preserved.${idsCL[Default]}" + return 1 + ;; + diverged) + echo -e "${idsCL[LightYellow]}${label} has diverged from origin; automatic update was refused.${idsCL[Default]}" + return 1 + ;; + *) + echo -e "${idsCL[Red]}Could not determine the update state for ${label}.${idsCL[Default]}" + return 1 + ;; + esac } INSTALL_UPDATES() { local current_branch + local defaults_warning=0 local update_failed=0 echo -e "${idsCL[LightCyan]}Checking for updates...${idsCL[Default]}" @@ -112,7 +169,7 @@ INSTALL_UPDATES() { fi UPDATE_REPOSITORY /opt/idssys/defaults master "iDSSYS Defaults" || - update_failed=1 + defaults_warning=1 current_branch="$(git -C /opt/idssys/ta-proxmenu branch --show-current)" if [ -z "$current_branch" ]; then @@ -128,6 +185,9 @@ INSTALL_UPDATES() { if (( update_failed == 0 )); then source /opt/idssys/ta-proxmenu/defaults.inc echo -e "\n${idsCL[Green]}Update check complete. Installed version: ${VERS}${idsCL[Default]}" + if (( defaults_warning == 1 )); then + echo -e "${idsCL[LightYellow]}TA-ProxMenu was processed, but iDSSYS Defaults requires attention.${idsCL[Default]}" + fi return 0 fi From d19400a732c8667c96145bea0c47f285f9f98628 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 17:48:04 -0500 Subject: [PATCH 23/76] update --- README.md | 18 +- defaults.inc | 2 +- inc/evacuate-proxmox-node.sh | 822 ++++++++++++++++++++++++++++------- proxmenu-scripts.sh | 647 +++++++++++++++++++++++---- run.sh | 75 ++++ 5 files changed, 1314 insertions(+), 250 deletions(-) mode change 100755 => 100644 inc/evacuate-proxmox-node.sh diff --git a/README.md b/README.md index 2da264a..a06711c 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ interactive menu. Update availability is checked in the background and cached; updates are installed only when explicitly selected or requested with `tapm update`. +Use `tapm V2` to switch an installed copy to the published V2 branch for +testing, and `tapm main` to switch it back. The command refuses to switch when +local changes, local-only commits, or diverged branch history would be at risk. + The required iDSSYS Defaults repository is handled separately: it is automatically refreshed before launch when its last successful check is more than four hours old. If the remote is unavailable, the installed copy is used. @@ -34,7 +38,7 @@ The menu script also supports these direct actions: ```text pulse Install Pulse monitoring rmm Install the ConnectWise RMM agent -omsa Install Dell OpenManage Server Administrator +omsa Install legacy Dell OMSA on supported PowerEdge x30/x40 hosts glances Install Glances acronis Install the Acronis agent post-install Run the ProxMenux post-install configuration @@ -66,3 +70,15 @@ portal without changing ProxMenu. The Keepalived deployment additionally requires a healthy, quorate Proxmox cluster and passwordless root SSH between cluster nodes. + +The legacy Dell OMSA installer is limited to supported PowerEdge x30/x40 +systems running Proxmox VE 9 on Debian 13 (Trixie), amd64. + +CPU compatibility detection previews cluster-wide QEMU VM and template +changes before applying the ProxCLMC recommendation through the Proxmox CLI. +Running VMs are not restarted automatically. + +Maintenance evacuation leaves HA-managed guests under Proxmox HA control. +Remaining shared-storage guests are routed to online, non-maintenance nodes +with the required storage, while local-storage guests are gracefully shut +down. HA node-affinity preferences are honored when an eligible node exists. diff --git a/defaults.inc b/defaults.inc index ba32347..7039e48 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-30' +VERS='2026.7.25-34' noupdate=' ' diff --git a/inc/evacuate-proxmox-node.sh b/inc/evacuate-proxmox-node.sh old mode 100755 new mode 100644 index 287ab9f..3afc05d --- a/inc/evacuate-proxmox-node.sh +++ b/inc/evacuate-proxmox-node.sh @@ -1,18 +1,21 @@ #!/usr/bin/env bash -set -u +set -u -o pipefail # Evacuate non-HA guests after the local Proxmox node enters HA maintenance. -# Guests using shared storage are migrated to one operator-selected node. -# Guests with local storage remain on this node and are gracefully shut down. +# HA-managed guests remain under Proxmox HA control. Non-HA guests using +# shared storage are migrated, while guests using local storage are shut down. HA_WAIT_SECONDS=300 MAINTENANCE_WAIT_SECONDS=60 MAX_PARALLEL_MIGRATIONS=3 SHUTDOWN_TIMEOUT=180 LOCAL_NODE="$(hostname -s)" -MIGRATION_LOG_DIR="$(mktemp -d)" +MIGRATION_LOG_DIR='' +PREFERRED_TARGET='' +HA_RULES_FILE="${HA_RULES_FILE:-/etc/pve/ha/rules.cfg}" -trap 'rm -rf "$MIGRATION_LOG_DIR"' EXIT +declare -a MIGRATION_NODES=() +declare -A NODE_STORAGE_CACHE=() log() { printf '\n[%s] %s\n' "$(date '+%F %T')" "$*" @@ -27,18 +30,24 @@ die() { exit 1 } -command -v pvesh >/dev/null 2>&1 || die "pvesh is required." -command -v ha-manager >/dev/null 2>&1 || die "ha-manager is required." -command -v python3 >/dev/null 2>&1 || die "python3 is required." +load_lines() { + local destination_name="$1" + local output + local -n destination_ref="$destination_name" + shift -pvesh get /cluster/resources --type vm --output-format json >/dev/null 2>&1 || - die "Could not read cluster guest resources." -pvesh get /cluster/ha/resources --output-format json >/dev/null 2>&1 || - die "Could not read HA resources." -pvesh get /nodes --output-format json >/dev/null 2>&1 || - die "Could not read cluster node status." -pvesh get /storage --output-format json >/dev/null 2>&1 || - die "Could not read cluster storage configuration." + output="$("$@")" || return 1 + destination_ref=() + # shellcheck disable=SC2034 # mapfile writes through the nameref. + [[ -z "$output" ]] || mapfile -t destination_ref <<< "$output" +} + +cleanup() { + if [[ "$MIGRATION_LOG_DIR" == /tmp/ta-proxmenu-evacuation.* && + -d "$MIGRATION_LOG_DIR" ]]; then + rm -rf -- "$MIGRATION_LOG_DIR" + fi +} get_local_guests() { pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | @@ -55,6 +64,7 @@ for guest in json.load(sys.stdin): guest.get("type", ""), guest.get("status", "unknown"), str(guest.get("name", "")).replace("\x1f", " "), + int(guest.get("maxmem") or 0), sep="\x1f", ) ' "$LOCAL_NODE" @@ -75,6 +85,16 @@ for resource in json.load(sys.stdin): ' } +guest_is_ha_managed() { + local vmid="$1" + local ha_id + + for ha_id in "${CURRENT_HA_IDS[@]:-}"; do + [[ "$ha_id" == "$vmid" ]] && return 0 + done + return 1 +} + get_online_nodes() { pvesh get /nodes --output-format json 2>/dev/null | python3 -c ' @@ -89,6 +109,14 @@ for node in json.load(sys.stdin): ' "$LOCAL_NODE" } +node_in_maintenance() { + local node="$1" + + ha-manager status 2>/dev/null | + grep -F "lrm ${node} " | + grep -q "maintenance mode" +} + get_shared_storages() { pvesh get /storage --output-format json 2>/dev/null | python3 -c ' @@ -101,6 +129,74 @@ for storage in json.load(sys.stdin): ' } +get_node_active_storages() { + local node="$1" + + pvesh get "/nodes/${node}/storage" --output-format json 2>/dev/null | + python3 -c ' +import json +import sys + +for storage in json.load(sys.stdin): + active = storage.get("active") + enabled = storage.get("enabled", 1) + if active in (1, True, "1") and enabled not in (0, False, "0"): + storage_id = str(storage.get("storage", "")) + if storage_id: + print(storage_id) +' +} + +refresh_migration_nodes() { + local ha_status + local node + local online_output + local storage_csv + local -a online_nodes=() + local -a usable_nodes=() + + online_output="$(get_online_nodes)" || { + warn "Could not refresh online cluster nodes." + return 1 + } + [[ -z "$online_output" ]] || + mapfile -t online_nodes <<< "$online_output" + + ha_status="$(ha-manager status 2>/dev/null)" || { + warn "Could not refresh HA node status." + return 1 + } + NODE_STORAGE_CACHE=() + + for node in "${online_nodes[@]}"; do + if grep -F "lrm ${node} " <<< "$ha_status" | + grep -q "maintenance mode"; then + continue + fi + storage_csv="$(get_node_active_storages "$node" | paste -sd, -)" || { + warn "Could not read storage status from ${node}; it will not be used." + continue + } + NODE_STORAGE_CACHE["$node"]="$storage_csv" + usable_nodes+=("$node") + done + MIGRATION_NODES=("${usable_nodes[@]}") +} + +node_has_required_storages() { + local node="$1" + local required_csv="$2" + local available_csv="${NODE_STORAGE_CACHE[$node]-}" + local storage + local -a required_storages=() + + [[ -n "$required_csv" ]] || return 0 + IFS=',' read -r -a required_storages <<< "$required_csv" + for storage in "${required_storages[@]}"; do + [[ ",${available_csv}," == *",${storage},"* ]] || return 1 + done +} + guest_storage_scope() { local guest_type="$1" local vmid="$2" @@ -125,6 +221,7 @@ else: disk_key = re.compile(r"^(?:rootfs|mp\d+|unused\d+)$") local_reasons = [] +required_storages = set() for key, raw_value in config.items(): if not disk_key.match(key) or not isinstance(raw_value, str): continue @@ -137,41 +234,221 @@ for key, raw_value in config.items(): continue storage = volume.split(":", 1)[0] + required_storages.add(storage) if storage not in shared: local_reasons.append(f"{key}={storage}") -if local_reasons: - print("local\x1f" + ", ".join(local_reasons)) -else: - print("shared\x1f") +scope = "local" if local_reasons else "shared" +print( + scope, + ", ".join(local_reasons), + ",".join(sorted(required_storages)), + sep="\x1f", +) ' "$guest_type" "$shared_csv" } +get_guest_node_affinity() { + local guest_type="$1" + local vmid="$2" + local sid_type='vm' + + [[ "$guest_type" == "lxc" ]] && sid_type='ct' + python3 -c ' +import os +import sys + +sid = sys.argv[1] +path = sys.argv[2] +if not os.path.exists(path): + print("none", "0", "", sep="\x1f") + raise SystemExit + +rules = [] +current = None +with open(path, encoding="utf-8") as handle: + for raw_line in handle: + line = raw_line.rstrip() + if not line or line.lstrip().startswith("#"): + continue + if not line[0].isspace() and ":" in line: + rule_type, rule_id = line.split(":", 1) + current = { + "type": rule_type.strip(), + "id": rule_id.strip(), + } + rules.append(current) + continue + if current is not None and line[0].isspace(): + key_value = line.strip().split(None, 1) + if len(key_value) == 2: + current[key_value[0]] = key_value[1].strip() + +for rule in rules: + if rule.get("type") != "node-affinity": + continue + if rule.get("disable", "0") in ("1", "yes", "true", "on"): + continue + resources = { + item.strip() + for item in rule.get("resources", "").replace(";", ",").split(",") + if item.strip() + } + if sid not in resources: + continue + + nodes = [] + for position, item in enumerate(rule.get("nodes", "").split(",")): + item = item.strip() + if not item: + continue + node = item + priority = 0 + if ":" in item: + possible_node, possible_priority = item.rsplit(":", 1) + try: + priority = int(possible_priority) + node = possible_node + except ValueError: + pass + nodes.append((node, priority, position)) + nodes.sort(key=lambda value: (-value[1], value[2])) + strict = "1" if rule.get("strict", "0") in ("1", "yes", "true", "on") else "0" + print(rule.get("id", "node-affinity"), strict, ",".join(n[0] for n in nodes), sep="\x1f") + raise SystemExit + +print("none", "0", "", sep="\x1f") +' "${sid_type}:${vmid}" "$HA_RULES_FILE" +} + select_target_node() { - local -a nodes local choice local index - mapfile -t nodes < <(get_online_nodes) - (( ${#nodes[@]} > 0 )) || die "No other online cluster node is available." + (( ${#MIGRATION_NODES[@]} > 0 )) || + die "No other online, non-maintenance cluster node is available." - printf '\nAvailable migration targets:\n\n' - for index in "${!nodes[@]}"; do - printf ' %d) %s\n' "$((index + 1))" "${nodes[$index]}" + printf '\nAvailable preferred migration targets:\n\n' + for index in "${!MIGRATION_NODES[@]}"; do + printf ' %d) %s\n' "$((index + 1))" "${MIGRATION_NODES[$index]}" done while true; do printf '\n' - read -r -p "Select migration target [1-${#nodes[@]}]: " choice + read -r -p "Select preferred migration target [1-${#MIGRATION_NODES[@]}]: " choice if [[ "$choice" =~ ^[0-9]+$ ]] && - (( choice >= 1 && choice <= ${#nodes[@]} )); then - TARGET_NODE="${nodes[$((choice - 1))]}" + (( choice >= 1 && choice <= ${#MIGRATION_NODES[@]} )); then + PREFERRED_TARGET="${MIGRATION_NODES[$((choice - 1))]}" return fi printf 'Invalid selection.\n' >&2 done } +CHOSEN_DESTINATION='' +CHOSEN_NOTE='' + +choose_destination() { + local required_csv="$1" + local policy_nodes_csv="$2" + local policy_strict="$3" + local node + local policy_node + local -a storage_candidates=() + local -a policy_nodes=() + + CHOSEN_DESTINATION='' + CHOSEN_NOTE='' + + for node in "${MIGRATION_NODES[@]}"; do + if node_has_required_storages "$node" "$required_csv"; then + storage_candidates+=("$node") + fi + done + (( ${#storage_candidates[@]} > 0 )) || return 1 + + IFS=',' read -r -a policy_nodes <<< "$policy_nodes_csv" + if [[ -z "$policy_nodes_csv" ]]; then + for node in "${storage_candidates[@]}"; do + if [[ "$node" == "$PREFERRED_TARGET" ]]; then + CHOSEN_DESTINATION="$node" + return 0 + fi + done + CHOSEN_DESTINATION="${storage_candidates[0]}" + CHOSEN_NOTE="preferred target unavailable for required storage" + return 0 + fi + + for node in "${storage_candidates[@]}"; do + if [[ "$node" == "$PREFERRED_TARGET" && + ",${policy_nodes_csv}," == *",${node},"* ]]; then + CHOSEN_DESTINATION="$node" + return 0 + fi + done + + for policy_node in "${policy_nodes[@]}"; do + for node in "${storage_candidates[@]}"; do + if [[ "$node" == "$policy_node" ]]; then + CHOSEN_DESTINATION="$node" + CHOSEN_NOTE="routed to satisfy HA node-affinity preference" + return 0 + fi + done + done + + if (( ${#storage_candidates[@]} == 1 )); then + CHOSEN_DESTINATION="${storage_candidates[0]}" + CHOSEN_NOTE="only eligible node; HA node-affinity preference overridden" + return 0 + fi + + if [[ "$policy_strict" == "1" ]]; then + return 1 + fi + + for node in "${storage_candidates[@]}"; do + if [[ "$node" == "$PREFERRED_TARGET" ]]; then + CHOSEN_DESTINATION="$node" + CHOSEN_NOTE="no preferred HA node was eligible; non-strict fallback used" + return 0 + fi + done + CHOSEN_DESTINATION="${storage_candidates[0]}" + CHOSEN_NOTE="no preferred HA node was eligible; non-strict fallback used" +} + +get_node_available_memory() { + local node="$1" + + pvesh get /nodes --output-format json 2>/dev/null | + python3 -c ' +import json +import sys + +requested = sys.argv[1] +for node in json.load(sys.stdin): + if str(node.get("node", "")) != requested: + continue + total = int(node.get("maxmem") or 0) + used = int(node.get("mem") or 0) + print(max(0, total - used)) + raise SystemExit +raise SystemExit(1) +' "$node" +} + +format_bytes() { + local bytes="${1:-0}" + + if command -v numfmt >/dev/null 2>&1; then + numfmt --to=iec-i --suffix=B "$bytes" + else + printf '%d MiB' "$((bytes / 1024 / 1024))" + fi +} + wait_for_ha_evacuation() { local deadline=$((SECONDS + HA_WAIT_SECONDS)) local -a local_guests @@ -183,8 +460,14 @@ wait_for_ha_evacuation() { log "Waiting for HA-managed guests to leave ${LOCAL_NODE}." while true; do - mapfile -t local_guests < <(get_local_guests) - mapfile -t ha_ids < <(get_ha_guest_ids) + load_lines local_guests get_local_guests || { + warn "Could not refresh guests assigned to ${LOCAL_NODE}." + return 1 + } + load_lines ha_ids get_ha_guest_ids || { + warn "Could not refresh HA resources." + return 1 + } remaining=() for guest in "${local_guests[@]}"; do @@ -214,23 +497,55 @@ migrate_guest() { local vmid="$1" local guest_type="$2" local status="$3" + local destination="$4" if [[ "$guest_type" == "qemu" ]]; then if [[ "$status" == "running" ]]; then - qm migrate "$vmid" "$TARGET_NODE" --online + qm migrate "$vmid" "$destination" --online else - qm migrate "$vmid" "$TARGET_NODE" + qm migrate "$vmid" "$destination" fi else if [[ "$status" == "running" ]]; then - pct migrate "$vmid" "$TARGET_NODE" --restart 1 \ + pct migrate "$vmid" "$destination" --restart 1 \ --timeout "$SHUTDOWN_TIMEOUT" else - pct migrate "$vmid" "$TARGET_NODE" + pct migrate "$vmid" "$destination" fi fi } +get_guest_node() { + local vmid="$1" + + pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | + python3 -c ' +import json +import sys + +vmid = str(sys.argv[1]) +for guest in json.load(sys.stdin): + if str(guest.get("vmid", "")) == vmid: + print(guest.get("node", "")) + raise SystemExit +raise SystemExit(1) +' "$vmid" +} + +wait_for_guest_node() { + local vmid="$1" + local destination="$2" + local attempts="${3:-15}" + local attempt=0 + + while (( attempt < attempts )); do + [[ "$(get_guest_node "$vmid")" == "$destination" ]] && return 0 + sleep 1 + ((attempt++)) + done + return 1 +} + wait_for_migration_batch() { local index local pid @@ -240,17 +555,26 @@ wait_for_migration_batch() { local guest_type local status local name + local maxmem + local required_storages + local policy_nodes + local policy_strict + local policy_rule + local destination + local route_note for index in "${!batch_pids[@]}"; do pid="${batch_pids[$index]}" guest="${batch_guests[$index]}" log_file="${batch_logs[$index]}" - IFS=$'\x1f' read -r vmid guest_type status name <<< "$guest" + IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \ + policy_nodes policy_strict policy_rule destination route_note <<< "$guest" - if wait "$pid"; then - log "Migration completed for ${guest_type} ${vmid} (${name:-unnamed})." + if wait "$pid" && wait_for_guest_node "$vmid" "$destination"; then + log "Migration completed for ${guest_type} ${vmid} (${name:-unnamed}) to ${destination}." + migration_successes+=("$guest") else - warn "Migration failed for ${guest_type} ${vmid} (${name:-unnamed})." + warn "Migration failed for ${guest_type} ${vmid} (${name:-unnamed}) to ${destination}." migration_failures+=("$guest") fi @@ -286,15 +610,27 @@ guest_status() { fi } +wait_for_guest_stopped() { + local vmid="$1" + local guest_type="$2" + local attempts="${3:-15}" + local attempt=0 + + while (( attempt < attempts )); do + [[ "$(guest_status "$vmid" "$guest_type")" == "stopped" ]] && return 0 + sleep 1 + ((attempt++)) + done + return 1 +} + wait_for_maintenance_mode() { local deadline=$((SECONDS + MAINTENANCE_WAIT_SECONDS)) log "Waiting for ${LOCAL_NODE} to enter HA maintenance mode." while true; do - if ha-manager status | - grep -F "$LOCAL_NODE" | - grep -q "maintenance mode"; then + if node_in_maintenance "$LOCAL_NODE"; then printf '\n' return fi @@ -308,132 +644,312 @@ wait_for_maintenance_mode() { done } -wait_for_maintenance_mode +preflight() { + local command -wait_for_ha_evacuation || - die "Resolve the remaining HA guests before continuing the evacuation." - -select_target_node - -mapfile -t shared_storages < <(get_shared_storages) -shared_csv="$(IFS=,; echo "${shared_storages[*]}")" - -mapfile -t guests < <(get_local_guests) -if (( ${#guests[@]} == 0 )); then - log "No guests remain on ${LOCAL_NODE}." - exit 0 -fi - -declare -a shared_guests=() -declare -a local_guests=() - -for guest in "${guests[@]}"; do - IFS=$'\x1f' read -r vmid guest_type status name <<< "$guest" - storage_result="$(guest_storage_scope "$guest_type" "$vmid" "$shared_csv")" || - die "Could not inspect storage for ${guest_type} ${vmid}." - IFS=$'\x1f' read -r scope reason <<< "$storage_result" - - if [[ "$scope" == "shared" ]]; then - shared_guests+=("$guest") - else - local_guests+=("${guest}"$'\x1f'"${reason:-local storage}") - fi -done - -printf '\nEvacuation plan for %s:\n' "$LOCAL_NODE" -printf ' Migration target: %s\n' "$TARGET_NODE" -printf ' Shared-storage guests to migrate: %d\n' "${#shared_guests[@]}" -printf ' Local-storage guests to retain: %d\n' "${#local_guests[@]}" - -if (( ${#shared_guests[@]} > 0 )); then - printf '\nShared-storage guests:\n' - for guest in "${shared_guests[@]}"; do - IFS=$'\x1f' read -r vmid guest_type status name <<< "$guest" - printf ' %-6s %-5s %-8s %s\n' "$vmid" "$guest_type" "$status" "$name" + for command in pvesh ha-manager python3 qm pct; do + command -v "$command" >/dev/null 2>&1 || + die "${command} is required." done -fi -if (( ${#local_guests[@]} > 0 )); then - printf '\nLocal-storage guests (will not migrate):\n' - for guest in "${local_guests[@]}"; do - IFS=$'\x1f' read -r vmid guest_type status name reason <<< "$guest" - printf ' %-6s %-5s %-8s %-24s %s\n' \ - "$vmid" "$guest_type" "$status" "$name" "$reason" + pvesh get /cluster/resources --type vm --output-format json >/dev/null 2>&1 || + die "Could not read cluster guest resources." + pvesh get /cluster/ha/resources --output-format json >/dev/null 2>&1 || + die "Could not read HA resources." + pvesh get /nodes --output-format json >/dev/null 2>&1 || + die "Could not read cluster node status." + pvesh get /storage --output-format json >/dev/null 2>&1 || + die "Could not read cluster storage configuration." + ha-manager status >/dev/null 2>&1 || + die "Could not read HA node status." +} + +main() { + local answer + local available_memory + local destination + local guest + local guest_type + local log_file + local maxmem + local name + local policy_nodes + local policy_result + local policy_rule + local policy_strict + local reason + local required_memory + local required_storages + local route_note + local scope + local shared_csv + local status + local storage_result + local vmid + local -a guests=() + local -a shared_storages=() + local -a shared_guests=() + local -a local_guests=() + local -a unroutable_guests=() + local -a migration_failures=() + local -a migration_successes=() + local -a migration_skipped=() + local -a shutdown_failures=() + local -a shutdown_successes=() + local -a batch_pids=() + local -a batch_guests=() + local -a batch_logs=() + local -a final_guests=() + local -a running_final_guests=() + local -a CURRENT_HA_IDS=() + local -A DEST_REQUIRED_MEMORY=() + + MIGRATION_LOG_DIR="$(mktemp -d /tmp/ta-proxmenu-evacuation.XXXXXX)" || + die "Could not create the migration log directory." + chmod 0700 "$MIGRATION_LOG_DIR" + trap cleanup EXIT + + preflight + wait_for_maintenance_mode + wait_for_ha_evacuation || + die "Resolve the remaining HA guests before continuing the evacuation." + + load_lines shared_storages get_shared_storages || + die "Could not read shared-storage configuration." + shared_csv="$(IFS=,; echo "${shared_storages[*]}")" + load_lines guests get_local_guests || + die "Could not read guests assigned to ${LOCAL_NODE}." + + if (( ${#guests[@]} == 0 )); then + log "No guests remain on ${LOCAL_NODE}." + return 0 + fi + + for guest in "${guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem <<< "$guest" + storage_result="$(guest_storage_scope "$guest_type" "$vmid" "$shared_csv")" || + die "Could not inspect storage for ${guest_type} ${vmid}." + IFS=$'\x1f' read -r scope reason required_storages <<< "$storage_result" + + if [[ "$scope" == "shared" ]]; then + shared_guests+=( + "${guest}"$'\x1f'"${required_storages}" + ) + else + local_guests+=( + "${guest}"$'\x1f'"${reason:-local storage}" + ) + fi done -fi -printf '\n' -read -r -p "Proceed with shared-storage guest migration? [y/N] " answer -[[ "$answer" =~ ^[Yy]$ ]] || { echo "Evacuation cancelled."; exit 0; } + if (( ${#shared_guests[@]} > 0 )); then + refresh_migration_nodes || + die "Could not build a safe migration-target list." + select_target_node -declare -a migration_failures=() -declare -a batch_pids=() -declare -a batch_guests=() -declare -a batch_logs=() + guests=("${shared_guests[@]}") + shared_guests=() + for guest in "${guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages <<< "$guest" + policy_result="$(get_guest_node_affinity "$guest_type" "$vmid")" || + die "Could not inspect HA node-affinity policy for ${guest_type} ${vmid}." + IFS=$'\x1f' read -r policy_rule policy_strict policy_nodes <<< "$policy_result" -for guest in "${shared_guests[@]}"; do - IFS=$'\x1f' read -r vmid guest_type status name <<< "$guest" - log "Starting migration for ${guest_type} ${vmid} (${name:-unnamed}) to ${TARGET_NODE}." - - log_file="${MIGRATION_LOG_DIR}/${guest_type}-${vmid}.log" - migrate_guest "$vmid" "$guest_type" "$status" >"$log_file" 2>&1 & - batch_pids+=("$!") - batch_guests+=("$guest") - batch_logs+=("$log_file") - - if (( ${#batch_pids[@]} >= MAX_PARALLEL_MIGRATIONS )); then - wait_for_migration_batch + if choose_destination "$required_storages" "$policy_nodes" "$policy_strict"; then + destination="$CHOSEN_DESTINATION" + route_note="$CHOSEN_NOTE" + shared_guests+=( + "${guest}"$'\x1f'"${policy_nodes}"$'\x1f'"${policy_strict}"$'\x1f'"${policy_rule}"$'\x1f'"${destination}"$'\x1f'"${route_note}" + ) + if [[ "$status" == "running" && "$maxmem" =~ ^[0-9]+$ ]]; then + DEST_REQUIRED_MEMORY["$destination"]="$(( ${DEST_REQUIRED_MEMORY[$destination]:-0} + maxmem ))" + fi + else + unroutable_guests+=( + "${guest}"$'\x1f'"${policy_nodes}"$'\x1f'"${policy_strict}"$'\x1f'"${policy_rule}" + ) + fi + done fi -done -(( ${#batch_pids[@]} == 0 )) || wait_for_migration_batch + printf '\nEvacuation plan for %s:\n' "$LOCAL_NODE" + [[ -n "$PREFERRED_TARGET" ]] && + printf ' Preferred migration target: %s\n' "$PREFERRED_TARGET" + printf ' Shared-storage guests to migrate: %d\n' "${#shared_guests[@]}" + printf ' Local-storage guests to shut down: %d\n' "${#local_guests[@]}" + printf ' Shared guests without a valid route: %d\n' "${#unroutable_guests[@]}" -declare -a running_local_guests=() -for guest in "${local_guests[@]}"; do - IFS=$'\x1f' read -r vmid guest_type status name reason <<< "$guest" - status="$(guest_status "$vmid" "$guest_type")" - if [[ "$status" == "running" ]]; then - running_local_guests+=( - "${vmid}"$'\x1f'"${guest_type}"$'\x1f'"${status}"$'\x1f'"${name}"$'\x1f'"${reason}" - ) + if (( ${#shared_guests[@]} > 0 )); then + printf '\nShared-storage guests:\n' + for guest in "${shared_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \ + policy_nodes policy_strict policy_rule destination route_note <<< "$guest" + printf ' %-6s %-5s %-8s %-24s -> %s' \ + "$vmid" "$guest_type" "$status" "$name" "$destination" + [[ -n "$route_note" ]] && printf ' (%s)' "$route_note" + printf '\n' + done fi -done -if (( ${#running_local_guests[@]} > 0 )); then - printf '\nThe following local-storage guests remain running:\n' - for guest in "${running_local_guests[@]}"; do - IFS=$'\x1f' read -r vmid guest_type status name reason <<< "$guest" - printf ' %-6s %-5s %-24s %s\n' "$vmid" "$guest_type" "$name" "$reason" + if (( ${#local_guests[@]} > 0 )); then + printf '\nLocal-storage guests (will not migrate):\n' + for guest in "${local_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem reason <<< "$guest" + printf ' %-6s %-5s %-8s %-24s %s\n' \ + "$vmid" "$guest_type" "$status" "$name" "$reason" + done + fi + + if (( ${#unroutable_guests[@]} > 0 )); then + printf '\nShared guests with no eligible destination:\n' + for guest in "${unroutable_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \ + policy_nodes policy_strict policy_rule <<< "$guest" + printf ' %-6s %-5s %-24s storages=%s' \ + "$vmid" "$guest_type" "$name" "$required_storages" + [[ "$policy_rule" != "none" ]] && + printf ' policy=%s strict=%s nodes=%s' \ + "$policy_rule" "$policy_strict" "$policy_nodes" + printf '\n' + done + fi + + for destination in "${!DEST_REQUIRED_MEMORY[@]}"; do + required_memory="${DEST_REQUIRED_MEMORY[$destination]}" + available_memory="$(get_node_available_memory "$destination" 2>/dev/null || echo 0)" + printf '\nTarget %s memory: %s available; %s configured for incoming running guests.\n' \ + "$destination" "$(format_bytes "$available_memory")" \ + "$(format_bytes "$required_memory")" + if (( available_memory > 0 && required_memory > available_memory )); then + warn "${destination} has less currently available memory than the incoming guests' configured memory." + fi done printf '\n' - read -r -p "Gracefully shut down these local-storage guests? [y/N] " answer - if [[ "$answer" =~ ^[Yy]$ ]]; then - for guest in "${running_local_guests[@]}"; do - IFS=$'\x1f' read -r vmid guest_type status name reason <<< "$guest" - log "Shutting down ${guest_type} ${vmid} (${name:-unnamed})." - shutdown_guest "$vmid" "$guest_type" || - warn "Graceful shutdown failed for ${guest_type} ${vmid}; it was not force-stopped." + read -r -p "Proceed with guest migrations and local-storage guest shutdown? [y/N] " answer + [[ "$answer" =~ ^[Yy]$ ]] || { + echo "Evacuation cancelled; ${LOCAL_NODE} remains in maintenance mode." + return 1 + } + + load_lines CURRENT_HA_IDS get_ha_guest_ids || + die "Could not safely recheck HA-managed guests." + refresh_migration_nodes || + die "Could not safely recheck migration targets." + + for guest in "${shared_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \ + policy_nodes policy_strict policy_rule destination route_note <<< "$guest" + + if guest_is_ha_managed "$vmid"; then + warn "${guest_type} ${vmid} became HA-managed; TAPM will not migrate it manually." + migration_skipped+=("$guest") + continue + fi + + if ! choose_destination "$required_storages" "$policy_nodes" "$policy_strict"; then + warn "No eligible destination remains for ${guest_type} ${vmid}; migration skipped." + migration_skipped+=("$guest") + continue + fi + if [[ "$destination" != "$CHOSEN_DESTINATION" ]]; then + warn "Destination for ${guest_type} ${vmid} changed from ${destination} to ${CHOSEN_DESTINATION} after recheck." + destination="$CHOSEN_DESTINATION" + route_note="$CHOSEN_NOTE" + guest="${vmid}"$'\x1f'"${guest_type}"$'\x1f'"${status}"$'\x1f'"${name}"$'\x1f'"${maxmem}"$'\x1f'"${required_storages}"$'\x1f'"${policy_nodes}"$'\x1f'"${policy_strict}"$'\x1f'"${policy_rule}"$'\x1f'"${destination}"$'\x1f'"${route_note}" + fi + + log "Starting migration for ${guest_type} ${vmid} (${name:-unnamed}) to ${destination}." + log_file="${MIGRATION_LOG_DIR}/${guest_type}-${vmid}.log" + migrate_guest "$vmid" "$guest_type" "$status" "$destination" >"$log_file" 2>&1 & + batch_pids+=("$!") + batch_guests+=("$guest") + batch_logs+=("$log_file") + + if (( ${#batch_pids[@]} >= MAX_PARALLEL_MIGRATIONS )); then + wait_for_migration_batch + fi + done + (( ${#batch_pids[@]} == 0 )) || wait_for_migration_batch + + for guest in "${local_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem reason <<< "$guest" + status="$(guest_status "$vmid" "$guest_type")" + [[ "$status" == "running" ]] || continue + + log "Shutting down local-storage ${guest_type} ${vmid} (${name:-unnamed})." + if shutdown_guest "$vmid" "$guest_type" && + wait_for_guest_stopped "$vmid" "$guest_type"; then + shutdown_successes+=("$guest") + else + warn "Graceful shutdown failed for ${guest_type} ${vmid}; it was not force-stopped." + shutdown_failures+=("$guest") + fi + done + + load_lines final_guests get_local_guests || + die "Could not verify the final guest state on ${LOCAL_NODE}." + for guest in "${final_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem <<< "$guest" + [[ "$status" == "running" ]] && running_final_guests+=("$guest") + done + + printf '\nEvacuation summary:\n' + printf ' Successful migrations: %d\n' "${#migration_successes[@]}" + printf ' Failed migrations: %d\n' "${#migration_failures[@]}" + printf ' Skipped migrations: %d\n' "${#migration_skipped[@]}" + printf ' Unroutable shared guests: %d\n' "${#unroutable_guests[@]}" + printf ' Local guest shutdowns: %d\n' "${#shutdown_successes[@]}" + printf ' Failed local shutdowns: %d\n' "${#shutdown_failures[@]}" + printf ' Guests still running locally: %d\n' "${#running_final_guests[@]}" + + if (( ${#migration_successes[@]} > 0 )); then + printf '\nMigrated guests:\n' + for guest in "${migration_successes[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \ + policy_nodes policy_strict policy_rule destination route_note <<< "$guest" + printf ' %-6s %-5s %-24s -> %s\n' \ + "$vmid" "$guest_type" "$name" "$destination" done - else - warn "Local-storage guests were left running." fi + + if (( ${#migration_failures[@]} > 0 )); then + printf '\nFailed migrations (left unchanged and not force-stopped):\n' + for guest in "${migration_failures[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem required_storages \ + policy_nodes policy_strict policy_rule destination route_note <<< "$guest" + printf ' %-6s %-5s %-24s target=%s\n' \ + "$vmid" "$guest_type" "$name" "$destination" + done + fi + + if (( ${#shutdown_successes[@]} > 0 )); then + printf '\nLocal-storage guests shut down:\n' + for guest in "${shutdown_successes[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem reason <<< "$guest" + printf ' %-6s %-5s %s\n' "$vmid" "$guest_type" "$name" + done + fi + + if (( ${#final_guests[@]} > 0 )); then + printf '\nGuests still assigned to %s:\n' "$LOCAL_NODE" + for guest in "${final_guests[@]}"; do + IFS=$'\x1f' read -r vmid guest_type status name maxmem <<< "$guest" + printf ' %-6s %-5s %-8s %s\n' "$vmid" "$guest_type" "$status" "$name" + done + fi + + printf '\nNo guest was force-stopped.\n' + + if (( ${#migration_failures[@]} > 0 || + ${#migration_skipped[@]} > 0 || + ${#unroutable_guests[@]} > 0 || + ${#shutdown_failures[@]} > 0 || + ${#running_final_guests[@]} > 0 )); then + return 1 + fi +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" fi - -mapfile -t final_guests < <(get_local_guests) - -printf '\nEvacuation summary:\n' -printf ' Migration failures: %d\n' "${#migration_failures[@]}" -printf ' Guests still assigned to %s: %d\n' "$LOCAL_NODE" "${#final_guests[@]}" - -if (( ${#migration_failures[@]} > 0 )); then - printf '\nFailed migrations (left unchanged; not shut down automatically):\n' - printf ' %s\n' "${migration_failures[@]}" -fi - -if (( ${#final_guests[@]} > 0 )); then - printf '\nGuests still assigned to %s:\n' "$LOCAL_NODE" - printf ' %s\n' "${final_guests[@]}" -fi - -printf '\nNo guest was force-stopped.\n' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 3739d7f..f8bb34f 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -18,6 +18,7 @@ FINISH_ACTION() { FINISH_FAILED_ACTION() { (( ACTION_REQUESTED == 1 )) && exit 1 ENTER2CONTINUE + return 1 } declare -a TAPM_TEMP_DIRS=() @@ -89,6 +90,22 @@ TAPM_DOWNLOAD_HTTPS() { fi } +TAPM_DOWNLOAD_SHA256() { + local url="$1" + local destination="$2" + local expected_sha256="${3,,}" + local label="${4:-Installer}" + local actual_sha256 + + TAPM_DOWNLOAD_HTTPS "$url" "$destination" "$label" || return 1 + actual_sha256="$(sha256sum "$destination" | cut -d' ' -f1)" + if [[ "$actual_sha256" != "$expected_sha256" ]]; then + rm -f -- "$destination" + echo -e "${idsCL[LightRed]}${label} checksum did not match; the file was removed.${idsCL[Default]}" + return 1 + fi +} + TAPM_PACKAGE_INSTALLED() { dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q '^install ok installed$' @@ -108,6 +125,23 @@ TAPM_WAIT_FOR_SERVICE() { return 1 } +TAPM_WAIT_FOR_TCP_PORT() { + local port="$1" + local attempts="${2:-30}" + local attempt=0 + + while (( attempt < attempts )); do + if ss -H -lnt 2>/dev/null | + awk -v port="$port" '$4 ~ (":" port "$") { found=1 } END { exit !found }'; then + return 0 + fi + sleep 1 + ((attempt++)) + done + + return 1 +} + trap TAPM_CLEAN_ALL_TEMP_DIRS EXIT TAPM_CLEAR_AUTHORIZATION() { @@ -286,13 +320,68 @@ INSTALL_PROXMENUX() { } PROXMENUX_POST_INSTALL() { - PMFLDR='/usr/local/share/proxmenux/scripts/post_install' - [ ! -f "${PMFLDR}/customizable_post_install.sh" ] && INSTALL_PROXMENUX - bash "${PMFLDR}/customizable_post_install.sh" - - touch /opt/.PROXMENUX_POST_INSTALL - [ -s /etc/apt/sources.list ] && cat /dev/null > /etc/apt/sources.list - + local backup_dir='/var/backups/ta-proxmenu' + local backup_file='' + local post_install_dir='/usr/local/share/proxmenux/scripts/post_install' + local post_install_script="${post_install_dir}/customizable_post_install.sh" + local timestamp + + if [[ ! -f "$post_install_script" ]]; then + INSTALL_PROXMENUX + [[ -f "$post_install_script" ]] || return 1 + fi + + if ! bash "$post_install_script"; then + echo -e "${idsCL[LightRed]}The ProxMenux post-install script failed. A completion marker was not created.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + if [[ -e /etc/apt/sources.list && ! -f /etc/apt/sources.list ]]; then + echo -e "${idsCL[LightRed]}/etc/apt/sources.list is not a regular file and was not changed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + if [[ -s /etc/apt/sources.list ]]; then + timestamp="$(date +%Y%m%d-%H%M%S)" + if ! install -d -m 0700 "$backup_dir" || + ! backup_file="$(mktemp "${backup_dir}/sources.list.proxmenux.${timestamp}.XXXXXX")" || + ! install -m 0600 /etc/apt/sources.list "$backup_file"; then + [[ -n "$backup_file" ]] && rm -f -- "$backup_file" + echo -e "${idsCL[LightRed]}Could not back up /etc/apt/sources.list; it was not cleared.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + fi + + if ! : >/etc/apt/sources.list || + ! chmod 0644 /etc/apt/sources.list; then + echo -e "${idsCL[LightRed]}Could not enforce an empty /etc/apt/sources.list.${idsCL[Default]}" + [[ -n "$backup_file" ]] && + echo "Backup retained at: ${backup_file}" + FINISH_FAILED_ACTION + return + fi + + if ! apt-get update; then + echo -e "${idsCL[LightRed]}APT repository validation failed after ProxMenux post-install.${idsCL[Default]}" + [[ -n "$backup_file" ]] && + echo "Removed sources were retained at: ${backup_file}" + FINISH_FAILED_ACTION + return + fi + + if ! touch /opt/.PROXMENUX_POST_INSTALL; then + echo -e "${idsCL[LightRed]}Post-install succeeded, but the completion marker could not be created.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + echo -e "\n${idsCL[Green]}ProxMenux post-install completed and APT sources were validated.${idsCL[Default]}" + [[ -n "$backup_file" ]] && + echo "ProxMenux additions to sources.list were saved at: ${backup_file}" + FINISH_ACTION } INSTALL_GLANCES() { @@ -501,48 +590,193 @@ INSTALL_S1() { FINISH_ACTION } +TAPM_SYSTEM_PRODUCT_NAME() { + local product_name='' + + if [[ -r /sys/class/dmi/id/product_name ]]; then + read -r product_name /dev/null 2>&1; then + product_name="$(dmidecode -s system-product-name 2>/dev/null)" + fi + + printf '%s\n' "$product_name" +} + +TAPM_OMSA_SUPPORTED_HARDWARE() { + local product_name + + product_name="$(TAPM_SYSTEM_PRODUCT_NAME)" + [[ "$product_name" =~ PowerEdge[[:space:]]+[[:alpha:]]+[0-9](3|4)[0-9][[:alnum:]-]* ]] +} + +TAPM_OMSA_SUPPORTED_PLATFORM() { + local architecture + local codename + local os_version + local pve_version + + architecture="$(dpkg --print-architecture 2>/dev/null)" + os_version="$(. /etc/os-release 2>/dev/null; printf '%s' "${VERSION_ID:-}")" + codename="$(. /etc/os-release 2>/dev/null; printf '%s' "${VERSION_CODENAME:-}")" + pve_version="$(pveversion 2>/dev/null | head -n 1)" + + [[ "$architecture" == "amd64" && + "$os_version" == "13" && + "$codename" == "trixie" && + "$pve_version" == pve-manager/9.* ]] +} + INSTALL_OMSA() { - echo - mkdir -p /tmp/omsa - cd /tmp/omsa || return 1 - apt install -y gnupg libcurl4t64 libncurses6 libxslt1.1 libgpm2 libtinfo6 - mkdir -p /etc/apt/keyrings - wget -qO - https://linux.dell.com/repo/pgp_pubkeys/0x1285491434D8786F.asc | gpg --dearmor -o /etc/apt/keyrings/linux.dell.com.gpg - chmod +r /etc/apt/keyrings/linux.dell.com.gpg - echo "deb [signed-by=/etc/apt/keyrings/linux.dell.com.gpg] http://linux.dell.com/repo/community/openmanage/11000/jammy jammy main" > /etc/apt/sources.list.d/linux.dell.com.list - apt update - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/o/openwsman/libwsman-curl-client-transport1_2.6.5-0ubuntu16_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/o/openwsman/libwsman-client4t64_2.6.5-0ubuntu16_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/o/openwsman/libwsman1t64_2.6.5-0ubuntu16_amd64.deb - # wget -c http://http.us.debian.org/debian/pool/main/libx/libxml2/libxml2-16_2.15.1+dfsg-2+b1_amd64.deb - wget -c http://http.us.debian.org/debian/pool/main/libx/libxml2/libxml2-16_2.15.2+dfsg-0.1_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/o/openwsman/libwsman-server1t64_2.6.5-0ubuntu16_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/s/sblim-sfcc/libcimcclient0_2.2.8-0ubuntu2_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/o/openwsman/openwsman_2.6.5-0ubuntu16_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/multiverse/c/cim-schema/cim-schema_2.48.0-0ubuntu1_all.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/s/sblim-sfc-common/libsfcutil0_1.0.1-0ubuntu4_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/multiverse/s/sblim-sfcb/sfcb_1.4.9-0ubuntu7_amd64.deb - wget -c http://archive.ubuntu.com/ubuntu/pool/universe/s/sblim-cmpi-devel/libcmpicppimpl0_2.0.3-0ubuntu2_amd64.deb - wget -c http://ftp.us.debian.org/debian/pool/main/o/openssl/libssl1.1_1.1.1w-0+deb11u1_amd64.deb - dpkg -i libwsman-curl-client-transport1_2.6.5-0ubuntu16_amd64.deb - dpkg -i libwsman-client4t64_2.6.5-0ubuntu16_amd64.deb - dpkg -i libxml2-16_2.15.2+dfsg-0.1_amd64.deb - dpkg -i libwsman1t64_2.6.5-0ubuntu16_amd64.deb - dpkg -i libwsman-server1t64_2.6.5-0ubuntu16_amd64.deb - dpkg -i libcimcclient0_2.2.8-0ubuntu2_amd64.deb - dpkg -i openwsman_2.6.5-0ubuntu16_amd64.deb - dpkg -i cim-schema_2.48.0-0ubuntu1_all.deb - dpkg -i libsfcutil0_1.0.1-0ubuntu4_amd64.deb - dpkg -i sfcb_1.4.9-0ubuntu7_amd64.deb - dpkg -i libcmpicppimpl0_2.0.3-0ubuntu2_amd64.deb - dpkg -i libssl1.1_1.1.1w-0+deb11u1_amd64.deb - apt install -y srvadmin-all - /opt/dell/srvadmin/sbin/srvadmin-services.sh start - rm -rf -- /tmp/omsa - - echo -e "\n${idsCL[Green]}Dell OMSA has been installed${idsCL[Default]}" - echo -e "\n${idsCL[LightCyan]}Available at: ${idsCL[LightGreen]}https://${RNIP}:1311${idsCL[Default]}" - FINISH_ACTION + local base_url='https://archive.ubuntu.com/ubuntu/pool' + local dell_key + local dell_keyring + local dell_source + local index + local product_name + local temp_dir + local -a filenames=( + 'libwsman-curl-client-transport1_2.6.5-0ubuntu16_amd64.deb' + 'libwsman-client4t64_2.6.5-0ubuntu16_amd64.deb' + 'libxml2-16_2.15.2+dfsg-0.1_amd64.deb' + 'libwsman1t64_2.6.5-0ubuntu16_amd64.deb' + 'libwsman-server1t64_2.6.5-0ubuntu16_amd64.deb' + 'libcimcclient0_2.2.8-0ubuntu2_amd64.deb' + 'openwsman_2.6.5-0ubuntu16_amd64.deb' + 'cim-schema_2.48.0-0ubuntu1_all.deb' + 'libsfcutil0_1.0.1-0ubuntu4_amd64.deb' + 'sfcb_1.4.9-0ubuntu7_amd64.deb' + 'libcmpicppimpl0_2.0.3-0ubuntu2_amd64.deb' + 'libssl1.1_1.1.1w-0+deb11u1_amd64.deb' + ) + local -a urls=( + "${base_url}/universe/o/openwsman/${filenames[0]}" + "${base_url}/universe/o/openwsman/${filenames[1]}" + 'https://snapshot.debian.org/file/32f51d914435fd29ce31af7ba17525f6276ea58d' + "${base_url}/universe/o/openwsman/${filenames[3]}" + "${base_url}/universe/o/openwsman/${filenames[4]}" + "${base_url}/universe/s/sblim-sfcc/${filenames[5]}" + "${base_url}/universe/o/openwsman/${filenames[6]}" + "${base_url}/multiverse/c/cim-schema/${filenames[7]}" + "${base_url}/universe/s/sblim-sfc-common/${filenames[8]}" + "${base_url}/multiverse/s/sblim-sfcb/${filenames[9]}" + "${base_url}/universe/s/sblim-cmpi-devel/${filenames[10]}" + "https://deb.debian.org/debian/pool/main/o/openssl/${filenames[11]}" + ) + local -a checksums=( + '42fdd34722ac1304427f80c4176ee781d057f05669d485f0ee1da4d87df7488c' + '6d1855a2e8263e9a578b4c0bb7a963a6d99dc8d2ef41e287725799dcad0c6cb3' + '8571682a07f329bb462569502b57aced4866e5b95c2db3ec7e5414a5b3bbdc14' + '61b91e8f234c5f2f87b4bce3534bc2f2304d50cd2fd95f426f12fc73d80e27b4' + '5d3f948ab605b4973b399f53bd62bddd70eb01161769a0d39a811399fe7c2daf' + '14b9ac374f88bd44e57395e87faa76d99d02e242c813fe30083d5bbfafec5870' + '62b30fcf41dae0c1d841f67a46049b7dfa4dfffe314e4226a776eb134605b7fc' + 'a87d16d41e81092c7ada43824a97cf79fab18c4a3722ef6f0476ad697a3d9ab7' + 'ba890cf5f2359befd3da1e5763672ef8b03d5424fa4e3d1ef21c9d52884af247' + '3eb5dce0a873f8eb77174fdd5e02ac55a989f7a73bd5ef8c8aae501d225d7524' + '284acfbb6d675496046ee46e6d5ea6c70ceafa3781cf1eece04f612cbadf117c' + 'aadf8b4b197335645b230c2839b4517aa444fd2e8f434e5438c48a18857988f7' + ) + local -a package_paths=() + + echo + product_name="$(TAPM_SYSTEM_PRODUCT_NAME)" + if ! TAPM_OMSA_SUPPORTED_HARDWARE; then + echo -e "${idsCL[LightRed]}Dell OMSA legacy installation is limited to PowerEdge x30/x40 systems.${idsCL[Default]}" + echo "Detected system: ${product_name:-Unknown}" + FINISH_FAILED_ACTION + return + fi + if ! TAPM_OMSA_SUPPORTED_PLATFORM; then + echo -e "${idsCL[LightRed]}This legacy OMSA package set requires PVE 9 on Debian 13 (Trixie), amd64.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + echo -e "${idsCL[LightYellow]}Installing legacy Dell OMSA 11.0 compatibility packages on ${product_name}.${idsCL[Default]}" + + if ! TAPM_CREATE_TEMP_DIR omsa; then + FINISH_FAILED_ACTION + return + fi + temp_dir="$TAPM_TEMP_DIR" + dell_key="${temp_dir}/dell-openmanage.asc" + dell_keyring="${temp_dir}/linux.dell.com.gpg" + dell_source="${temp_dir}/linux.dell.com.list" + + if ! TAPM_DOWNLOAD_SHA256 \ + 'https://linux.dell.com/repo/pgp_pubkeys/0x1285491434D8786F.asc' \ + "$dell_key" \ + '92f9622bf300f1fc8a4ef12d8e5efef6511b089c63c15e635cfc7429499e86d4' \ + 'Dell OpenManage signing key'; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + FINISH_FAILED_ACTION + return + fi + + for index in "${!filenames[@]}"; do + package_paths+=("${temp_dir}/${filenames[$index]}") + if ! TAPM_DOWNLOAD_SHA256 "${urls[$index]}" "${package_paths[$index]}" \ + "${checksums[$index]}" "${filenames[$index]}"; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + FINISH_FAILED_ACTION + return + fi + done + + if ! apt-get update || + ! DEBIAN_FRONTEND=noninteractive apt-get install -y \ + gnupg libcurl4t64 libncurses6 libxslt1.1 libgpm2 libtinfo6 || + ! gpg --batch --yes --dearmor --output "$dell_keyring" "$dell_key"; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}Unable to prepare the Dell OMSA repository.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + if ! printf '%s\n' \ + 'deb [signed-by=/etc/apt/keyrings/linux.dell.com.gpg] https://linux.dell.com/repo/community/openmanage/11000/jammy jammy main' \ + >"$dell_source" || + ! mkdir -p /etc/apt/keyrings || + ! install -m 0644 "$dell_keyring" /etc/apt/keyrings/linux.dell.com.gpg || + ! install -m 0644 "$dell_source" /etc/apt/sources.list.d/linux.dell.com.list; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}Unable to write the Dell OMSA repository configuration.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + if ! apt-get update; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}The Dell OMSA repository could not be refreshed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + if ! dpkg -i "${package_paths[@]}"; then + if ! DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y || + ! dpkg -i "${package_paths[@]}"; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}The legacy OMSA compatibility packages could not be installed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + fi + + if ! DEBIAN_FRONTEND=noninteractive apt-get install -y srvadmin-all || + ! TAPM_PACKAGE_INSTALLED srvadmin-all || + [[ ! -x /opt/dell/srvadmin/sbin/srvadmin-services.sh ]] || + ! /opt/dell/srvadmin/sbin/srvadmin-services.sh start || + ! TAPM_WAIT_FOR_TCP_PORT 1311 30; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "${idsCL[LightRed]}Dell OMSA installation failed or port 1311 did not become available.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo -e "\n${idsCL[Green]}Dell OMSA has been installed and verified.${idsCL[Default]}" + echo -e "\n${idsCL[LightCyan]}Available at: ${idsCL[LightGreen]}https://${RNIP}:1311${idsCL[Default]}" + FINISH_ACTION } DOWNLOAD_VIRTIO() { @@ -558,36 +792,198 @@ DOWNLOAD_VIRTIO() { FINISH_ACTION } -DETECT_CPU(){ - # if [ ! -f /etc/apt/sources.list.d/proxlb.list ]; then - # echo "deb https://repo.gyptazy.com/stable /" > /etc/apt/sources.list.d/proxlb.list - # wget -O /etc/apt/trusted.gpg.d/proxlb.asc https://repo.gyptazy.com/repository.gpg - # apt-get update - # fi - if [ ! -f /etc/apt/sources.list.d/gyptazy.list ]; then - curl https://git.gyptazy.com/api/packages/gyptazy/debian/repository.key -o /etc/apt/keyrings/gyptazy.asc - echo "deb [signed-by=/etc/apt/keyrings/gyptazy.asc] https://packages.gyptazy.com/api/packages/gyptazy/debian trixie main" | sudo tee -a /etc/apt/sources.list.d/gyptazy.list - apt update - fi - if [ "$(dpkg -l | awk '/proxclmc/ {print }'|wc -l)" -eq 0 ]; then - apt -y install proxclmc - fi +TAPM_INSTALL_PROXCLMC() { + local key_url='https://git.gyptazy.com/api/packages/gyptazy/debian/repository.key' + local keyring='/etc/apt/keyrings/gyptazy.asc' + local repository_file='/etc/apt/sources.list.d/gyptazy.list' + local repository_line='deb [signed-by=/etc/apt/keyrings/gyptazy.asc] https://packages.gyptazy.com/api/packages/gyptazy/debian trixie main' + local temp_dir + local temp_key + TAPM_PACKAGE_INSTALLED proxclmc && command -v proxclmc >/dev/null 2>&1 && + return 0 + + TAPM_CREATE_TEMP_DIR proxclmc || return 1 + temp_dir="$TAPM_TEMP_DIR" + temp_key="${temp_dir}/gyptazy.asc" + + TAPM_DOWNLOAD_HTTPS "$key_url" "$temp_key" "ProxCLMC repository key" || + return 1 + if ! command -v gpg >/dev/null 2>&1 || + ! gpg --batch --show-keys "$temp_key" >/dev/null 2>&1; then + echo -e "${idsCL[LightRed]}The downloaded ProxCLMC repository key is not a valid OpenPGP key.${idsCL[Default]}" + return 1 + fi + + if ! install -d -m 0755 /etc/apt/keyrings || + ! install -m 0644 "$temp_key" "$keyring" || + ! printf '%s\n' "$repository_line" >"$repository_file" || + ! chmod 0644 "$repository_file"; then + echo -e "${idsCL[LightRed]}Could not configure the ProxCLMC package repository.${idsCL[Default]}" + return 1 + fi + + if ! apt-get update || + ! apt-get install -y proxclmc || + ! command -v proxclmc >/dev/null 2>&1; then + echo -e "${idsCL[LightRed]}ProxCLMC could not be installed.${idsCL[Default]}" + return 1 + fi + + TAPM_CLEAN_TEMP_DIR "$temp_dir" +} + +TAPM_CLUSTER_QEMU_GUESTS() { + pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | + python3 -c ' +import json +import sys + +for guest in json.load(sys.stdin): + if guest.get("type") != "qemu": + continue + print( + guest.get("vmid", ""), + str(guest.get("name", "")) + .replace("\x1f", " ") + .replace("\r", " ") + .replace("\n", " "), + guest.get("node", ""), + "yes" if guest.get("template") in (1, True, "1") else "no", + sep="\x1f", + ) +' +} + +TAPM_QEMU_CPU_MODEL() { + local vmid="$1" + local config_output + local cpu_model + + config_output="$(qm config "$vmid" 2>/dev/null)" || return 1 + cpu_model="$( + awk -F': ' '$1 == "cpu" { print $2; exit }' <<< "$config_output" + )" + [[ -n "$cpu_model" ]] || cpu_model='kvm64' + printf '%s\n' "$cpu_model" +} + +DETECT_CPU() { + local answer + local cpu_model + local current_cpu + local guest + local guest_output + local name + local node + local template + local vmid + local -a changes=() + local -a failures=() + local -a successes=() + + for command in apt-get curl gpg install pvesh python3 qm; do + if ! command -v "$command" >/dev/null 2>&1; then + echo -e "${idsCL[LightRed]}${command} is required for CPU compatibility detection.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + done + + if ! TAPM_INSTALL_PROXCLMC; then + FINISH_FAILED_ACTION + return + fi + + echo -e "\n${idsCL[LightCyan]}Analyzing CPU compatibility across the cluster...${idsCL[Default]}" + cpu_model="$(proxclmc --list-only 2>/dev/null)" || { + echo -e "${idsCL[LightRed]}ProxCLMC could not determine a compatible CPU model.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + } + cpu_model="${cpu_model//$'\r'/}" + cpu_model="${cpu_model//$'\n'/}" + if [[ ! "$cpu_model" =~ ^x86-64-v(1|2-AES|3|4)$ ]]; then + echo -e "${idsCL[LightRed]}ProxCLMC returned an unexpected CPU model: ${cpu_model:-empty}.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + guest_output="$(TAPM_CLUSTER_QEMU_GUESTS)" || { + echo -e "${idsCL[LightRed]}Could not read cluster QEMU resources.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + } + if [[ -z "$guest_output" ]]; then + echo -e "\n${idsCL[LightCyan]}No QEMU VMs or templates were found in the cluster.${idsCL[Default]}" + FINISH_ACTION + return + fi + + while IFS=$'\x1f' read -r vmid name node template; do + [[ -n "$vmid" ]] || continue + current_cpu="$(TAPM_QEMU_CPU_MODEL "$vmid")" || { + echo -e "${idsCL[LightRed]}Could not read the CPU configuration for VMID ${vmid}.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + } + [[ "$current_cpu" == "$cpu_model" ]] && continue + changes+=( + "${vmid}"$'\x1f'"${name}"$'\x1f'"${node}"$'\x1f'"${template}"$'\x1f'"${current_cpu}" + ) + done <<< "$guest_output" + + if (( ${#changes[@]} == 0 )); then + echo -e "\n${idsCL[Green]}All QEMU VMs and templates already use ${cpu_model}.${idsCL[Default]}" + FINISH_ACTION + return + fi + + printf '\nRecommended cluster CPU model: %s\n' "$cpu_model" + printf '\n%-7s %-28s %-20s %-10s %-24s %s\n' \ + 'VMID' 'NAME' 'NODE' 'TEMPLATE' 'CURRENT CPU' 'PROPOSED CPU' + printf '%-7s %-28s %-20s %-10s %-24s %s\n' \ + '-------' '----------------------------' '--------------------' \ + '----------' '------------------------' '----------------' + for guest in "${changes[@]}"; do + IFS=$'\x1f' read -r vmid name node template current_cpu <<< "$guest" + printf '%-7s %-28.28s %-20.20s %-10s %-24.24s %s\n' \ + "$vmid" "${name:-unnamed}" "$node" "$template" "$current_cpu" "$cpu_model" + done + + echo -en "\n${idsCL[LightCyan]}Apply this CPU model to ${#changes[@]} VM(s) and template(s) (y/N)?${idsCL[Default]} " + read -r -n 1 answer echo - proxclmc - echo - echo -en "${idsCL[LightCyan]}Would you like to set '${idsCL[LightGreen]}cpu: $(proxclmc --list-only)${idsCL[LightCyan]}' on all VMs (y/N)?${idsCL[Default]} " - read -n 1 choice - case "$choice" in - [Yy]) - sed -i "/cpu:/c cpu: $(proxclmc --list-only)" /etc/pve/nodes/*/qemu-server/*.conf - echo - echo -e "\n${idsCL[Green]}All VM's have been reconfigured\n${idsCL[LightCyan]}This will require the VM's to be powered off and then turned back on in order to take effect${idsCL[Default]}" - FINISH_ACTION - ;; - *) echo;; - esac - + [[ "$answer" =~ ^[Yy]$ ]] || return + + for guest in "${changes[@]}"; do + IFS=$'\x1f' read -r vmid name node template current_cpu <<< "$guest" + if qm set "$vmid" --cpu "$cpu_model" && + [[ "$(TAPM_QEMU_CPU_MODEL "$vmid")" == "$cpu_model" ]]; then + successes+=("$guest") + else + failures+=("$guest") + fi + done + + printf '\nCPU model update summary:\n' + printf ' Successful: %d\n' "${#successes[@]}" + printf ' Failed: %d\n' "${#failures[@]}" + + if (( ${#failures[@]} > 0 )); then + printf '\nFailed VM/template updates:\n' + for guest in "${failures[@]}"; do + IFS=$'\x1f' read -r vmid name node template current_cpu <<< "$guest" + printf ' %s (%s) on %s\n' "$vmid" "${name:-unnamed}" "$node" + done + echo -e "\n${idsCL[LightRed]}One or more CPU model updates failed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + echo -e "\n${idsCL[Green]}The CPU model was updated to ${cpu_model}.${idsCL[Default]}" + echo -e "${idsCL[LightCyan]}Running VMs must be fully shut down and started again before the new CPU model takes effect.${idsCL[Default]}" + FINISH_ACTION } RESTART_SERVICE_GROUP() { @@ -674,26 +1070,86 @@ RESTART_PVE_SERVICES() { pvedaemon pveproxy pvestatd pvescheduler } -MAINTENANCE_MODE(){ +HA_NODE_IN_MAINTENANCE() { + local node="$1" + local ha_status + + ha_status="$(ha-manager status 2>/dev/null)" || return 2 + grep -F "lrm ${node} " <<< "$ha_status" | + grep -q "maintenance mode" +} + +WAIT_FOR_HA_MAINTENANCE_STATE() { + local node="$1" + local expected_state="$2" + local attempts="${3:-30}" + local attempt=0 + local active=0 + local state_result + + while (( attempt < attempts )); do + active=0 + HA_NODE_IN_MAINTENANCE "$node" + state_result=$? + [[ "$state_result" -eq 0 ]] && active=1 + if [[ "$state_result" -gt 1 ]]; then + sleep 1 + ((attempt++)) + continue + fi + if [[ "$expected_state" == "enabled" && "$active" -eq 1 ]] || + [[ "$expected_state" == "disabled" && "$active" -eq 0 ]]; then + return 0 + fi + sleep 1 + ((attempt++)) + done + return 1 +} + +MAINTENANCE_MODE() { + local choice local local_node + local maintenance_state local_node="$(hostname -s)" - if ha-manager status | grep -F "$local_node" | grep -q "maintenance mode"; then + HA_NODE_IN_MAINTENANCE "$local_node" + maintenance_state=$? + if [[ "$maintenance_state" -gt 1 ]]; then + echo -e "\n${idsCL[LightRed]}Could not read HA maintenance status for ${local_node}.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + if [[ "$maintenance_state" -eq 0 ]]; then echo -en "${idsCL[LightCyan]}Take the local host out of maintenance mode (Y/n)?${idsCL[Default]} " else echo -en "${idsCL[LightCyan]}Put the local host into maintenance mode (Y/n)?${idsCL[Default]} " fi - read -n 1 choice + read -r -n 1 choice case "$choice" in - [Nn]) echo;; - *) echo - if ha-manager status | grep -F "$local_node" | grep -q "maintenance mode"; then - ha-manager crm-command node-maintenance disable "$local_node" + [Nn]) echo;; + *) echo + if [[ "$maintenance_state" -eq 0 ]]; then + if ! ha-manager crm-command node-maintenance disable "$local_node" || + ! WAIT_FOR_HA_MAINTENANCE_STATE "$local_node" disabled; then + echo -e "\n${idsCL[LightRed]}Failed to take ${local_node} out of HA maintenance mode.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi echo -e "\n${idsCL[Green]}This host will be taken out of maintenance mode${idsCL[Default]}\n" else - ha-manager crm-command node-maintenance enable "$local_node" + if ! ha-manager crm-command node-maintenance enable "$local_node"; then + echo -e "\n${idsCL[LightRed]}Failed to request HA maintenance mode for ${local_node}.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi echo -e "\n${idsCL[Green]}This host will be entered into maintenance mode${idsCL[Default]}\n" - bash /opt/idssys/ta-proxmenu/inc/evacuate-proxmox-node.sh + if ! bash /opt/idssys/ta-proxmenu/inc/evacuate-proxmox-node.sh; then + echo -e "\n${idsCL[LightRed]}Evacuation did not complete. ${local_node} remains in HA maintenance mode.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi fi FINISH_ACTION ;; @@ -991,19 +1447,15 @@ SHOW_ABOUT() { HOST_SETUP_MENU() { local -a labels - local -a values=( - "post_install" - "cpu" - "virtio" - "glances" - "omsa" - ) + local -a values while true; do labels=("Run ProxMenux post-install configuration") + values=("post_install") [ -f /opt/.PROXMENUX_POST_INSTALL ] && labels[0]="Run ProxMenux post-install configuration (previously run)" labels+=("Detect CPU model for live migrations") + values+=("cpu") if [ -f "${DLDIR}/${VIRTIO_FILE}" ]; then labels+=("VirtIO drivers (${VIRTIO_FILE} already downloaded)") @@ -1012,14 +1464,19 @@ HOST_SETUP_MENU() { else labels+=("Download current VirtIO drivers") fi + values+=("virtio") command -v glances >/dev/null 2>&1 && labels+=("Glances CLI monitor (installed)") || labels+=("Install Glances CLI monitor") + values+=("glances") - dpkg-query -W -f='${Status}' srvadmin-all 2>/dev/null | grep -q "install ok installed" && - labels+=("Dell OpenManage Server Administrator (installed)") || - labels+=("Install Dell OpenManage Server Administrator") + if TAPM_OMSA_SUPPORTED_HARDWARE; then + TAPM_PACKAGE_INSTALLED srvadmin-all && + labels+=("Dell OMSA - Legacy Dell Hosts (installed)") || + labels+=("Install Dell OMSA - Legacy Dell Hosts") + values+=("omsa") + fi SELECT_MENU "Host Setup" labels values case "$MENU_SELECTION" in diff --git a/run.sh b/run.sh index ff7aaf6..48869cd 100755 --- a/run.sh +++ b/run.sh @@ -156,6 +156,77 @@ UPDATE_REPOSITORY() { esac } +SWITCH_TAPM_BRANCH() { + local target_branch="$1" + local repository='/opt/idssys/ta-proxmenu' + local current_branch + local target_state='' + + if ! git check-ref-format --branch "$target_branch" >/dev/null 2>&1; then + echo -e "${idsCL[Red]}'${target_branch}' is not a valid Git branch name.${idsCL[Default]}" + return 1 + fi + if [[ ! -d "${repository}/.git" ]]; then + echo -e "${idsCL[Red]}TA-ProxMenu is not a Git repository.${idsCL[Default]}" + return 1 + fi + + current_branch="$(git -C "$repository" branch --show-current 2>/dev/null)" + if [[ -z "$current_branch" ]]; then + echo -e "${idsCL[Red]}TA-ProxMenu is in a detached HEAD state; branch switching was refused.${idsCL[Default]}" + return 1 + fi + if [[ "$target_branch" == "$current_branch" ]]; then + echo -e "${idsCL[Green]}TA-ProxMenu is already using '${target_branch}'.${idsCL[Default]}" + return 0 + fi + if TAPM_GIT_WORKTREE_DIRTY "$repository"; then + echo -e "${idsCL[LightYellow]}TA-ProxMenu has local changes; branch switching was refused to preserve them.${idsCL[Default]}" + git -C "$repository" status --short + return 1 + fi + + echo -e "${idsCL[LightCyan]}Fetching TA-ProxMenu branch '${target_branch}'...${idsCL[Default]}" + if ! TAPM_GIT_FETCH_BRANCH "$repository" "$target_branch" 30 >/dev/null 2>&1; then + echo -e "${idsCL[Red]}Could not fetch branch '${target_branch}' from origin.${idsCL[Default]}" + return 1 + fi + + if git -C "$repository" show-ref --verify --quiet "refs/heads/${target_branch}"; then + target_state="$( + TAPM_GIT_RELATION "$repository" "refs/heads/${target_branch}" \ + "refs/remotes/origin/${target_branch}" + )" || { + echo -e "${idsCL[Red]}Could not compare local and remote '${target_branch}'.${idsCL[Default]}" + return 1 + } + case "$target_state" in + ahead) + echo -e "${idsCL[LightYellow]}Local branch '${target_branch}' has commits not on origin; switching was refused.${idsCL[Default]}" + return 1 + ;; + diverged) + echo -e "${idsCL[LightYellow]}Local branch '${target_branch}' has diverged from origin; switching was refused.${idsCL[Default]}" + return 1 + ;; + esac + + git -C "$repository" switch "$target_branch" >/dev/null || return 1 + else + git -C "$repository" switch --create "$target_branch" \ + --track "origin/${target_branch}" >/dev/null || return 1 + fi + + if [[ "$target_state" == "behind" ]] && + ! TAPM_GIT_FAST_FORWARD "$repository" "$target_branch" >/dev/null; then + echo -e "${idsCL[Red]}Could not fast-forward '${target_branch}'; no commits were discarded.${idsCL[Default]}" + return 1 + fi + + rm -f /var/cache/ta-proxmenu/update-status 2>/dev/null || true + echo -e "${idsCL[Green]}TA-ProxMenu is now using branch '${target_branch}'.${idsCL[Default]}" +} + INSTALL_UPDATES() { local current_branch local defaults_warning=0 @@ -199,6 +270,10 @@ case "${1:-}" in INSTALL_UPDATES exit $? ;; + main|V[0-9]*) + SWITCH_TAPM_BRANCH "$1" + exit $? + ;; tapm) exit 0 ;; From d360b571fb169ff06baaa9e15a58ae4aa67e2e35 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 17:57:45 -0500 Subject: [PATCH 24/76] update --- README.md | 6 +++--- run.sh | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a06711c..8b52657 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ interactive menu. Update availability is checked in the background and cached; updates are installed only when explicitly selected or requested with `tapm update`. -Use `tapm V2` to switch an installed copy to the published V2 branch for -testing, and `tapm main` to switch it back. The command refuses to switch when -local changes, local-only commits, or diverged branch history would be at risk. +When testing this branch, use `tapm main` to switch the installed copy back to +the published main branch. The command refuses to switch when local changes, +local-only commits, or diverged branch history would be at risk. The required iDSSYS Defaults repository is handled separately: it is automatically refreshed before launch when its last successful check is more diff --git a/run.sh b/run.sh index 48869cd..96f8194 100755 --- a/run.sh +++ b/run.sh @@ -270,7 +270,7 @@ case "${1:-}" in INSTALL_UPDATES exit $? ;; - main|V[0-9]*) + main) SWITCH_TAPM_BRANCH "$1" exit $? ;; From 9cc1c64c643e42d1e89e086ad1129da429cafaec Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 18:06:03 -0500 Subject: [PATCH 25/76] Update run.sh --- run.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/run.sh b/run.sh index 96f8194..babff3c 100755 --- a/run.sh +++ b/run.sh @@ -224,7 +224,8 @@ SWITCH_TAPM_BRANCH() { fi rm -f /var/cache/ta-proxmenu/update-status 2>/dev/null || true - echo -e "${idsCL[Green]}TA-ProxMenu is now using branch '${target_branch}'.${idsCL[Default]}" + echo -e "${idsCL[Green]}TA-ProxMenu is now using branch '${target_branch}'. Loading its menu...${idsCL[Default]}" + exec /opt/idssys/ta-proxmenu/run.sh } INSTALL_UPDATES() { From 808546846ce01dbe91ec633f2a25468b948330cc Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 18:35:35 -0500 Subject: [PATCH 26/76] update --- README.md | 5 + defaults.inc | 11 +- proxmenu-scripts.sh | 311 +++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 300 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 8b52657..5b51fc8 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,11 @@ CPU compatibility detection previews cluster-wide QEMU VM and template changes before applying the ProxCLMC recommendation through the Proxmox CLI. Running VMs are not restarted automatically. +VirtIO downloads are managed from a dedicated submenu. The stable release is +checked only when that submenu is opened, and curated compatibility ISOs are +available for Windows Server 2008, 2008 R2, 2012/R2, and 2016. Downloads are +validated before atomically replacing a file with the same name. + Maintenance evacuation leaves HA-managed guests under Proxmox HA control. Remaining shared-storage guests are routed to online, non-maintenance nodes with the required storage, while local-storage guests are gracefully shut diff --git a/defaults.inc b/defaults.inc index 7039e48..921b6d0 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-34' +VERS='2026.7.25-35' noupdate=' ' @@ -15,15 +15,6 @@ else fi VIRTIO_STABLE_URL="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso" -VIRTIO_FALLBACK_URL="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/archive-virtio/virtio-win-0.1.285-1/virtio-win-0.1.285.iso" -VIRTIO_DOWNLOAD_URL="$(curl --location --fail --silent --show-error --head \ - --connect-timeout 3 --max-time 10 --output /dev/null \ - --write-out '%{url_effective}' "$VIRTIO_STABLE_URL" 2>/dev/null)" - -if [[ -z "$VIRTIO_DOWNLOAD_URL" ]]; then - VIRTIO_DOWNLOAD_URL="$VIRTIO_FALLBACK_URL" -fi -VIRTIO_FILE="${VIRTIO_DOWNLOAD_URL##*/}" TAPM_BROKER_URL='https://tapm.scity.us' S1_BROKER_PACKAGE='sentinelone-linux' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index f8bb34f..27f7aa5 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -779,19 +779,298 @@ INSTALL_OMSA() { FINISH_ACTION } -DOWNLOAD_VIRTIO() { - - echo -e "\n${idsCL[LightCyan]}Current \"Stable\" version available for download: ${idsCL[White]}${VIRTIO_FILE}${idsCL[Default]}" - if [ -f "${DLDIR}/${VIRTIO_FILE}" ]; then - echo -en "\n${idsCL[LightRed]}Removing existing download ... " - rm -f "${DLDIR}/${VIRTIO_FILE}" - echo -e "${idsCL[Red]}Done${idsCL[Default]}" - fi - wget -q -P "$DLDIR" "$VIRTIO_DOWNLOAD_URL" & - echo -e "\n${idsCL[LightCyan]}Downloading will continue in the background\n" +VIRTIO_STABLE_CHECKED=0 +VIRTIO_STABLE_STATUS='unknown' +VIRTIO_STABLE_FILE='' +VIRTIO_LAST_FILE='' + +TAPM_VIRTIO_FILENAME_FROM_URL() { + local url="${1%%\?*}" + local filename="${url##*/}" + + [[ "$filename" =~ ^virtio-win(-0\.1\.[0-9]+)?\.iso$ ]] || return 1 + printf '%s\n' "$filename" +} + +TAPM_REFRESH_VIRTIO_LOCAL_STATUS() { + [[ -n "$VIRTIO_STABLE_FILE" ]] || return + + if [[ -f "${DLDIR}/${VIRTIO_STABLE_FILE}" ]]; then + VIRTIO_STABLE_STATUS='current' + elif compgen -G "${DLDIR}/virtio-win*.iso" >/dev/null; then + VIRTIO_STABLE_STATUS='update' + else + VIRTIO_STABLE_STATUS='available' + fi +} + +TAPM_CHECK_VIRTIO_STABLE() { + local force="${1:-0}" + local effective_url + + if (( VIRTIO_STABLE_CHECKED == 1 && force == 0 )); then + return + fi + + VIRTIO_STABLE_CHECKED=1 + VIRTIO_STABLE_STATUS='unavailable' + VIRTIO_STABLE_FILE='' + + effective_url="$( + curl --fail --location --silent --show-error --head \ + --proto '=https' --proto-redir '=https' \ + --connect-timeout 5 --max-time 15 \ + --output /dev/null --write-out '%{url_effective}' \ + "$VIRTIO_STABLE_URL" 2>/dev/null + )" || return 1 + VIRTIO_STABLE_FILE="$(TAPM_VIRTIO_FILENAME_FROM_URL "$effective_url")" || + return 1 + TAPM_REFRESH_VIRTIO_LOCAL_STATUS +} + +TAPM_VALID_VIRTIO_ISO() { + local iso_file="$1" + local iso_signature + local size + + size="$(stat -c '%s' "$iso_file" 2>/dev/null)" || return 1 + (( size >= 10 * 1024 * 1024 )) || return 1 + iso_signature="$( + dd if="$iso_file" bs=1 skip=32769 count=5 status=none 2>/dev/null + )" + [[ "$iso_signature" == 'CD001' ]] +} + +TAPM_DOWNLOAD_VIRTIO_ISO() { + local url="$1" + local expected_filename="${2:-}" + local description="${3:-VirtIO driver ISO}" + local effective_url + local filename + local final_file + local partial_file + local size + + VIRTIO_LAST_FILE='' + if ! TAPM_VALID_HTTPS_URL "$url" || + [[ "$url" != https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/* ]]; then + echo -e "${idsCL[LightRed]}The VirtIO download URL is not an approved Fedora VirtIO-Win URL.${idsCL[Default]}" + return 1 + fi + + if [[ ! -d "$DLDIR" ]] && ! install -d -m 0755 "$DLDIR"; then + echo -e "${idsCL[LightRed]}Could not create the ISO destination: ${DLDIR}.${idsCL[Default]}" + return 1 + fi + if [[ ! -w "$DLDIR" ]]; then + echo -e "${idsCL[LightRed]}The ISO destination is not writable: ${DLDIR}.${idsCL[Default]}" + return 1 + fi + + partial_file="$(mktemp "${DLDIR}/.tapm-virtio.part.XXXXXX")" || { + echo -e "${idsCL[LightRed]}Could not create a temporary file on ${DLDIR}.${idsCL[Default]}" + return 1 + } + chmod 0600 "$partial_file" + + echo -e "\n${idsCL[LightCyan]}Downloading ${description}...${idsCL[Default]}\n" + effective_url="$( + curl --fail --location --show-error --progress-bar \ + --proto '=https' --proto-redir '=https' \ + --connect-timeout 10 \ + --output "$partial_file" --write-out '%{url_effective}' \ + "$url" + )" || { + rm -f -- "$partial_file" + echo -e "\n${idsCL[LightRed]}The VirtIO ISO download failed. Existing ISOs were not changed.${idsCL[Default]}" + return 1 + } + + filename="$(TAPM_VIRTIO_FILENAME_FROM_URL "$effective_url")" || { + rm -f -- "$partial_file" + echo -e "${idsCL[LightRed]}The VirtIO source returned an unexpected filename.${idsCL[Default]}" + return 1 + } + if [[ -n "$expected_filename" && "$filename" != "$expected_filename" ]]; then + rm -f -- "$partial_file" + echo -e "${idsCL[LightRed]}The VirtIO archive returned ${filename}; expected ${expected_filename}.${idsCL[Default]}" + return 1 + fi + + if ! TAPM_VALID_VIRTIO_ISO "$partial_file"; then + rm -f -- "$partial_file" + echo -e "${idsCL[LightRed]}The downloaded file did not pass ISO validation and was removed.${idsCL[Default]}" + return 1 + fi + + final_file="${DLDIR}/${filename}" + if ! chmod 0644 "$partial_file" || + ! mv -f -- "$partial_file" "$final_file"; then + rm -f -- "$partial_file" + echo -e "${idsCL[LightRed]}Could not install the validated VirtIO ISO in ${DLDIR}.${idsCL[Default]}" + return 1 + fi + + size="$(stat -c '%s' "$final_file")" + VIRTIO_LAST_FILE="$filename" + echo -e "\n${idsCL[Green]}VirtIO ISO downloaded and validated.${idsCL[Default]}" + printf ' File: %s\n' "$filename" + printf ' Size: %s\n' "$(numfmt --to=iec-i --suffix=B "$size")" + printf ' Destination: %s\n' "$DLDIR" +} + +DOWNLOAD_VIRTIO_STABLE() { + if ! TAPM_DOWNLOAD_VIRTIO_ISO "$VIRTIO_STABLE_URL" '' \ + 'current stable VirtIO drivers'; then + FINISH_FAILED_ACTION + return + fi + + VIRTIO_STABLE_FILE="$VIRTIO_LAST_FILE" + VIRTIO_STABLE_STATUS='current' FINISH_ACTION } +DOWNLOAD_VIRTIO_ARCHIVE() { + local release="$1" + local description="$2" + local iso_version="${release%-*}" + local filename="virtio-win-${iso_version}.iso" + local url="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/archive-virtio/virtio-win-${release}/${filename}" + + if ! TAPM_DOWNLOAD_VIRTIO_ISO "$url" "$filename" "$description"; then + FINISH_FAILED_ACTION + return + fi + FINISH_ACTION +} + +DOWNLOAD_CUSTOM_VIRTIO_ARCHIVE() { + local release + + echo + read -r -p "Archived VirtIO release (example: 0.1.229-1; blank cancels): " release + [[ -n "$release" ]] || return + if [[ ! "$release" =~ ^0\.1\.[0-9]+-[0-9]+$ ]]; then + echo -e "${idsCL[LightRed]}Use a release in the form 0.1.229-1.${idsCL[Default]}" + ENTER2CONTINUE + return + fi + + DOWNLOAD_VIRTIO_ARCHIVE "$release" "VirtIO archive ${release}" +} + +SHOW_LOCAL_VIRTIO_ISOS() { + local file + local found=0 + + MENU_HEADER + echo + echo -e " ${idsCL[LightCyan]}Downloaded VirtIO ISOs${idsCL[Default]}" + echo + echo " Destination: ${DLDIR}" + echo + while IFS= read -r -d '' file; do + found=1 + printf ' %-34s %10s %s\n' \ + "${file##*/}" \ + "$(numfmt --to=iec-i --suffix=B "$(stat -c '%s' "$file")")" \ + "$(date --date="@$(stat -c '%Y' "$file")" '+%F %R')" + done < <( + find "$DLDIR" -maxdepth 1 -type f -name 'virtio-win*.iso' \ + -print0 2>/dev/null | sort -z + ) + (( found == 1 )) || echo " No VirtIO ISOs are currently downloaded." + echo + read -r -p " Press ENTER to return..." _ +} + +VIRTIO_MENU() { + local stable_label + local -a labels + local -a values=( + "stable" + "server2016" + "server2012" + "server2008r2" + "server2008" + "archive" + "local" + "refresh" + ) + + echo -en "\n${idsCL[LightCyan]}Checking the current stable VirtIO release...${idsCL[Default]} " + TAPM_CHECK_VIRTIO_STABLE 1 || true + echo + + while true; do + TAPM_REFRESH_VIRTIO_LOCAL_STATUS + case "$VIRTIO_STABLE_STATUS" in + current) + stable_label="Current stable drivers (${VIRTIO_STABLE_FILE} downloaded)" + ;; + update) + stable_label="Current stable drivers (${VIRTIO_STABLE_FILE} update available)" + ;; + available) + stable_label="Current stable drivers (${VIRTIO_STABLE_FILE})" + ;; + *) + stable_label="Current stable drivers (version check unavailable)" + ;; + esac + + labels=( + "$stable_label" + "Windows Server 2016 compatibility ISO (0.1.240)" + "Windows Server 2012/R2 compatibility ISO (0.1.189)" + "Windows Server 2008 R2 compatibility ISO (0.1.172)" + "Windows Server 2008 compatibility ISO (0.1.141)" + "Download another archived VirtIO release" + "View downloaded VirtIO ISOs" + "Refresh stable-version check" + ) + [[ -f "${DLDIR}/virtio-win-0.1.240.iso" ]] && + labels[1]+=" (downloaded)" + [[ -f "${DLDIR}/virtio-win-0.1.189.iso" ]] && + labels[2]+=" (downloaded)" + [[ -f "${DLDIR}/virtio-win-0.1.172.iso" ]] && + labels[3]+=" (downloaded)" + [[ -f "${DLDIR}/virtio-win-0.1.141.iso" ]] && + labels[4]+=" (downloaded)" + + SELECT_MENU "VirtIO Driver Downloads" labels values + case "$MENU_SELECTION" in + stable) DOWNLOAD_VIRTIO_STABLE;; + server2016) + DOWNLOAD_VIRTIO_ARCHIVE 0.1.240-1 \ + "Windows Server 2016 compatibility drivers" + ;; + server2012) + DOWNLOAD_VIRTIO_ARCHIVE 0.1.189-1 \ + "Windows Server 2012/R2 compatibility drivers" + ;; + server2008r2) + DOWNLOAD_VIRTIO_ARCHIVE 0.1.172-1 \ + "Windows Server 2008 R2 compatibility drivers" + ;; + server2008) + DOWNLOAD_VIRTIO_ARCHIVE 0.1.141-1 \ + "Windows Server 2008 compatibility drivers" + ;; + archive) DOWNLOAD_CUSTOM_VIRTIO_ARCHIVE;; + local) SHOW_LOCAL_VIRTIO_ISOS;; + refresh) + echo -en "\n${idsCL[LightCyan]}Refreshing the stable VirtIO release...${idsCL[Default]} " + TAPM_CHECK_VIRTIO_STABLE 1 || true + echo + ;; + back) return;; + quit) EXIT1; exit 0;; + esac + done +} + TAPM_INSTALL_PROXCLMC() { local key_url='https://git.gyptazy.com/api/packages/gyptazy/debian/repository.key' local keyring='/etc/apt/keyrings/gyptazy.asc' @@ -1457,12 +1736,10 @@ HOST_SETUP_MENU() { labels+=("Detect CPU model for live migrations") values+=("cpu") - if [ -f "${DLDIR}/${VIRTIO_FILE}" ]; then - labels+=("VirtIO drivers (${VIRTIO_FILE} already downloaded)") - elif compgen -G "${DLDIR}/virtio*.iso" >/dev/null; then - labels+=("Download updated VirtIO drivers") + if compgen -G "${DLDIR}/virtio-win*.iso" >/dev/null; then + labels+=("VirtIO driver downloads (local ISOs available)") else - labels+=("Download current VirtIO drivers") + labels+=("VirtIO driver downloads") fi values+=("virtio") @@ -1482,7 +1759,7 @@ HOST_SETUP_MENU() { case "$MENU_SELECTION" in post_install) PROXMENUX_POST_INSTALL;; cpu) DETECT_CPU;; - virtio) DOWNLOAD_VIRTIO;; + virtio) VIRTIO_MENU;; glances) INSTALL_GLANCES;; omsa) INSTALL_OMSA;; back) return;; @@ -1826,7 +2103,7 @@ if (( ACTION_REQUESTED == 1 )); then acronis) INSTALL_ACRONIS;; post-install|post_install) PROXMENUX_POST_INSTALL;; proxmenux) [ ! -f /usr/local/bin/menu ] && INSTALL_PROXMENUX || /usr/local/bin/menu;; - virtio) DOWNLOAD_VIRTIO;; + virtio) VIRTIO_MENU;; sentinelone|s1) INSTALL_S1;; screenconnect) INSTALL_SCREENCONNECT;; restart) RESTART_PVE_SERVICES "${2:-}";; From 6bed7068c70bf76ebec1adc61d1d9dd3efb23854 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 18:45:18 -0500 Subject: [PATCH 27/76] update --- README.md | 7 + defaults.inc | 10 +- inc/deploy-iso-nfs-lxc.sh | 263 ++++++++++++++++++++++++++++++++++++++ proxmenu-scripts.sh | 186 ++++++++++++++++++++++----- 4 files changed, 428 insertions(+), 38 deletions(-) create mode 100644 inc/deploy-iso-nfs-lxc.sh diff --git a/README.md b/README.md index 5b51fc8..a2de0eb 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ restart Restart core local Proxmox management services cpu Detect and optionally apply a migration-safe CPU model maintenance Toggle local HA maintenance mode keepalived Deploy Keepalived across the cluster +iso-nfs Create an LXC NFS server and cluster-wide shared ISO storage ``` ## Companion files @@ -71,6 +72,12 @@ portal without changing ProxMenu. The Keepalived deployment additionally requires a healthy, quorate Proxmox cluster and passwordless root SSH between cluster nodes. +The shared ISO storage wizard creates a privileged Debian LXC with a dedicated +secondary volume, restricts its NFS export to a supplied client CIDR, and adds +the export to Proxmox's cluster-wide storage configuration. The container host +and network path to the container must remain available for nodes to use the +ISO repository. + The legacy Dell OMSA installer is limited to supported PowerEdge x30/x40 systems running Proxmox VE 9 on Debian 13 (Trixie), amd64. diff --git a/defaults.inc b/defaults.inc index 921b6d0..8d60755 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-35' +VERS='2026.7.25-37' noupdate=' ' @@ -20,14 +20,6 @@ TAPM_BROKER_URL='https://tapm.scity.us' S1_BROKER_PACKAGE='sentinelone-linux' S1_PACKAGE='tapm-sentinelone.deb' -if [[ -d /mnt/pve/PVE-Shared-Storage/template/iso ]]; then - DLDIR='/mnt/pve/PVE-Shared-Storage/template/iso' -else - DLDIR='/var/lib/vz/template/iso' -fi - - - # if [ -f /etc/apt/sources.list.d/gyptazy.list ]; then # rm -f /etc/apt/sources.list.d/gyptazy.list /etc/apt/keyrings/gyptazy.asc # echo "deb https://repo.gyptazy.com/stable /" > /etc/apt/sources.list.d/proxlb.list diff --git a/inc/deploy-iso-nfs-lxc.sh b/inc/deploy-iso-nfs-lxc.sh new file mode 100644 index 0000000..a36132f --- /dev/null +++ b/inc/deploy-iso-nfs-lxc.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash +# Deploy a dedicated LXC NFS server and register it as cluster ISO storage. + +TAPM_ISO_NFS_VALID_ID() { + [[ "${1:-}" =~ ^[A-Za-z][A-Za-z0-9_-]{0,31}$ ]] +} + +TAPM_ISO_NFS_VALID_CTID() { + [[ "${1:-}" =~ ^[1-9][0-9]{2,8}$ ]] +} + +TAPM_ISO_NFS_VALID_IPV4_CIDR() { + local value="${1:-}" + local address prefix octet + local -a octets + + [[ "$value" == */* ]] || return 1 + address="${value%/*}" + prefix="${value#*/}" + [[ "$prefix" =~ ^[0-9]+$ ]] && (( prefix <= 32 )) || return 1 + IFS=. read -r -a octets <<<"$address" + (( ${#octets[@]} == 4 )) || return 1 + for octet in "${octets[@]}"; do + [[ "$octet" =~ ^[0-9]+$ ]] && (( 10#$octet <= 255 )) || return 1 + done +} + +TAPM_ISO_NFS_PROMPT() { + local variable="$1" + local label="$2" + local default_value="${3:-}" + local value + + if [[ -n "$default_value" ]]; then + read -r -p " ${label} [${default_value}]: " value + printf -v "$variable" '%s' "${value:-$default_value}" + else + read -r -p " ${label}: " value + printf -v "$variable" '%s' "$value" + fi +} + +TAPM_ISO_NFS_FAIL() { + echo -e "\n${idsCL[LightRed]}$1${idsCL[Default]}" + return 1 +} + +TAPM_ISO_NFS_SELECT_STORAGE() { + local variable="$1" + local label="$2" + local default_value="$3" + local value + + echo + echo " Active storages that support LXC volumes:" + pvesm status --content rootdir 2>/dev/null | + awk 'NR == 1 || $3 == "active" { printf " %s\n", $1 }' + TAPM_ISO_NFS_PROMPT value "$label" "$default_value" + if ! pvesm status --content rootdir 2>/dev/null | + awk 'NR > 1 && $3 == "active" { print $1 }' | + grep -Fxq -- "$value"; then + TAPM_ISO_NFS_FAIL "Storage '${value}' is not active here or does not support LXC volumes." + return 1 + fi + printf -v "$variable" '%s' "$value" +} + +TAPM_DEPLOY_ISO_NFS_LXC() { + local ctid default_ctid hostname address_cidr server_ip gateway bridge + local client_cidr root_storage data_storage root_size data_size + local pve_storage_id template_storage template_name template_path + local default_root_storage default_template_storage choice test_file + local container_config + + echo + echo -e "${idsCL[LightCyan]}Shared ISO storage using an LXC NFS server${idsCL[Default]}" + echo + echo " This creates a privileged Debian LXC with an unconfined AppArmor profile," + echo " allocates a dedicated mp0 data volume, exports it to the cluster, and" + echo " registers it in storage.cfg. This reduced isolation is required for the" + echo " kernel NFS service; do not run unrelated or untrusted software in this LXC." + echo " The container's host must be online for ISO storage to remain available." + echo + + [[ $EUID -eq 0 ]] || + { TAPM_ISO_NFS_FAIL "Run this action as root on a Proxmox VE host."; return 1; } + for command in pct pvesm pveam pvesh; do + command -v "$command" >/dev/null 2>&1 || + { TAPM_ISO_NFS_FAIL "Required Proxmox command '${command}' was not found."; return 1; } + done + + default_ctid="$(pvesh get /cluster/nextid 2>/dev/null || true)" + TAPM_ISO_NFS_PROMPT ctid "Container ID" "$default_ctid" + TAPM_ISO_NFS_VALID_CTID "$ctid" || + { TAPM_ISO_NFS_FAIL "The container ID is invalid."; return 1; } + if pct status "$ctid" >/dev/null 2>&1; then + TAPM_ISO_NFS_FAIL "Container ${ctid} already exists; no changes were made." + return 1 + fi + + TAPM_ISO_NFS_PROMPT hostname "Container hostname" "iso-nfs" + [[ "$hostname" =~ ^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$ ]] || + { TAPM_ISO_NFS_FAIL "The hostname is invalid."; return 1; } + TAPM_ISO_NFS_PROMPT address_cidr "Static IPv4 address with prefix (example: 10.20.30.10/24)" + TAPM_ISO_NFS_VALID_IPV4_CIDR "$address_cidr" || + { TAPM_ISO_NFS_FAIL "A valid static IPv4 address and prefix are required."; return 1; } + server_ip="${address_cidr%/*}" + TAPM_ISO_NFS_PROMPT gateway "IPv4 gateway" + TAPM_ISO_NFS_VALID_IPV4_CIDR "${gateway}/32" || + { TAPM_ISO_NFS_FAIL "A valid IPv4 gateway is required."; return 1; } + TAPM_ISO_NFS_PROMPT bridge "Proxmox bridge" "vmbr0" + ip link show "$bridge" >/dev/null 2>&1 || + { TAPM_ISO_NFS_FAIL "Bridge '${bridge}' does not exist on this host."; return 1; } + client_cidr="$( + python3 -c 'import ipaddress,sys; print(ipaddress.ip_interface(sys.argv[1]).network)' \ + "$address_cidr" 2>/dev/null + )" || client_cidr='' + TAPM_ISO_NFS_PROMPT client_cidr "CIDR allowed to mount the export" "$client_cidr" + TAPM_ISO_NFS_VALID_IPV4_CIDR "$client_cidr" || + { TAPM_ISO_NFS_FAIL "The allowed client CIDR is invalid."; return 1; } + + default_root_storage="$( + pvesm status --content rootdir 2>/dev/null | + awk 'NR > 1 && $3 == "active" { print $1; exit }' + )" + [[ -n "$default_root_storage" ]] || + { TAPM_ISO_NFS_FAIL "No active storage supports LXC volumes."; return 1; } + TAPM_ISO_NFS_SELECT_STORAGE root_storage "Root filesystem storage" "$default_root_storage" || return 1 + TAPM_ISO_NFS_SELECT_STORAGE data_storage "Dedicated ISO volume storage" "$root_storage" || return 1 + TAPM_ISO_NFS_PROMPT root_size "Root filesystem size in GiB" "8" + [[ "$root_size" =~ ^[1-9][0-9]*$ ]] || + { TAPM_ISO_NFS_FAIL "The root filesystem size must be a positive integer."; return 1; } + TAPM_ISO_NFS_PROMPT data_size "ISO volume size in GiB" "250" + [[ "$data_size" =~ ^[1-9][0-9]*$ ]] || + { TAPM_ISO_NFS_FAIL "The ISO volume size must be a positive integer."; return 1; } + TAPM_ISO_NFS_PROMPT pve_storage_id "Proxmox cluster storage ID" "PVE-Shared-Storage" + TAPM_ISO_NFS_VALID_ID "$pve_storage_id" || + { TAPM_ISO_NFS_FAIL "The Proxmox storage ID is invalid."; return 1; } + if pvesm status 2>/dev/null | awk 'NR > 1 { print $1 }' | grep -Fxq -- "$pve_storage_id"; then + TAPM_ISO_NFS_FAIL "Storage ID '${pve_storage_id}' already exists; no changes were made." + return 1 + fi + + default_template_storage="$( + pvesm status --content vztmpl 2>/dev/null | + awk 'NR > 1 && $3 == "active" { print $1; exit }' + )" + [[ -n "$default_template_storage" ]] || + { TAPM_ISO_NFS_FAIL "No active storage supports container templates."; return 1; } + template_storage="$default_template_storage" + + echo + echo " Deployment summary" + echo " LXC: ${ctid} (${hostname}), privileged" + echo " Network: ${address_cidr} via ${gateway} on ${bridge}" + echo " Root volume: ${root_storage}:${root_size} GiB" + echo " ISO volume mp0: ${data_storage}:${data_size} GiB -> /srv/iso" + echo " NFS clients: ${client_cidr}" + echo " Cluster storage: ${pve_storage_id}" + echo + read -r -p " Create this container and storage (type YES to continue)? " choice + [[ "$choice" == "YES" ]] || { + echo " Cancelled; no changes were made." + return 0 + } + + echo -e "\n${idsCL[LightCyan]}Locating a Debian container template...${idsCL[Default]}" + pveam update || { TAPM_ISO_NFS_FAIL "Could not refresh the template catalog."; return 1; } + template_name="$( + pveam available --section system | + awk '$2 ~ /^debian-(13|12)-standard_/ { print $2 }' | + sort -V | + tail -1 + )" + [[ -n "$template_name" ]] || + { TAPM_ISO_NFS_FAIL "No supported Debian 12/13 standard template was found."; return 1; } + template_path="${template_storage}:vztmpl/${template_name}" + if ! pveam list "$template_storage" 2>/dev/null | + awk 'NR > 1 { print $1 }' | + grep -Fxq -- "$template_path"; then + pveam download "$template_storage" "$template_name" || + { TAPM_ISO_NFS_FAIL "The Debian template download failed."; return 1; } + fi + + echo -e "\n${idsCL[LightCyan]}Creating LXC ${ctid}...${idsCL[Default]}" + if ! pct create "$ctid" "$template_path" \ + --hostname "$hostname" \ + --ostype debian \ + --unprivileged 0 \ + --features nesting=1 \ + --cores 2 \ + --memory 1024 \ + --swap 512 \ + --rootfs "${root_storage}:${root_size}" \ + --mp0 "${data_storage}:${data_size},mp=/srv/iso,backup=1" \ + --net0 "name=eth0,bridge=${bridge},ip=${address_cidr},gw=${gateway},type=veth" \ + --onboot 1 \ + --startup order=1; then + TAPM_ISO_NFS_FAIL "Container creation failed." + return 1 + fi + container_config="/etc/pve/lxc/${ctid}.conf" + if ! grep -q '^lxc\.apparmor\.profile:' "$container_config"; then + printf 'lxc.apparmor.profile: unconfined\n' >>"$container_config" || + { TAPM_ISO_NFS_FAIL "Container ${ctid} was created, but its NFS AppArmor setting could not be applied."; return 1; } + fi + + pct start "$ctid" || + { TAPM_ISO_NFS_FAIL "Container ${ctid} was created but could not be started."; return 1; } + if ! timeout 60 bash -c \ + "until pct exec '$ctid' -- test -d /run/systemd/system >/dev/null 2>&1; do sleep 2; done"; then + TAPM_ISO_NFS_FAIL "Container ${ctid} did not become ready within 60 seconds." + return 1 + fi + + echo -e "\n${idsCL[LightCyan]}Installing and configuring NFS...${idsCL[Default]}" + pct exec "$ctid" -- apt-get update || + { TAPM_ISO_NFS_FAIL "Package index refresh failed inside container ${ctid}."; return 1; } + pct exec "$ctid" -- env DEBIAN_FRONTEND=noninteractive \ + apt-get install -y nfs-kernel-server || + { TAPM_ISO_NFS_FAIL "NFS package installation failed inside container ${ctid}."; return 1; } + pct exec "$ctid" -- install -d -m 0775 /srv/iso/template/iso || + { TAPM_ISO_NFS_FAIL "Could not initialize the ISO directory."; return 1; } + printf '/srv/iso %s(rw,sync,no_subtree_check,no_root_squash)\n' "$client_cidr" | + pct exec "$ctid" -- tee /etc/exports.d/proxmox-isos.exports >/dev/null || + { TAPM_ISO_NFS_FAIL "Could not write the NFS export configuration."; return 1; } + pct exec "$ctid" -- exportfs -ra || + { TAPM_ISO_NFS_FAIL "The NFS export configuration was rejected."; return 1; } + pct exec "$ctid" -- systemctl enable --now nfs-server || + { TAPM_ISO_NFS_FAIL "The NFS server could not be started."; return 1; } + pct exec "$ctid" -- exportfs -v | grep -Fq "/srv/iso" || + { TAPM_ISO_NFS_FAIL "The expected NFS export is not active."; return 1; } + + echo -e "\n${idsCL[LightCyan]}Registering cluster storage...${idsCL[Default]}" + if ! pvesm add nfs "$pve_storage_id" \ + --server "$server_ip" \ + --export /srv/iso \ + --content iso \ + --options vers=3; then + TAPM_ISO_NFS_FAIL "The LXC is running, but Proxmox could not add the NFS storage." + return 1 + fi + if ! timeout 30 pvesm status --storage "$pve_storage_id" | + awk -v id="$pve_storage_id" 'NR > 1 && $1 == id && $3 == "active" { found=1 } END { exit !found }'; then + pvesm remove "$pve_storage_id" >/dev/null 2>&1 || true + TAPM_ISO_NFS_FAIL "The NFS storage did not become active; its cluster entry was removed." + return 1 + fi + + test_file="/mnt/pve/${pve_storage_id}/template/iso/.tapm-write-test" + if ! touch "$test_file" || ! rm -f -- "$test_file"; then + pvesm remove "$pve_storage_id" >/dev/null 2>&1 || true + TAPM_ISO_NFS_FAIL "The NFS mount was not writable; its cluster entry was removed." + return 1 + fi + + echo + echo -e "${idsCL[Green]}Shared ISO storage '${pve_storage_id}' is active.${idsCL[Default]}" + echo " LXC ${ctid} serves ${data_storage}:${data_size} GiB from ${server_ip}:/srv/iso." + echo " Because Proxmox storage configuration is cluster-wide, every cluster node" + echo " can use it when that node can reach ${server_ip} and is allowed by ${client_cidr}." + return 0 +} diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 27f7aa5..db1ad20 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -6,6 +6,7 @@ source /opt/idssys/defaults/default.inc source /opt/idssys/ta-proxmenu/defaults.inc source /opt/idssys/ta-proxmenu/inc/git-update.inc +source /opt/idssys/ta-proxmenu/inc/deploy-iso-nfs-lxc.sh ACTION_REQUESTED=0 [[ -n "${action:-}" ]] && ACTION_REQUESTED=1 @@ -174,8 +175,9 @@ TAPM_AUTHORIZE() { host_fingerprint="$(sha256sum /etc/machine-id | cut -d' ' -f1)" exchange_response="$( TAPM_CODE="$deploycode" TAPM_FINGERPRINT="$host_fingerprint" TAPM_HOSTNAME="$(hostname)" \ + TAPM_LAN_IP="${RNIP:-}" \ TAPM_REQUESTED_ACTION="$required_action" TAPM_REQUESTED_PACKAGE="$required_package" \ - python3 -c 'import json, os, sys; json.dump({"code": os.environ["TAPM_CODE"], "host_fingerprint": os.environ["TAPM_FINGERPRINT"], "hostname": os.environ["TAPM_HOSTNAME"], "requested_action": os.environ["TAPM_REQUESTED_ACTION"], "requested_package": os.environ["TAPM_REQUESTED_PACKAGE"]}, sys.stdout)' | + python3 -c 'import json, os, sys; json.dump({"code": os.environ["TAPM_CODE"], "host_fingerprint": os.environ["TAPM_FINGERPRINT"], "hostname": os.environ["TAPM_HOSTNAME"], "lan_ip": os.environ["TAPM_LAN_IP"], "requested_action": os.environ["TAPM_REQUESTED_ACTION"], "requested_package": os.environ["TAPM_REQUESTED_PACKAGE"]}, sys.stdout)' | curl --fail --silent --show-error \ --header 'Content-Type: application/json' \ --data-binary @- "${TAPM_BROKER_URL}/api/v1/exchange" @@ -783,6 +785,11 @@ VIRTIO_STABLE_CHECKED=0 VIRTIO_STABLE_STATUS='unknown' VIRTIO_STABLE_FILE='' VIRTIO_LAST_FILE='' +DLDIR='' +VIRTIO_SELECTED_STORAGE='' +declare -a VIRTIO_STORAGE_IDS=() +declare -a VIRTIO_STORAGE_TYPES=() +declare -a VIRTIO_STORAGE_DIRS=() TAPM_VIRTIO_FILENAME_FROM_URL() { local url="${1%%\?*}" @@ -792,12 +799,103 @@ TAPM_VIRTIO_FILENAME_FROM_URL() { printf '%s\n' "$filename" } +TAPM_VIRTIO_LABELED_FILENAME() { + local source_filename="$1" + local label="$2" + local version='' + + [[ "$label" =~ ^[a-z0-9-]+$ ]] || return 1 + if [[ "$source_filename" =~ ^virtio-win-(0\.1\.[0-9]+)\.iso$ ]]; then + version="${BASH_REMATCH[1]}" + elif [[ "$source_filename" != 'virtio-win.iso' ]]; then + return 1 + fi + + printf 'virtio-win-%s%s.iso\n' "$label" "${version:+-${version}}" +} + +TAPM_REFRESH_ISO_STORAGES() { + local storage + local type + local status + local probe_path + + VIRTIO_STORAGE_IDS=() + VIRTIO_STORAGE_TYPES=() + VIRTIO_STORAGE_DIRS=() + command -v pvesm >/dev/null 2>&1 || return 1 + + while read -r storage type status _; do + [[ "$storage" != 'Name' && "$status" == 'active' ]] || continue + probe_path="$( + pvesm path "${storage}:iso/tapm-path-probe.iso" 2>/dev/null + )" || continue + [[ "$probe_path" == /*/* ]] || continue + + VIRTIO_STORAGE_IDS+=("$storage") + VIRTIO_STORAGE_TYPES+=("$type") + VIRTIO_STORAGE_DIRS+=("${probe_path%/*}") + done < <(pvesm status --content iso --enabled 1 2>/dev/null) + + (( ${#VIRTIO_STORAGE_IDS[@]} > 0 )) +} + +TAPM_SELECT_ISO_STORAGE() { + local index + local -a labels=() + local -a values=() + + if ! TAPM_REFRESH_ISO_STORAGES; then + echo -e "${idsCL[LightRed]}No active, enabled Proxmox storage supporting ISO images was found.${idsCL[Default]}" + ENTER2CONTINUE + return 1 + fi + + if (( ${#VIRTIO_STORAGE_IDS[@]} == 1 )); then + index=0 + else + for index in "${!VIRTIO_STORAGE_IDS[@]}"; do + labels+=("${VIRTIO_STORAGE_IDS[$index]} (${VIRTIO_STORAGE_TYPES[$index]}) — ${VIRTIO_STORAGE_DIRS[$index]}") + values+=("storage:${index}") + done + SELECT_MENU "Select ISO Storage" labels values + case "$MENU_SELECTION" in + storage:*) index="${MENU_SELECTION#storage:}";; + quit) EXIT1; exit 0;; + *) return 1;; + esac + fi + + VIRTIO_SELECTED_STORAGE="${VIRTIO_STORAGE_IDS[$index]}" + DLDIR="${VIRTIO_STORAGE_DIRS[$index]}" + echo -e "\n${idsCL[LightCyan]}ISO storage: ${VIRTIO_SELECTED_STORAGE} (${DLDIR})${idsCL[Default]}" +} + +TAPM_VIRTIO_FILE_EXISTS() { + local filename="$1" + local directory + + for directory in "${VIRTIO_STORAGE_DIRS[@]}"; do + [[ -f "${directory}/${filename}" ]] && return 0 + done + return 1 +} + +TAPM_ANY_LOCAL_VIRTIO_ISOS() { + local directory + + for directory in "${VIRTIO_STORAGE_DIRS[@]}"; do + compgen -G "${directory}/virtio-win*.iso" >/dev/null && return 0 + done + return 1 +} + TAPM_REFRESH_VIRTIO_LOCAL_STATUS() { [[ -n "$VIRTIO_STABLE_FILE" ]] || return - if [[ -f "${DLDIR}/${VIRTIO_STABLE_FILE}" ]]; then + if TAPM_VIRTIO_FILE_EXISTS "$VIRTIO_STABLE_FILE"; then VIRTIO_STABLE_STATUS='current' - elif compgen -G "${DLDIR}/virtio-win*.iso" >/dev/null; then + elif TAPM_ANY_LOCAL_VIRTIO_ISOS; then VIRTIO_STABLE_STATUS='update' else VIRTIO_STABLE_STATUS='available' @@ -807,6 +905,7 @@ TAPM_REFRESH_VIRTIO_LOCAL_STATUS() { TAPM_CHECK_VIRTIO_STABLE() { local force="${1:-0}" local effective_url + local source_filename if (( VIRTIO_STABLE_CHECKED == 1 && force == 0 )); then return @@ -823,7 +922,9 @@ TAPM_CHECK_VIRTIO_STABLE() { --output /dev/null --write-out '%{url_effective}' \ "$VIRTIO_STABLE_URL" 2>/dev/null )" || return 1 - VIRTIO_STABLE_FILE="$(TAPM_VIRTIO_FILENAME_FROM_URL "$effective_url")" || + source_filename="$(TAPM_VIRTIO_FILENAME_FROM_URL "$effective_url")" || + return 1 + VIRTIO_STABLE_FILE="$(TAPM_VIRTIO_LABELED_FILENAME "$source_filename" latest)" || return 1 TAPM_REFRESH_VIRTIO_LOCAL_STATUS } @@ -844,7 +945,8 @@ TAPM_VALID_VIRTIO_ISO() { TAPM_DOWNLOAD_VIRTIO_ISO() { local url="$1" local expected_filename="${2:-}" - local description="${3:-VirtIO driver ISO}" + local filename_label="$3" + local description="${4:-VirtIO driver ISO}" local effective_url local filename local final_file @@ -896,6 +998,11 @@ TAPM_DOWNLOAD_VIRTIO_ISO() { echo -e "${idsCL[LightRed]}The VirtIO archive returned ${filename}; expected ${expected_filename}.${idsCL[Default]}" return 1 fi + filename="$(TAPM_VIRTIO_LABELED_FILENAME "$filename" "$filename_label")" || { + rm -f -- "$partial_file" + echo -e "${idsCL[LightRed]}Could not create a safe local filename for the VirtIO ISO.${idsCL[Default]}" + return 1 + } if ! TAPM_VALID_VIRTIO_ISO "$partial_file"; then rm -f -- "$partial_file" @@ -916,11 +1023,13 @@ TAPM_DOWNLOAD_VIRTIO_ISO() { echo -e "\n${idsCL[Green]}VirtIO ISO downloaded and validated.${idsCL[Default]}" printf ' File: %s\n' "$filename" printf ' Size: %s\n' "$(numfmt --to=iec-i --suffix=B "$size")" + printf ' Storage: %s\n' "$VIRTIO_SELECTED_STORAGE" printf ' Destination: %s\n' "$DLDIR" } DOWNLOAD_VIRTIO_STABLE() { - if ! TAPM_DOWNLOAD_VIRTIO_ISO "$VIRTIO_STABLE_URL" '' \ + TAPM_SELECT_ISO_STORAGE || return + if ! TAPM_DOWNLOAD_VIRTIO_ISO "$VIRTIO_STABLE_URL" '' latest \ 'current stable VirtIO drivers'; then FINISH_FAILED_ACTION return @@ -934,11 +1043,13 @@ DOWNLOAD_VIRTIO_STABLE() { DOWNLOAD_VIRTIO_ARCHIVE() { local release="$1" local description="$2" + local filename_label="${3:-archive}" local iso_version="${release%-*}" local filename="virtio-win-${iso_version}.iso" local url="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/archive-virtio/virtio-win-${release}/${filename}" - if ! TAPM_DOWNLOAD_VIRTIO_ISO "$url" "$filename" "$description"; then + TAPM_SELECT_ISO_STORAGE || return + if ! TAPM_DOWNLOAD_VIRTIO_ISO "$url" "$filename" "$filename_label" "$description"; then FINISH_FAILED_ACTION return fi @@ -961,6 +1072,7 @@ DOWNLOAD_CUSTOM_VIRTIO_ARCHIVE() { } SHOW_LOCAL_VIRTIO_ISOS() { + local index local file local found=0 @@ -968,18 +1080,25 @@ SHOW_LOCAL_VIRTIO_ISOS() { echo echo -e " ${idsCL[LightCyan]}Downloaded VirtIO ISOs${idsCL[Default]}" echo - echo " Destination: ${DLDIR}" - echo - while IFS= read -r -d '' file; do - found=1 - printf ' %-34s %10s %s\n' \ - "${file##*/}" \ - "$(numfmt --to=iec-i --suffix=B "$(stat -c '%s' "$file")")" \ - "$(date --date="@$(stat -c '%Y' "$file")" '+%F %R')" - done < <( - find "$DLDIR" -maxdepth 1 -type f -name 'virtio-win*.iso' \ - -print0 2>/dev/null | sort -z - ) + if TAPM_REFRESH_ISO_STORAGES; then + for index in "${!VIRTIO_STORAGE_IDS[@]}"; do + echo -e " ${idsCL[LightCyan]}${VIRTIO_STORAGE_IDS[$index]}${idsCL[Default]} (${VIRTIO_STORAGE_TYPES[$index]})" + echo " ${VIRTIO_STORAGE_DIRS[$index]}" + while IFS= read -r -d '' file; do + found=1 + printf ' %-42s %10s %s\n' \ + "${file##*/}" \ + "$(numfmt --to=iec-i --suffix=B "$(stat -c '%s' "$file")")" \ + "$(date --date="@$(stat -c '%Y' "$file")" '+%F %R')" + done < <( + find "${VIRTIO_STORAGE_DIRS[$index]}" -maxdepth 1 -type f \ + -name 'virtio-win*.iso' -print0 2>/dev/null | sort -z + ) + echo + done + else + echo -e " ${idsCL[LightRed]}No active ISO-capable storage was found.${idsCL[Default]}" + fi (( found == 1 )) || echo " No VirtIO ISOs are currently downloaded." echo read -r -p " Press ENTER to return..." _ @@ -1000,10 +1119,12 @@ VIRTIO_MENU() { ) echo -en "\n${idsCL[LightCyan]}Checking the current stable VirtIO release...${idsCL[Default]} " + TAPM_REFRESH_ISO_STORAGES || true TAPM_CHECK_VIRTIO_STABLE 1 || true echo while true; do + TAPM_REFRESH_ISO_STORAGES || true TAPM_REFRESH_VIRTIO_LOCAL_STATUS case "$VIRTIO_STABLE_STATUS" in current) @@ -1030,13 +1151,13 @@ VIRTIO_MENU() { "View downloaded VirtIO ISOs" "Refresh stable-version check" ) - [[ -f "${DLDIR}/virtio-win-0.1.240.iso" ]] && + TAPM_VIRTIO_FILE_EXISTS 'virtio-win-server-2016-0.1.240.iso' && labels[1]+=" (downloaded)" - [[ -f "${DLDIR}/virtio-win-0.1.189.iso" ]] && + TAPM_VIRTIO_FILE_EXISTS 'virtio-win-server-2012r2-0.1.189.iso' && labels[2]+=" (downloaded)" - [[ -f "${DLDIR}/virtio-win-0.1.172.iso" ]] && + TAPM_VIRTIO_FILE_EXISTS 'virtio-win-server-2008r2-0.1.172.iso' && labels[3]+=" (downloaded)" - [[ -f "${DLDIR}/virtio-win-0.1.141.iso" ]] && + TAPM_VIRTIO_FILE_EXISTS 'virtio-win-server-2008-0.1.141.iso' && labels[4]+=" (downloaded)" SELECT_MENU "VirtIO Driver Downloads" labels values @@ -1044,19 +1165,19 @@ VIRTIO_MENU() { stable) DOWNLOAD_VIRTIO_STABLE;; server2016) DOWNLOAD_VIRTIO_ARCHIVE 0.1.240-1 \ - "Windows Server 2016 compatibility drivers" + "Windows Server 2016 compatibility drivers" server-2016 ;; server2012) DOWNLOAD_VIRTIO_ARCHIVE 0.1.189-1 \ - "Windows Server 2012/R2 compatibility drivers" + "Windows Server 2012/R2 compatibility drivers" server-2012r2 ;; server2008r2) DOWNLOAD_VIRTIO_ARCHIVE 0.1.172-1 \ - "Windows Server 2008 R2 compatibility drivers" + "Windows Server 2008 R2 compatibility drivers" server-2008r2 ;; server2008) DOWNLOAD_VIRTIO_ARCHIVE 0.1.141-1 \ - "Windows Server 2008 compatibility drivers" + "Windows Server 2008 compatibility drivers" server-2008 ;; archive) DOWNLOAD_CUSTOM_VIRTIO_ARCHIVE;; local) SHOW_LOCAL_VIRTIO_ISOS;; @@ -1736,7 +1857,8 @@ HOST_SETUP_MENU() { labels+=("Detect CPU model for live migrations") values+=("cpu") - if compgen -G "${DLDIR}/virtio-win*.iso" >/dev/null; then + TAPM_REFRESH_ISO_STORAGES >/dev/null 2>&1 || true + if TAPM_ANY_LOCAL_VIRTIO_ISOS; then labels+=("VirtIO driver downloads (local ISOs available)") else labels+=("VirtIO driver downloads") @@ -1810,7 +1932,7 @@ MONITORING_MENU() { CLUSTER_MENU() { local -a labels - local -a values=("maintenance" "services" "keepalived") + local -a values=("maintenance" "services" "keepalived" "iso_nfs") while true; do if ha-manager status | grep -F "$(hostname -s)" | grep -q "maintenance mode"; then @@ -1823,12 +1945,17 @@ CLUSTER_MENU() { dpkg-query -W -f='${Status}' keepalived 2>/dev/null | grep -q "install ok installed" && labels+=("Deploy/reconfigure Keepalived (installed locally)") || labels+=("Deploy Keepalived on all cluster hosts") + labels+=("Create shared ISO storage using an LXC NFS server") SELECT_MENU "Cluster & Maintenance" labels values case "$MENU_SELECTION" in maintenance) MAINTENANCE_MODE;; services) SERVICE_RECOVERY_MENU;; keepalived) INSTALL_KEEPALIVE;; + iso_nfs) + TAPM_DEPLOY_ISO_NFS_LXC + FINISH_ACTION + ;; back) return;; quit) EXIT1; exit 0;; esac @@ -2110,6 +2237,7 @@ if (( ACTION_REQUESTED == 1 )); then cpu) DETECT_CPU;; maintenance|mm) MAINTENANCE_MODE;; keepalived) INSTALL_KEEPALIVE;; + iso-nfs|iso_nfs) TAPM_DEPLOY_ISO_NFS_LXC;; *) MAIN_MENU;; esac else From 7a2f968670b5c2e62cdc8b854d183bd6014ed3e4 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 18:52:23 -0500 Subject: [PATCH 28/76] update --- defaults.inc | 2 +- inc/deploy-iso-nfs-lxc.sh | 49 ++++++++++++++++++++++++++++++--------- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/defaults.inc b/defaults.inc index 8d60755..fef0e04 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-37' +VERS='2026.7.25-38' noupdate=' ' diff --git a/inc/deploy-iso-nfs-lxc.sh b/inc/deploy-iso-nfs-lxc.sh index a36132f..ba7b967 100644 --- a/inc/deploy-iso-nfs-lxc.sh +++ b/inc/deploy-iso-nfs-lxc.sh @@ -49,20 +49,47 @@ TAPM_ISO_NFS_SELECT_STORAGE() { local variable="$1" local label="$2" local default_value="$3" - local value + local storage type status index + local default_found=0 + local -a storage_ids=() + local -a storage_types=() + local -a labels=() + local -a values=() - echo - echo " Active storages that support LXC volumes:" - pvesm status --content rootdir 2>/dev/null | - awk 'NR == 1 || $3 == "active" { printf " %s\n", $1 }' - TAPM_ISO_NFS_PROMPT value "$label" "$default_value" - if ! pvesm status --content rootdir 2>/dev/null | - awk 'NR > 1 && $3 == "active" { print $1 }' | - grep -Fxq -- "$value"; then - TAPM_ISO_NFS_FAIL "Storage '${value}' is not active here or does not support LXC volumes." + while read -r storage type status _; do + [[ "$storage" != 'Name' && "$status" == 'active' ]] || continue + storage_ids+=("$storage") + storage_types+=("$type") + [[ "$storage" == "$default_value" ]] && default_found=1 + done < <(pvesm status --content rootdir --enabled 1 2>/dev/null) + + if (( ${#storage_ids[@]} == 0 )); then + TAPM_ISO_NFS_FAIL "No active, enabled storage supports LXC volumes." return 1 fi - printf -v "$variable" '%s' "$value" + + # Put the suggested storage first so pressing ENTER retains the default. + if (( default_found == 1 )); then + for index in "${!storage_ids[@]}"; do + [[ "${storage_ids[$index]}" == "$default_value" ]] || continue + labels+=("${storage_ids[$index]} (${storage_types[$index]}) — default") + values+=("storage:${storage_ids[$index]}") + break + done + fi + for index in "${!storage_ids[@]}"; do + [[ $default_found == 1 && "${storage_ids[$index]}" == "$default_value" ]] && + continue + labels+=("${storage_ids[$index]} (${storage_types[$index]})") + values+=("storage:${storage_ids[$index]}") + done + + SELECT_MENU "$label" labels values + case "$MENU_SELECTION" in + storage:*) printf -v "$variable" '%s' "${MENU_SELECTION#storage:}";; + quit) EXIT1; exit 0;; + *) return 1;; + esac } TAPM_DEPLOY_ISO_NFS_LXC() { From e12f5137bd99471e1d15b8713b1aaf4caf3ca9a4 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 18:54:17 -0500 Subject: [PATCH 29/76] update --- defaults.inc | 2 +- inc/deploy-iso-nfs-lxc.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/defaults.inc b/defaults.inc index fef0e04..5dd160e 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-38' +VERS='2026.7.25-39' noupdate=' ' diff --git a/inc/deploy-iso-nfs-lxc.sh b/inc/deploy-iso-nfs-lxc.sh index ba7b967..f027b74 100644 --- a/inc/deploy-iso-nfs-lxc.sh +++ b/inc/deploy-iso-nfs-lxc.sh @@ -125,7 +125,7 @@ TAPM_DEPLOY_ISO_NFS_LXC() { return 1 fi - TAPM_ISO_NFS_PROMPT hostname "Container hostname" "iso-nfs" + TAPM_ISO_NFS_PROMPT hostname "Container hostname" "PVE-Shared-Storage" [[ "$hostname" =~ ^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$ ]] || { TAPM_ISO_NFS_FAIL "The hostname is invalid."; return 1; } TAPM_ISO_NFS_PROMPT address_cidr "Static IPv4 address with prefix (example: 10.20.30.10/24)" From 67e109d92320385cb415f942082a740e886ef4c0 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 19:04:05 -0500 Subject: [PATCH 30/76] update --- defaults.inc | 2 +- inc/deploy-iso-nfs-lxc.sh | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/defaults.inc b/defaults.inc index 5dd160e..c5d93f4 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-39' +VERS='2026.7.25-40' noupdate=' ' diff --git a/inc/deploy-iso-nfs-lxc.sh b/inc/deploy-iso-nfs-lxc.sh index f027b74..eff7097 100644 --- a/inc/deploy-iso-nfs-lxc.sh +++ b/inc/deploy-iso-nfs-lxc.sh @@ -248,6 +248,8 @@ TAPM_DEPLOY_ISO_NFS_LXC() { { TAPM_ISO_NFS_FAIL "NFS package installation failed inside container ${ctid}."; return 1; } pct exec "$ctid" -- install -d -m 0775 /srv/iso/template/iso || { TAPM_ISO_NFS_FAIL "Could not initialize the ISO directory."; return 1; } + pct exec "$ctid" -- install -d -m 0755 /etc/exports.d || + { TAPM_ISO_NFS_FAIL "Could not initialize the NFS exports directory."; return 1; } printf '/srv/iso %s(rw,sync,no_subtree_check,no_root_squash)\n' "$client_cidr" | pct exec "$ctid" -- tee /etc/exports.d/proxmox-isos.exports >/dev/null || { TAPM_ISO_NFS_FAIL "Could not write the NFS export configuration."; return 1; } From f2da5b3062c8de14c7492b957e9f0ab19abd21eb Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 19:17:09 -0500 Subject: [PATCH 31/76] update --- defaults.inc | 2 +- inc/deploy-iso-nfs-lxc.sh | 21 ++++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/defaults.inc b/defaults.inc index c5d93f4..f27ad12 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-40' +VERS='2026.7.25-44' noupdate=' ' diff --git a/inc/deploy-iso-nfs-lxc.sh b/inc/deploy-iso-nfs-lxc.sh index eff7097..71d914f 100644 --- a/inc/deploy-iso-nfs-lxc.sh +++ b/inc/deploy-iso-nfs-lxc.sh @@ -111,7 +111,7 @@ TAPM_DEPLOY_ISO_NFS_LXC() { [[ $EUID -eq 0 ]] || { TAPM_ISO_NFS_FAIL "Run this action as root on a Proxmox VE host."; return 1; } - for command in pct pvesm pveam pvesh; do + for command in pct pvesm pveam pvesh ha-manager; do command -v "$command" >/dev/null 2>&1 || { TAPM_ISO_NFS_FAIL "Required Proxmox command '${command}' was not found."; return 1; } done @@ -160,7 +160,7 @@ TAPM_DEPLOY_ISO_NFS_LXC() { TAPM_ISO_NFS_PROMPT data_size "ISO volume size in GiB" "250" [[ "$data_size" =~ ^[1-9][0-9]*$ ]] || { TAPM_ISO_NFS_FAIL "The ISO volume size must be a positive integer."; return 1; } - TAPM_ISO_NFS_PROMPT pve_storage_id "Proxmox cluster storage ID" "PVE-Shared-Storage" + TAPM_ISO_NFS_PROMPT pve_storage_id "Proxmox cluster storage ID" "$hostname" TAPM_ISO_NFS_VALID_ID "$pve_storage_id" || { TAPM_ISO_NFS_FAIL "The Proxmox storage ID is invalid."; return 1; } if pvesm status 2>/dev/null | awk 'NR > 1 { print $1 }' | grep -Fxq -- "$pve_storage_id"; then @@ -180,13 +180,15 @@ TAPM_DEPLOY_ISO_NFS_LXC() { echo " Deployment summary" echo " LXC: ${ctid} (${hostname}), privileged" echo " Network: ${address_cidr} via ${gateway} on ${bridge}" + echo " Resources: 2 vCPU, 2048 MiB RAM, 512 MiB swap, 100 CPU units" echo " Root volume: ${root_storage}:${root_size} GiB" echo " ISO volume mp0: ${data_storage}:${data_size} GiB -> /srv/iso" echo " NFS clients: ${client_cidr}" echo " Cluster storage: ${pve_storage_id}" + echo " HA resource: ct:${ctid} (started)" echo - read -r -p " Create this container and storage (type YES to continue)? " choice - [[ "$choice" == "YES" ]] || { + read -r -p " Create this container and storage (type yes to continue)? " choice + [[ "$choice" =~ ^[Yy][Ee][Ss]$ ]] || { echo " Cancelled; no changes were made." return 0 } @@ -216,7 +218,8 @@ TAPM_DEPLOY_ISO_NFS_LXC() { --unprivileged 0 \ --features nesting=1 \ --cores 2 \ - --memory 1024 \ + --cpunits 100 \ + --memory 2048 \ --swap 512 \ --rootfs "${root_storage}:${root_size}" \ --mp0 "${data_storage}:${data_size},mp=/srv/iso,backup=1" \ @@ -283,9 +286,17 @@ TAPM_DEPLOY_ISO_NFS_LXC() { return 1 fi + echo -e "\n${idsCL[LightCyan]}Adding LXC ${ctid} to Proxmox HA...${idsCL[Default]}" + if ! ha-manager add "ct:${ctid}" --state started; then + TAPM_ISO_NFS_FAIL \ + "Shared storage '${pve_storage_id}' is active, but LXC ${ctid} could not be added to HA." + return 1 + fi + echo echo -e "${idsCL[Green]}Shared ISO storage '${pve_storage_id}' is active.${idsCL[Default]}" echo " LXC ${ctid} serves ${data_storage}:${data_size} GiB from ${server_ip}:/srv/iso." + echo " HA now manages ct:${ctid} with requested state 'started'." echo " Because Proxmox storage configuration is cluster-wide, every cluster node" echo " can use it when that node can reach ${server_ip} and is allowed by ${client_cidr}." return 0 From 7102550f4e2fc77c556e3bb68d917a6194970c42 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 19:35:17 -0500 Subject: [PATCH 32/76] update --- README.md | 14 ++++++ defaults.inc | 2 +- inc/deploy-iso-nfs-lxc.sh | 6 ++- inc/deploy-proxmox-keepalived.sh | 1 + inc/virtio-helpers.inc | 25 ++++++++++ proxmenu-scripts.sh | 40 +++++---------- tests/run.sh | 83 ++++++++++++++++++++++++++++++++ tests/test-evacuation.sh | 70 +++++++++++++++++++++++++++ tests/test-git-update.sh | 79 ++++++++++++++++++++++++++++++ tests/test-iso-nfs.sh | 58 ++++++++++++++++++++++ tests/test-virtio.sh | 34 +++++++++++++ tests/testlib.sh | 48 ++++++++++++++++++ 12 files changed, 430 insertions(+), 30 deletions(-) create mode 100644 inc/virtio-helpers.inc create mode 100755 tests/run.sh create mode 100755 tests/test-evacuation.sh create mode 100755 tests/test-git-update.sh create mode 100755 tests/test-iso-nfs.sh create mode 100755 tests/test-virtio.sh create mode 100644 tests/testlib.sh diff --git a/README.md b/README.md index a2de0eb..2459ad8 100644 --- a/README.md +++ b/README.md @@ -94,3 +94,17 @@ Maintenance evacuation leaves HA-managed guests under Proxmox HA control. Remaining shared-storage guests are routed to online, non-maintenance nodes with the required storage, while local-storage guests are gracefully shut down. HA node-affinity preferences are honored when an eligible node exists. + +## Development checks + +Run the local validation suite with Bash 4.3 or newer: + +```bash +./tests/run.sh +``` + +The suite checks Bash syntax and Git whitespace, runs ShellCheck when it is +installed, and exercises Git update states, LXC input/storage selection, +maintenance evacuation routing, HA affinity parsing, and VirtIO filename +validation. Tests use temporary files and mocked Proxmox output; they do not +download installers or change a Proxmox host. diff --git a/defaults.inc b/defaults.inc index f27ad12..3cc0123 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-44' +VERS='2026.7.25-45' noupdate=' ' diff --git a/inc/deploy-iso-nfs-lxc.sh b/inc/deploy-iso-nfs-lxc.sh index 71d914f..00bee4f 100644 --- a/inc/deploy-iso-nfs-lxc.sh +++ b/inc/deploy-iso-nfs-lxc.sh @@ -9,6 +9,10 @@ TAPM_ISO_NFS_VALID_CTID() { [[ "${1:-}" =~ ^[1-9][0-9]{2,8}$ ]] } +TAPM_ISO_NFS_VALID_HOSTNAME() { + [[ "${1:-}" =~ ^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$ ]] +} + TAPM_ISO_NFS_VALID_IPV4_CIDR() { local value="${1:-}" local address prefix octet @@ -126,7 +130,7 @@ TAPM_DEPLOY_ISO_NFS_LXC() { fi TAPM_ISO_NFS_PROMPT hostname "Container hostname" "PVE-Shared-Storage" - [[ "$hostname" =~ ^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$ ]] || + TAPM_ISO_NFS_VALID_HOSTNAME "$hostname" || { TAPM_ISO_NFS_FAIL "The hostname is invalid."; return 1; } TAPM_ISO_NFS_PROMPT address_cidr "Static IPv4 address with prefix (example: 10.20.30.10/24)" TAPM_ISO_NFS_VALID_IPV4_CIDR "$address_cidr" || diff --git a/inc/deploy-proxmox-keepalived.sh b/inc/deploy-proxmox-keepalived.sh index 389d0be..65d427f 100644 --- a/inc/deploy-proxmox-keepalived.sh +++ b/inc/deploy-proxmox-keepalived.sh @@ -345,6 +345,7 @@ remote_exec() { if [[ "$node" == "$LOCAL_NODE" ]]; then bash -lc "$cmd" else + # shellcheck disable=SC2029 # cmd is intentionally expanded into the remote command. ssh "${SSH_OPTS[@]}" "root@${node}" "$cmd" fi } diff --git a/inc/virtio-helpers.inc b/inc/virtio-helpers.inc new file mode 100644 index 0000000..ce04a1b --- /dev/null +++ b/inc/virtio-helpers.inc @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Pure VirtIO filename helpers shared by TA-ProxMenu and its tests. + +TAPM_VIRTIO_FILENAME_FROM_URL() { + local url="${1%%\?*}" + local filename="${url##*/}" + + [[ "$filename" =~ ^virtio-win(-0\.1\.[0-9]+)?\.iso$ ]] || return 1 + printf '%s\n' "$filename" +} + +TAPM_VIRTIO_LABELED_FILENAME() { + local source_filename="$1" + local label="$2" + local version='' + + [[ "$label" =~ ^[a-z0-9-]+$ ]] || return 1 + if [[ "$source_filename" =~ ^virtio-win-(0\.1\.[0-9]+)\.iso$ ]]; then + version="${BASH_REMATCH[1]}" + elif [[ "$source_filename" != 'virtio-win.iso' ]]; then + return 1 + fi + + printf 'virtio-win-%s%s.iso\n' "$label" "${version:+-${version}}" +} diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index db1ad20..4907e3f 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -7,6 +7,7 @@ source /opt/idssys/defaults/default.inc source /opt/idssys/ta-proxmenu/defaults.inc source /opt/idssys/ta-proxmenu/inc/git-update.inc source /opt/idssys/ta-proxmenu/inc/deploy-iso-nfs-lxc.sh +source /opt/idssys/ta-proxmenu/inc/virtio-helpers.inc ACTION_REQUESTED=0 [[ -n "${action:-}" ]] && ACTION_REQUESTED=1 @@ -791,29 +792,6 @@ declare -a VIRTIO_STORAGE_IDS=() declare -a VIRTIO_STORAGE_TYPES=() declare -a VIRTIO_STORAGE_DIRS=() -TAPM_VIRTIO_FILENAME_FROM_URL() { - local url="${1%%\?*}" - local filename="${url##*/}" - - [[ "$filename" =~ ^virtio-win(-0\.1\.[0-9]+)?\.iso$ ]] || return 1 - printf '%s\n' "$filename" -} - -TAPM_VIRTIO_LABELED_FILENAME() { - local source_filename="$1" - local label="$2" - local version='' - - [[ "$label" =~ ^[a-z0-9-]+$ ]] || return 1 - if [[ "$source_filename" =~ ^virtio-win-(0\.1\.[0-9]+)\.iso$ ]]; then - version="${BASH_REMATCH[1]}" - elif [[ "$source_filename" != 'virtio-win.iso' ]]; then - return 1 - fi - - printf 'virtio-win-%s%s.iso\n' "$label" "${version:+-${version}}" -} - TAPM_REFRESH_ISO_STORAGES() { local storage local type @@ -1398,7 +1376,7 @@ RESTART_SERVICE_GROUP() { choice="y" else echo -en "${idsCL[LightCyan]}Restart ${description} on this host (Y/n)?${idsCL[Default]} " - read -n 1 choice + read -r -n 1 choice echo fi [[ "$choice" =~ ^[Nn]$ ]] && return @@ -1443,7 +1421,7 @@ RESTART_CLUSTER_FILESYSTEM() { fi echo -en "\n${idsCL[LightCyan]}Restart pve-cluster on this host (y/N)?${idsCL[Default]} " - read -n 1 choice + read -r -n 1 choice echo [[ "$choice" =~ ^[Yy]$ ]] || return @@ -2019,7 +1997,7 @@ SWITCH_SCRIPT_BRANCH() { fi echo -en "\n${idsCL[LightCyan]}Switch TA-ProxMenu from '${current_branch}' to '${target_branch}' (y/N)?${idsCL[Default]} " - read -n 1 choice + read -r -n 1 choice echo [[ "$choice" =~ ^[Yy]$ ]] || return @@ -2181,7 +2159,7 @@ UTILITIES_MENU() { continue fi echo -en "\n${idsCL[LightCyan]}Install the update for the current branch (y/N)?${idsCL[Default]} " - read -n 1 choice + read -r -n 1 choice echo if [[ "$choice" =~ ^[Yy]$ ]] && /opt/idssys/ta-proxmenu/run.sh update; then @@ -2229,7 +2207,13 @@ if (( ACTION_REQUESTED == 1 )); then glances) INSTALL_GLANCES;; acronis) INSTALL_ACRONIS;; post-install|post_install) PROXMENUX_POST_INSTALL;; - proxmenux) [ ! -f /usr/local/bin/menu ] && INSTALL_PROXMENUX || /usr/local/bin/menu;; + proxmenux) + if [[ ! -f /usr/local/bin/menu ]]; then + INSTALL_PROXMENUX + else + /usr/local/bin/menu + fi + ;; virtio) VIRTIO_MENU;; sentinelone|s1) INSTALL_S1;; screenconnect) INSTALL_SCREENCONNECT;; diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..ce2aa4e --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +declare -a shell_files=() +declare -a test_files=() +failures=0 +required_command='' + +if (( BASH_VERSINFO[0] < 4 || + (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3) )); then + printf 'TA-ProxMenu tests require Bash 4.3 or newer; found %s.\n' \ + "$BASH_VERSION" >&2 + exit 1 +fi + +for required_command in git python3; do + if ! command -v "$required_command" >/dev/null 2>&1; then + printf 'TA-ProxMenu tests require %s.\n' "$required_command" >&2 + exit 1 + fi +done + +while IFS= read -r -d '' file; do + shell_files+=("$file") +done < <( + find "$TEST_ROOT" -type f \( -name '*.sh' -o -name '*.inc' \) \ + -not -path '*/.git/*' -print0 | + sort -z +) + +printf 'Bash syntax\n' +for file in "${shell_files[@]}"; do + if ! bash -n "$file"; then + (( failures += 1 )) + fi +done +(( failures == 0 )) && printf ' PASS: %d files\n' "${#shell_files[@]}" + +printf '\nGit whitespace\n' +if git -C "$TEST_ROOT" diff --check && + git -C "$TEST_ROOT" diff --cached --check; then + printf ' PASS\n' +else + (( failures += 1 )) +fi + +printf '\nShellCheck\n' +if command -v shellcheck >/dev/null 2>&1; then + # These rules cannot follow TAPM's runtime-sourced defaults/colors or + # functions invoked indirectly through traps and menu dispatch. + if shellcheck -x -e SC1091,SC2034,SC2154,SC2317 "${shell_files[@]}"; then + printf ' PASS\n' + else + (( failures += 1 )) + fi +else + printf ' SKIP: shellcheck is not installed\n' +fi + +while IFS= read -r -d '' file; do + test_files+=("$file") +done < <( + find "${TEST_ROOT}/tests" -maxdepth 1 -type f -name 'test-*.sh' \ + -print0 | sort -z +) + +printf '\nBehavior tests\n' +for file in "${test_files[@]}"; do + printf ' %-30s ' "${file##*/}" + if bash "$file"; then + : + else + (( failures += 1 )) + fi +done + +printf '\n' +if (( failures > 0 )); then + printf 'FAILED: %d check group(s) failed.\n' "$failures" >&2 + exit 1 +fi +printf 'All TA-ProxMenu checks passed.\n' diff --git a/tests/test-evacuation.sh b/tests/test-evacuation.sh new file mode 100755 index 0000000..e999216 --- /dev/null +++ b/tests/test-evacuation.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${TEST_ROOT}/tests/testlib.sh" +source "${TEST_ROOT}/inc/evacuate-proxmox-node.sh" + +set_routing_fixture() { + MIGRATION_NODES=(node2 node3) + NODE_STORAGE_CACHE=() + NODE_STORAGE_CACHE[node2]='shared-a,shared-b' + NODE_STORAGE_CACHE[node3]='shared-a,shared-c' + PREFERRED_TARGET=node2 +} + +set_routing_fixture +assert_success "preferred destination is eligible" \ + choose_destination shared-a '' 0 +assert_equal node2 "$CHOSEN_DESTINATION" "preferred destination selected" + +set_routing_fixture +assert_success "affinity fallback is available" \ + choose_destination shared-a node3 1 +assert_equal node3 "$CHOSEN_DESTINATION" "affinity destination selected" +assert_equal "routed to satisfy HA node-affinity preference" "$CHOSEN_NOTE" \ + "affinity routing explanation" + +set_routing_fixture +assert_success "storage fallback is available" \ + choose_destination shared-c '' 0 +assert_equal node3 "$CHOSEN_DESTINATION" "storage-compatible destination selected" + +set_routing_fixture +assert_failure "strict affinity blocks two noncompliant destinations" \ + choose_destination shared-a node4 1 + +set_routing_fixture +MIGRATION_NODES=(node2) +assert_success "sole eligible node overrides affinity" \ + choose_destination shared-a node4 1 +assert_equal node2 "$CHOSEN_DESTINATION" "sole eligible destination selected" +assert_equal "only eligible node; HA node-affinity preference overridden" \ + "$CHOSEN_NOTE" "sole-node override explanation" + +TEST_RULES_FILE="$(mktemp /tmp/tapm-ha-rules.XXXXXX)" +cleanup_evacuation_tests() { + if [[ "$TEST_RULES_FILE" == /tmp/tapm-ha-rules.* ]]; then + rm -f -- "$TEST_RULES_FILE" + fi +} +trap cleanup_evacuation_tests EXIT + +printf '%s\n' \ + 'node-affinity: preferred-nodes' \ + ' resources vm:210,ct:211' \ + ' nodes node3:20,node2:10' \ + ' strict 1' >"$TEST_RULES_FILE" +HA_RULES_FILE="$TEST_RULES_FILE" + +assert_equal $'preferred-nodes\x1f1\x1fnode3,node2' \ + "$(get_guest_node_affinity qemu 210)" \ + "QEMU affinity parsing" +assert_equal $'preferred-nodes\x1f1\x1fnode3,node2' \ + "$(get_guest_node_affinity lxc 211)" \ + "LXC affinity parsing" +assert_equal $'none\x1f0\x1f' \ + "$(get_guest_node_affinity qemu 999)" \ + "guest without affinity" + +finish_tests diff --git a/tests/test-git-update.sh b/tests/test-git-update.sh new file mode 100755 index 0000000..19437c3 --- /dev/null +++ b/tests/test-git-update.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${TEST_ROOT}/tests/testlib.sh" +source "${TEST_ROOT}/inc/git-update.inc" + +TEST_TEMP_DIR="$(mktemp -d /tmp/tapm-git-tests.XXXXXX)" +TEST_REPOSITORY="${TEST_TEMP_DIR}/repository" + +cleanup_git_tests() { + if [[ "$TEST_TEMP_DIR" == /tmp/tapm-git-tests.* && -d "$TEST_TEMP_DIR" ]]; then + rm -rf -- "$TEST_TEMP_DIR" + fi +} +trap cleanup_git_tests EXIT + +git init -q -b main "$TEST_REPOSITORY" +git -C "$TEST_REPOSITORY" config user.name 'TA-ProxMenu Tests' +git -C "$TEST_REPOSITORY" config user.email 'tapm-tests@example.invalid' + +printf 'first\n' >"${TEST_REPOSITORY}/fixture.txt" +git -C "$TEST_REPOSITORY" add fixture.txt +git -C "$TEST_REPOSITORY" commit -q -m first +commit_a="$(git -C "$TEST_REPOSITORY" rev-parse HEAD)" + +printf 'second\n' >>"${TEST_REPOSITORY}/fixture.txt" +git -C "$TEST_REPOSITORY" commit -q -am second +commit_b="$(git -C "$TEST_REPOSITORY" rev-parse HEAD)" + +git -C "$TEST_REPOSITORY" switch -q --detach "$commit_a" +printf 'alternate\n' >"${TEST_REPOSITORY}/alternate.txt" +git -C "$TEST_REPOSITORY" add alternate.txt +git -C "$TEST_REPOSITORY" commit -q -m alternate +commit_c="$(git -C "$TEST_REPOSITORY" rev-parse HEAD)" + +git -C "$TEST_REPOSITORY" update-ref refs/tapm/a "$commit_a" +git -C "$TEST_REPOSITORY" update-ref refs/tapm/b "$commit_b" +git -C "$TEST_REPOSITORY" update-ref refs/tapm/c "$commit_c" + +assert_equal current \ + "$(TAPM_GIT_RELATION "$TEST_REPOSITORY" refs/tapm/a refs/tapm/a)" \ + "equal commits" +assert_equal behind \ + "$(TAPM_GIT_RELATION "$TEST_REPOSITORY" refs/tapm/a refs/tapm/b)" \ + "local commit behind remote" +assert_equal ahead \ + "$(TAPM_GIT_RELATION "$TEST_REPOSITORY" refs/tapm/b refs/tapm/a)" \ + "local commit ahead of remote" +assert_equal diverged \ + "$(TAPM_GIT_RELATION "$TEST_REPOSITORY" refs/tapm/b refs/tapm/c)" \ + "diverged commits" + +git -C "$TEST_REPOSITORY" switch -q -C main "$commit_a" +git -C "$TEST_REPOSITORY" update-ref refs/remotes/origin/main "$commit_a" +assert_equal current \ + "$(TAPM_GIT_BRANCH_STATE "$TEST_REPOSITORY" main)" \ + "clean current branch" + +printf 'untracked\n' >"${TEST_REPOSITORY}/untracked.txt" +assert_equal dirty \ + "$(TAPM_GIT_BRANCH_STATE "$TEST_REPOSITORY" main)" \ + "dirty working tree" +rm -f -- "${TEST_REPOSITORY}/untracked.txt" + +git -C "$TEST_REPOSITORY" switch -q -c feature +assert_equal wrong-branch \ + "$(TAPM_GIT_BRANCH_STATE "$TEST_REPOSITORY" main)" \ + "wrong checked-out branch" + +git -C "$TEST_REPOSITORY" switch -q main +git -C "$TEST_REPOSITORY" update-ref refs/remotes/origin/main "$commit_b" +assert_success "fast-forward merge" \ + TAPM_GIT_FAST_FORWARD "$TEST_REPOSITORY" main +assert_equal "$commit_b" \ + "$(git -C "$TEST_REPOSITORY" rev-parse HEAD)" \ + "fast-forward destination" + +finish_tests diff --git a/tests/test-iso-nfs.sh b/tests/test-iso-nfs.sh new file mode 100755 index 0000000..b62a31f --- /dev/null +++ b/tests/test-iso-nfs.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${TEST_ROOT}/tests/testlib.sh" +source "${TEST_ROOT}/inc/deploy-iso-nfs-lxc.sh" + +assert_success "valid storage ID" TAPM_ISO_NFS_VALID_ID PVE-Shared-Storage +assert_failure "storage ID cannot start with a number" TAPM_ISO_NFS_VALID_ID 1-storage +assert_failure "storage ID rejects spaces" TAPM_ISO_NFS_VALID_ID 'shared storage' + +assert_success "valid CTID" TAPM_ISO_NFS_VALID_CTID 210 +assert_failure "reserved two-digit CTID" TAPM_ISO_NFS_VALID_CTID 99 +assert_failure "CTID rejects text" TAPM_ISO_NFS_VALID_CTID ct210 + +assert_success "valid hostname" TAPM_ISO_NFS_VALID_HOSTNAME PVE-Shared-Storage +assert_success "valid FQDN hostname" TAPM_ISO_NFS_VALID_HOSTNAME iso-nfs.example.net +assert_failure "hostname rejects spaces" TAPM_ISO_NFS_VALID_HOSTNAME 'ISO Storage' + +assert_success "valid IPv4 CIDR" TAPM_ISO_NFS_VALID_IPV4_CIDR 10.10.2.45/16 +assert_failure "IPv4 octet out of range" TAPM_ISO_NFS_VALID_IPV4_CIDR 10.10.2.256/24 +assert_failure "IPv4 prefix out of range" TAPM_ISO_NFS_VALID_IPV4_CIDR 10.10.2.45/33 +assert_failure "IPv4 prefix required" TAPM_ISO_NFS_VALID_IPV4_CIDR 10.10.2.45 + +pvesm() { + [[ "${1:-}" == status ]] || return 1 + printf '%s\n' \ + 'Name Type Status Total Used Available %' \ + 'local-lvm lvmthin active 100 10 90 10%' \ + 'iSCSI-Datastore1 lvm active 200 20 180 10%' \ + 'iSCSI-Datastore1-2 lvm active 200 20 180 10%' \ + 'offline-store dir inactive 100 0 100 0%' +} + +SELECT_MENU() { + local title="$1" + + assert_equal "Root filesystem storage" "$title" "storage menu title" + assert_equal \ + 'iSCSI-Datastore1-2 (lvm) — default' \ + "${labels[0]}" \ + "default storage placed first" + assert_equal 3 "${#labels[@]}" "only active storages offered" + MENU_SELECTION="${values[0]}" +} + +test_default_storage_selection() { + local selected_storage='' + + TAPM_ISO_NFS_SELECT_STORAGE selected_storage \ + "Root filesystem storage" "iSCSI-Datastore1-2" || return 1 + assert_equal iSCSI-Datastore1-2 "$selected_storage" \ + "pressing Enter retains default storage" +} + +assert_success "default storage menu selection" test_default_storage_selection + +finish_tests diff --git a/tests/test-virtio.sh b/tests/test-virtio.sh new file mode 100755 index 0000000..b753d89 --- /dev/null +++ b/tests/test-virtio.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${TEST_ROOT}/tests/testlib.sh" +source "${TEST_ROOT}/inc/virtio-helpers.inc" + +assert_equal virtio-win.iso \ + "$(TAPM_VIRTIO_FILENAME_FROM_URL \ + 'https://fedorapeople.org/stable/virtio-win.iso?download=1')" \ + "stable source filename" +assert_equal virtio-win-0.1.285.iso \ + "$(TAPM_VIRTIO_FILENAME_FROM_URL \ + 'https://fedorapeople.org/archive/virtio-win-0.1.285.iso')" \ + "versioned source filename" +assert_failure "unexpected source filename rejected" \ + TAPM_VIRTIO_FILENAME_FROM_URL \ + 'https://example.invalid/virtio-win-latest.iso' + +assert_equal virtio-win-latest-0.1.285.iso \ + "$(TAPM_VIRTIO_LABELED_FILENAME virtio-win-0.1.285.iso latest)" \ + "stable local filename" +assert_equal virtio-win-server-2016-0.1.240.iso \ + "$(TAPM_VIRTIO_LABELED_FILENAME \ + virtio-win-0.1.240.iso server-2016)" \ + "Server 2016 local filename" +assert_equal virtio-win-server-2008r2-0.1.172.iso \ + "$(TAPM_VIRTIO_LABELED_FILENAME \ + virtio-win-0.1.172.iso server-2008r2)" \ + "Server 2008 R2 local filename" +assert_failure "unsafe local label rejected" \ + TAPM_VIRTIO_LABELED_FILENAME virtio-win-0.1.285.iso '../latest' + +finish_tests diff --git a/tests/testlib.sh b/tests/testlib.sh new file mode 100644 index 0000000..a339cf8 --- /dev/null +++ b/tests/testlib.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +TEST_ASSERTIONS=0 +TEST_FAILURES=0 + +test_fail() { + printf 'FAIL: %s\n' "$1" >&2 + (( TEST_FAILURES += 1 )) +} + +assert_equal() { + local expected="$1" + local actual="$2" + local label="$3" + + (( TEST_ASSERTIONS += 1 )) + if [[ "$actual" != "$expected" ]]; then + test_fail "${label}: expected '${expected}', received '${actual}'" + fi +} + +assert_success() { + local label="$1" + shift + + (( TEST_ASSERTIONS += 1 )) + "$@" || test_fail "${label}: expected success" +} + +assert_failure() { + local label="$1" + shift + + (( TEST_ASSERTIONS += 1 )) + if "$@"; then + test_fail "${label}: expected failure" + fi +} + +finish_tests() { + if (( TEST_FAILURES > 0 )); then + printf '%d assertion(s), %d failure(s)\n' \ + "$TEST_ASSERTIONS" "$TEST_FAILURES" >&2 + return 1 + fi + + printf '%d assertion(s) passed\n' "$TEST_ASSERTIONS" +} From 76af8116da99e1b5f998b4157dda9b4b1bd6979b Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 19:46:11 -0500 Subject: [PATCH 33/76] update --- defaults.inc | 5 +- inc/deploy-pulse-lxc.sh | 411 ++++++++++++++++++++++++++++++++++++++++ proxmenu-scripts.sh | 27 +-- tests/test-pulse.sh | 35 ++++ 4 files changed, 455 insertions(+), 23 deletions(-) create mode 100644 inc/deploy-pulse-lxc.sh create mode 100644 tests/test-pulse.sh diff --git a/defaults.inc b/defaults.inc index 3cc0123..9e50dfd 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-45' +VERS='2026.7.25-46' noupdate=' ' @@ -16,6 +16,9 @@ fi VIRTIO_STABLE_URL="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso" +PULSE_RELEASE='v6.1.1' +PULSE_PORT='7655' + TAPM_BROKER_URL='https://tapm.scity.us' S1_BROKER_PACKAGE='sentinelone-linux' S1_PACKAGE='tapm-sentinelone.deb' diff --git a/inc/deploy-pulse-lxc.sh b/inc/deploy-pulse-lxc.sh new file mode 100644 index 0000000..56034bb --- /dev/null +++ b/inc/deploy-pulse-lxc.sh @@ -0,0 +1,411 @@ +#!/usr/bin/env bash +# TA-managed Pulse LXC deployment for Proxmox VE. +# +# TA-ProxMenu owns the LXC provisioning and pins an exact Pulse release. The +# matching upstream installer and archive are both downloaded from that release +# and verified with Pulse's published SSH signing key before use. There is no +# "latest" URL lookup or HEAD request in this workflow. + +TAPM_PULSE_SIGNING_IDENTITY='pulse-installer' +TAPM_PULSE_SIGNING_NAMESPACE='pulse-install' +TAPM_PULSE_SIGNING_KEY='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm' + +TAPM_PULSE_VALID_RELEASE() { + [[ "${1:-}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?$ ]] +} + +TAPM_PULSE_VALID_CTID() { + [[ "${1:-}" =~ ^[1-9][0-9]{2,8}$ ]] +} + +TAPM_PULSE_VALID_HOSTNAME() { + [[ "${1:-}" =~ ^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$ ]] +} + +TAPM_PULSE_VALID_IPV4_CIDR() { + local value="${1:-}" + local address prefix octet + local -a octets + + [[ "$value" == */* ]] || return 1 + address="${value%/*}" + prefix="${value#*/}" + [[ "$prefix" =~ ^[0-9]+$ ]] && (( prefix <= 32 )) || return 1 + IFS=. read -r -a octets <<<"$address" + (( ${#octets[@]} == 4 )) || return 1 + for octet in "${octets[@]}"; do + [[ "$octet" =~ ^[0-9]+$ ]] && (( 10#$octet <= 255 )) || return 1 + done +} + +TAPM_PULSE_ARCH() { + case "${1:-}" in + x86_64|amd64) printf 'amd64\n';; + aarch64|arm64) printf 'arm64\n';; + *) return 1;; + esac +} + +TAPM_PULSE_RESOURCE_INSTALLED() { + local resources_json="${1:-[]}" + + RESOURCES_JSON="$resources_json" python3 -c ' +import json, os +try: + resources = json.loads(os.environ["RESOURCES_JSON"]) +except (TypeError, ValueError): + raise SystemExit(1) +for item in resources: + tags = str(item.get("tags", "")).split(";") + if item.get("type") == "lxc" and ( + "pulse" in tags or str(item.get("name", "")).lower() == "pulse" + ): + raise SystemExit(0) +raise SystemExit(1) +' 2>/dev/null +} + +TAPM_PULSE_PROMPT() { + local variable="$1" + local label="$2" + local default_value="${3:-}" + local value + + if [[ -n "$default_value" ]]; then + read -r -p " ${label} [${default_value}]: " value + printf -v "$variable" '%s' "${value:-$default_value}" + else + read -r -p " ${label}: " value + printf -v "$variable" '%s' "$value" + fi +} + +TAPM_PULSE_FAIL() { + echo -e "\n${idsCL[LightRed]}$1${idsCL[Default]}" + return 1 +} + +TAPM_PULSE_SELECT_BRIDGE() { + local variable="$1" + local bridge default_bridge="${2:-vmbr0}" + local default_found=0 + local -a bridges=() + local -a labels=() + local -a values=() + + while IFS= read -r bridge; do + [[ -n "$bridge" ]] || continue + bridges+=("$bridge") + [[ "$bridge" == "$default_bridge" ]] && default_found=1 + done < <( + for bridge_path in /sys/class/net/*/bridge; do + [[ -d "$bridge_path" ]] && basename "$(dirname "$bridge_path")" + done | sort -V + ) + + (( ${#bridges[@]} > 0 )) || + { TAPM_PULSE_FAIL "No Linux bridges were found on this host."; return 1; } + + if (( default_found == 1 )); then + labels+=("${default_bridge} — default") + values+=("bridge:${default_bridge}") + fi + for bridge in "${bridges[@]}"; do + [[ $default_found == 1 && "$bridge" == "$default_bridge" ]] && continue + labels+=("$bridge") + values+=("bridge:${bridge}") + done + + SELECT_MENU "Pulse network bridge" labels values + case "$MENU_SELECTION" in + bridge:*) printf -v "$variable" '%s' "${MENU_SELECTION#bridge:}";; + quit) EXIT1; exit 0;; + *) return 1;; + esac +} + +TAPM_PULSE_STORAGE_IS_SHARED() { + local storage="$1" + + pvesh get "/storage/${storage}" --output-format json 2>/dev/null | + python3 -c ' +import json, sys +try: + value = json.load(sys.stdin).get("shared", 0) +except (AttributeError, TypeError, ValueError): + raise SystemExit(1) +raise SystemExit(0 if str(value).lower() in {"1", "true", "yes"} else 1) +' +} + +TAPM_PULSE_VERIFY_SIGNATURE() { + local target_path="$1" + local signature_path="$2" + local label="${3:-Pulse release asset}" + local allowed_signers + + command -v ssh-keygen >/dev/null 2>&1 || + { TAPM_PULSE_FAIL "OpenSSH is required to verify ${label}."; return 1; } + [[ -s "$target_path" && -s "$signature_path" ]] || + { TAPM_PULSE_FAIL "${label} or its signature is missing."; return 1; } + + allowed_signers="$(mktemp /tmp/tapm-pulse-signers.XXXXXX)" || + { TAPM_PULSE_FAIL "Could not create the Pulse signature verifier file."; return 1; } + printf '%s %s\n' "$TAPM_PULSE_SIGNING_IDENTITY" \ + "$TAPM_PULSE_SIGNING_KEY" >"$allowed_signers" + + if ! ssh-keygen -Y verify \ + -f "$allowed_signers" \ + -I "$TAPM_PULSE_SIGNING_IDENTITY" \ + -n "$TAPM_PULSE_SIGNING_NAMESPACE" \ + -s "$signature_path" <"$target_path" >/dev/null 2>&1; then + rm -f -- "$allowed_signers" + TAPM_PULSE_FAIL "Signature verification failed for ${label}; nothing was installed." + return 1 + fi + rm -f -- "$allowed_signers" +} + +TAPM_PULSE_REMOVE_PARTIAL_LXC() { + local ctid="$1" + + echo -e "${idsCL[LightYellow]}Removing incomplete LXC ${ctid} created by this deployment...${idsCL[Default]}" + pct stop "$ctid" --skiplock 1 >/dev/null 2>&1 || true + pct destroy "$ctid" --purge 1 >/dev/null 2>&1 || true +} + +TAPM_DEPLOY_PULSE_LXC() { + local release="${PULSE_RELEASE:-v6.1.1}" + local pulse_port="${PULSE_PORT:-7655}" + local ctid default_ctid hostname bridge address_cidr gateway vlan_id + local root_storage default_root_storage template_storage template_name template_path + local arch archive_name base_url installer archive signature installer_signature + local network_config choice add_ha='no' container_ip timezone temp_dir + local container_created=0 + + echo + echo -e "${idsCL[LightCyan]}Deploy Pulse monitoring in a dedicated LXC${idsCL[Default]}" + echo + echo " This TA-managed workflow creates the container and installs a pinned," + echo " cryptographically verified Pulse release. It does not query a latest" + echo " release URL before installation." + echo + + [[ $EUID -eq 0 ]] || + { TAPM_PULSE_FAIL "Run this action as root on a Proxmox VE host."; return 1; } + for command in pct pvesm pveam pvesh ssh-keygen python3; do + command -v "$command" >/dev/null 2>&1 || + { TAPM_PULSE_FAIL "Required command '${command}' was not found."; return 1; } + done + TAPM_PULSE_VALID_RELEASE "$release" || + { TAPM_PULSE_FAIL "Configured Pulse release '${release}' is invalid."; return 1; } + + default_ctid="$(pvesh get /cluster/nextid 2>/dev/null || true)" + TAPM_PULSE_PROMPT ctid "Container ID" "$default_ctid" + TAPM_PULSE_VALID_CTID "$ctid" || + { TAPM_PULSE_FAIL "The container ID is invalid."; return 1; } + if pct status "$ctid" >/dev/null 2>&1; then + TAPM_PULSE_FAIL "Container ${ctid} already exists; no changes were made." + return 1 + fi + + TAPM_PULSE_PROMPT hostname "Container hostname" "pulse" + TAPM_PULSE_VALID_HOSTNAME "$hostname" || + { TAPM_PULSE_FAIL "The hostname is invalid."; return 1; } + TAPM_PULSE_SELECT_BRIDGE bridge vmbr0 || return 1 + TAPM_PULSE_PROMPT address_cidr \ + "Static IPv4 address with prefix (leave blank for DHCP)" + if [[ -n "$address_cidr" ]]; then + TAPM_PULSE_VALID_IPV4_CIDR "$address_cidr" || + { TAPM_PULSE_FAIL "The static IPv4 address is invalid."; return 1; } + TAPM_PULSE_PROMPT gateway "IPv4 gateway" + TAPM_PULSE_VALID_IPV4_CIDR "${gateway}/32" || + { TAPM_PULSE_FAIL "The IPv4 gateway is invalid."; return 1; } + fi + TAPM_PULSE_PROMPT vlan_id "VLAN ID (leave blank for untagged)" + if [[ -n "$vlan_id" ]] && + { [[ ! "$vlan_id" =~ ^[0-9]+$ ]] || (( vlan_id < 1 || vlan_id > 4094 )); }; then + TAPM_PULSE_FAIL "The VLAN ID must be between 1 and 4094." + return 1 + fi + + default_root_storage="$( + pvesm status --content rootdir 2>/dev/null | + awk 'NR > 1 && $3 == "active" { print $1; exit }' + )" + [[ -n "$default_root_storage" ]] || + { TAPM_PULSE_FAIL "No active storage supports LXC volumes."; return 1; } + TAPM_ISO_NFS_SELECT_STORAGE root_storage \ + "Pulse root filesystem storage" "$default_root_storage" || return 1 + + default_root_storage="$( + pvesm status --content vztmpl 2>/dev/null | + awk 'NR > 1 && $3 == "active" { print $1; exit }' + )" + [[ -n "$default_root_storage" ]] || + { TAPM_PULSE_FAIL "No active storage supports container templates."; return 1; } + template_storage="$default_root_storage" + + if TAPM_PULSE_STORAGE_IS_SHARED "$root_storage" && + command -v ha-manager >/dev/null 2>&1; then + read -r -p " Add the Pulse LXC to Proxmox HA after installation? [Y/n] " choice + [[ ! "$choice" =~ ^[Nn]$ ]] && add_ha='yes' + fi + + echo + echo " Deployment summary" + echo " Pulse release: ${release}" + echo " LXC: ${ctid} (${hostname}), unprivileged" + if [[ -n "$address_cidr" ]]; then + echo " Network: ${address_cidr} via ${gateway} on ${bridge}" + else + echo " Network: DHCP on ${bridge}" + fi + [[ -n "$vlan_id" ]] && echo " VLAN: ${vlan_id}" + echo " Resources: 2 vCPU, 2048 MiB RAM, 512 MiB swap, 100 CPU units" + echo " Root volume: ${root_storage}:8 GiB" + echo " Pulse port: ${pulse_port}" + echo " Start at boot: yes" + echo " Automatic update: enabled" + echo " Proxmox HA: ${add_ha}" + echo + read -r -p " Create this Pulse container (type yes to continue)? " choice + [[ "$choice" =~ ^[Yy][Ee][Ss]$ ]] || { + echo " Cancelled; no changes were made." + return 0 + } + + arch="$(TAPM_PULSE_ARCH "$(uname -m)")" || + { TAPM_PULSE_FAIL "Pulse does not support this host architecture."; return 1; } + archive_name="pulse-${release}-linux-${arch}.tar.gz" + base_url="https://github.com/rcourtman/Pulse/releases/download/${release}" + + if ! TAPM_CREATE_TEMP_DIR pulse; then + return 1 + fi + temp_dir="$TAPM_TEMP_DIR" + installer="${temp_dir}/install.sh" + installer_signature="${installer}.sshsig" + archive="${temp_dir}/${archive_name}" + signature="${archive}.sshsig" + + echo -e "\n${idsCL[LightCyan]}Downloading and verifying Pulse ${release}...${idsCL[Default]}" + if ! TAPM_DOWNLOAD_HTTPS "${base_url}/install.sh" "$installer" "Pulse installer" || + ! TAPM_DOWNLOAD_HTTPS "${base_url}/install.sh.sshsig" \ + "$installer_signature" "Pulse installer signature" || + ! TAPM_PULSE_VERIFY_SIGNATURE "$installer" "$installer_signature" "Pulse installer" || + ! TAPM_DOWNLOAD_HTTPS "${base_url}/${archive_name}" "$archive" "Pulse archive" || + ! TAPM_DOWNLOAD_HTTPS "${base_url}/${archive_name}.sshsig" \ + "$signature" "Pulse archive signature" || + ! TAPM_PULSE_VERIFY_SIGNATURE "$archive" "$signature" "Pulse archive"; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + return 1 + fi + + echo -e "\n${idsCL[LightCyan]}Locating a Debian container template...${idsCL[Default]}" + if ! pveam update; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + TAPM_PULSE_FAIL "Could not refresh the container template catalog." + return 1 + fi + template_name="$( + pveam available --section system | + awk '$2 ~ /^debian-(13|12)-standard_/ { print $2 }' | + sort -V | + tail -1 + )" + if [[ -z "$template_name" ]]; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + TAPM_PULSE_FAIL "No supported Debian 12/13 standard template was found." + return 1 + fi + template_path="${template_storage}:vztmpl/${template_name}" + if ! pveam list "$template_storage" 2>/dev/null | + awk 'NR > 1 { print $1 }' | + grep -Fxq -- "$template_path"; then + if ! pveam download "$template_storage" "$template_name"; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + TAPM_PULSE_FAIL "The Debian template download failed." + return 1 + fi + fi + + if [[ -n "$address_cidr" ]]; then + network_config="name=eth0,bridge=${bridge},ip=${address_cidr},gw=${gateway},firewall=1,type=veth" + else + network_config="name=eth0,bridge=${bridge},ip=dhcp,firewall=1,type=veth" + fi + [[ -n "$vlan_id" ]] && network_config+=",tag=${vlan_id}" + + echo -e "\n${idsCL[LightCyan]}Creating and starting LXC ${ctid}...${idsCL[Default]}" + if ! pct create "$ctid" "$template_path" \ + --hostname "$hostname" \ + --ostype debian \ + --unprivileged 1 \ + --features nesting=1 \ + --cores 2 \ + --cpunits 100 \ + --memory 2048 \ + --swap 512 \ + --rootfs "${root_storage}:8" \ + --net0 "$network_config" \ + --onboot 1 \ + --startup order=10 \ + --tags 'tapm;pulse'; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + TAPM_PULSE_FAIL "Container creation failed." + return 1 + fi + container_created=1 + if ! pct start "$ctid" || + ! timeout 90 bash -c \ + "until pct exec '$ctid' -- test -d /run/systemd/system >/dev/null 2>&1; do sleep 2; done"; then + TAPM_PULSE_REMOVE_PARTIAL_LXC "$ctid" + TAPM_CLEAN_TEMP_DIR "$temp_dir" + TAPM_PULSE_FAIL "The new Pulse container did not become ready." + return 1 + fi + + timezone="$(timedatectl show --property=Timezone --value 2>/dev/null || true)" + [[ -n "$timezone" ]] || timezone='America/Chicago' + pct exec "$ctid" -- ln -snf "/usr/share/zoneinfo/${timezone}" /etc/localtime || true + + echo -e "\n${idsCL[LightCyan]}Installing verified Pulse release inside LXC ${ctid}...${idsCL[Default]}" + if ! pct push "$ctid" "$installer" /tmp/install.sh || + ! pct push "$ctid" "$archive" "/tmp/${archive_name}" || + ! pct push "$ctid" "$signature" "/tmp/${archive_name}.sshsig" || + ! timeout 600 pct exec "$ctid" -- env "FRONTEND_PORT=${pulse_port}" \ + bash /tmp/install.sh \ + --in-container \ + --version "$release" \ + --archive "/tmp/${archive_name}" \ + --enable-auto-updates || + ! pct exec "$ctid" -- systemctl is-active --quiet pulse; then + (( container_created == 1 )) && TAPM_PULSE_REMOVE_PARTIAL_LXC "$ctid" + TAPM_CLEAN_TEMP_DIR "$temp_dir" + TAPM_PULSE_FAIL "Pulse could not be installed or verified." + return 1 + fi + + container_ip="$( + pct exec "$ctid" -- hostname -I 2>/dev/null | + awk '{ print $1; exit }' + )" + if [[ "$add_ha" == 'yes' ]] && + ! ha-manager add "ct:${ctid}" --state started; then + echo -e "${idsCL[LightYellow]}Pulse is running, but it could not be added to HA.${idsCL[Default]}" + fi + + pct exec "$ctid" -- rm -f \ + /tmp/install.sh "/tmp/${archive_name}" "/tmp/${archive_name}.sshsig" || true + TAPM_CLEAN_TEMP_DIR "$temp_dir" + container_created=0 + + echo + echo -e "${idsCL[Green]}Pulse ${release} was installed and its service is active.${idsCL[Default]}" + if [[ -n "$container_ip" ]]; then + echo -e " Open ${idsCL[LightCyan]}http://${container_ip}:${pulse_port}${idsCL[Default]} to finish setup." + else + echo " Open the Pulse LXC address on port ${pulse_port} to finish setup." + fi +} diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 4907e3f..35c3f12 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -7,6 +7,7 @@ source /opt/idssys/defaults/default.inc source /opt/idssys/ta-proxmenu/defaults.inc source /opt/idssys/ta-proxmenu/inc/git-update.inc source /opt/idssys/ta-proxmenu/inc/deploy-iso-nfs-lxc.sh +source /opt/idssys/ta-proxmenu/inc/deploy-pulse-lxc.sh source /opt/idssys/ta-proxmenu/inc/virtio-helpers.inc ACTION_REQUESTED=0 @@ -234,30 +235,12 @@ print(package.get("version", "") if package else "") } INSTALL_PULSE() { - local installer - local temp_dir - - echo - if ! TAPM_CREATE_TEMP_DIR pulse; then - FINISH_FAILED_ACTION - return - fi - temp_dir="$TAPM_TEMP_DIR" - installer="${temp_dir}/install.sh" - - if ! TAPM_DOWNLOAD_HTTPS \ - 'https://github.com/rcourtman/Pulse/releases/latest/download/install.sh' \ - "$installer" 'Pulse installer' || - ! bash "$installer"; then - TAPM_CLEAN_TEMP_DIR "$temp_dir" - echo -e "${idsCL[LightRed]}Pulse installation failed.${idsCL[Default]}" + if ! TAPM_DEPLOY_PULSE_LXC; then + echo -e "${idsCL[LightRed]}Pulse deployment failed.${idsCL[Default]}" FINISH_FAILED_ACTION return fi - TAPM_CLEAN_TEMP_DIR "$temp_dir" - echo - echo -e "\n${idsCL[Green]}Pulse installer completed successfully.${idsCL[Default]}" FINISH_ACTION } @@ -1874,8 +1857,8 @@ MONITORING_MENU() { local cluster_resources while true; do - cluster_resources="$(pvesh get /cluster/resources 2>/dev/null)" - grep -qi pulse <<< "$cluster_resources" && + cluster_resources="$(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null)" + TAPM_PULSE_RESOURCE_INSTALLED "$cluster_resources" && labels=("Pulse monitoring (installed)") || labels=("Install Pulse monitoring") diff --git a/tests/test-pulse.sh b/tests/test-pulse.sh new file mode 100644 index 0000000..adf1ece --- /dev/null +++ b/tests/test-pulse.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${TEST_ROOT}/tests/testlib.sh" +source "${TEST_ROOT}/inc/deploy-pulse-lxc.sh" + +assert_success "stable Pulse release" TAPM_PULSE_VALID_RELEASE v6.1.1 +assert_success "prerelease Pulse release" TAPM_PULSE_VALID_RELEASE v6.2.0-rc.1 +assert_failure "Pulse release requires v prefix" TAPM_PULSE_VALID_RELEASE 6.1.1 +assert_failure "Pulse release rejects URL content" \ + TAPM_PULSE_VALID_RELEASE 'v6.1.1/../../latest' + +assert_equal amd64 "$(TAPM_PULSE_ARCH x86_64)" "x86 architecture mapping" +assert_equal arm64 "$(TAPM_PULSE_ARCH aarch64)" "ARM architecture mapping" +assert_failure "unsupported Pulse architecture" TAPM_PULSE_ARCH riscv64 + +assert_success "valid Pulse CTID" TAPM_PULSE_VALID_CTID 210 +assert_failure "invalid Pulse CTID" TAPM_PULSE_VALID_CTID 99 +assert_success "valid Pulse hostname" TAPM_PULSE_VALID_HOSTNAME pulse-monitor +assert_failure "invalid Pulse hostname" TAPM_PULSE_VALID_HOSTNAME 'pulse monitor' +assert_success "valid Pulse IPv4 CIDR" TAPM_PULSE_VALID_IPV4_CIDR 10.10.1.50/24 +assert_failure "invalid Pulse IPv4 CIDR" TAPM_PULSE_VALID_IPV4_CIDR 10.10.1.500/24 + +resources='[ + {"type":"lxc","name":"pulse-a","tags":"tapm;pulse"}, + {"type":"qemu","name":"unrelated"} +]' +assert_success "tagged Pulse LXC detected" TAPM_PULSE_RESOURCE_INSTALLED "$resources" +assert_success "legacy Pulse hostname detected" \ + TAPM_PULSE_RESOURCE_INSTALLED '[{"type":"lxc","name":"Pulse"}]' +assert_failure "unrelated resource not detected" \ + TAPM_PULSE_RESOURCE_INSTALLED '[{"type":"qemu","name":"pulse"}]' + +finish_tests From 747e984da68d86a320cef3e73f68dfb60b870e42 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 19:56:08 -0500 Subject: [PATCH 34/76] update --- defaults.inc | 2 +- inc/deploy-pulse-lxc.sh | 210 ++++++++++++++++++++++++++++++---------- tests/test-pulse.sh | 9 ++ 3 files changed, 171 insertions(+), 50 deletions(-) diff --git a/defaults.inc b/defaults.inc index 9e50dfd..f15bf34 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-46' +VERS='2026.7.25-47' noupdate=' ' diff --git a/inc/deploy-pulse-lxc.sh b/inc/deploy-pulse-lxc.sh index 56034bb..133ec33 100644 --- a/inc/deploy-pulse-lxc.sh +++ b/inc/deploy-pulse-lxc.sh @@ -22,6 +22,14 @@ TAPM_PULSE_VALID_HOSTNAME() { [[ "${1:-}" =~ ^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$ ]] } +TAPM_PULSE_VALID_POSITIVE_INTEGER() { + [[ "${1:-}" =~ ^[1-9][0-9]*$ ]] +} + +TAPM_PULSE_VALID_PORT() { + [[ "${1:-}" =~ ^[0-9]+$ ]] && (( 10#$1 >= 1 && 10#$1 <= 65535 )) +} + TAPM_PULSE_VALID_IPV4_CIDR() { local value="${1:-}" local address prefix octet @@ -98,9 +106,14 @@ TAPM_PULSE_SELECT_BRIDGE() { bridges+=("$bridge") [[ "$bridge" == "$default_bridge" ]] && default_found=1 done < <( - for bridge_path in /sys/class/net/*/bridge; do - [[ -d "$bridge_path" ]] && basename "$(dirname "$bridge_path")" - done | sort -V + { + for bridge_path in /sys/class/net/*/bridge; do + [[ -d "$bridge_path" ]] && basename "$(dirname "$bridge_path")" + done + if command -v ovs-vsctl >/dev/null 2>&1; then + ovs-vsctl list-br 2>/dev/null || true + fi + } | sort -Vu ) (( ${#bridges[@]} > 0 )) || @@ -138,6 +151,21 @@ raise SystemExit(0 if str(value).lower() in {"1", "true", "yes"} else 1) ' } +TAPM_PULSE_HA_STATUS_ENABLED() { + local status="${1:-}" + + grep -q '^quorum OK' <<<"$status" && + grep -Eq '^master[[:space:]].*\(active,' <<<"$status" +} + +TAPM_PULSE_CLUSTER_HA_ENABLED() { + local status + + command -v ha-manager >/dev/null 2>&1 || return 1 + status="$(ha-manager status 2>/dev/null)" || return 1 + TAPM_PULSE_HA_STATUS_ENABLED "$status" +} + TAPM_PULSE_VERIFY_SIGNATURE() { local target_path="$1" local signature_path="$2" @@ -176,12 +204,26 @@ TAPM_PULSE_REMOVE_PARTIAL_LXC() { TAPM_DEPLOY_PULSE_LXC() { local release="${PULSE_RELEASE:-v6.1.1}" - local pulse_port="${PULSE_PORT:-7655}" + local pulse_port="${PULSE_PORT:-7655}" auto_update_flag='--disable-auto-updates' local ctid default_ctid hostname bridge address_cidr gateway vlan_id local root_storage default_root_storage template_storage template_name template_path local arch archive_name base_url installer archive signature installer_signature - local network_config choice add_ha='no' container_ip timezone temp_dir + local memory disk cores cpulimit swap onboot firewall unprivileged nameserver startup + local network_config choice add_ha='no' auto_updates='yes' container_ip timezone temp_dir + local default_bridge local container_created=0 + local -a create_args=() + + memory=2048 + disk=4 + cores=2 + cpulimit=2 + swap=256 + onboot=1 + firewall=1 + unprivileged=1 + startup=99 + auto_update_flag='--enable-auto-updates' echo echo -e "${idsCL[LightCyan]}Deploy Pulse monitoring in a dedicated LXC${idsCL[Default]}" @@ -199,6 +241,8 @@ TAPM_DEPLOY_PULSE_LXC() { done TAPM_PULSE_VALID_RELEASE "$release" || { TAPM_PULSE_FAIL "Configured Pulse release '${release}' is invalid."; return 1; } + TAPM_PULSE_VALID_PORT "$pulse_port" || + { TAPM_PULSE_FAIL "Configured Pulse port '${pulse_port}' is invalid."; return 1; } default_ctid="$(pvesh get /cluster/nextid 2>/dev/null || true)" TAPM_PULSE_PROMPT ctid "Container ID" "$default_ctid" @@ -209,10 +253,36 @@ TAPM_DEPLOY_PULSE_LXC() { return 1 fi - TAPM_PULSE_PROMPT hostname "Container hostname" "pulse" + TAPM_PULSE_PROMPT hostname "Container hostname" "Pulse-Monitor" TAPM_PULSE_VALID_HOSTNAME "$hostname" || { TAPM_PULSE_FAIL "The hostname is invalid."; return 1; } - TAPM_PULSE_SELECT_BRIDGE bridge vmbr0 || return 1 + + read -r -p " Customize CPU, memory, disk, or swap? [y/N] " choice + if [[ "$choice" =~ ^[Yy]$ ]]; then + TAPM_PULSE_PROMPT memory "Memory in MiB" "$memory" + TAPM_PULSE_VALID_POSITIVE_INTEGER "$memory" || + { TAPM_PULSE_FAIL "Memory must be a positive whole number."; return 1; } + TAPM_PULSE_PROMPT disk "Root disk size in GiB" "$disk" + TAPM_PULSE_VALID_POSITIVE_INTEGER "$disk" || + { TAPM_PULSE_FAIL "Disk size must be a positive whole number."; return 1; } + TAPM_PULSE_PROMPT cores "CPU cores" "$cores" + TAPM_PULSE_VALID_POSITIVE_INTEGER "$cores" || + { TAPM_PULSE_FAIL "CPU cores must be a positive whole number."; return 1; } + TAPM_PULSE_PROMPT cpulimit "CPU limit (0 for unlimited)" "$cpulimit" + [[ "$cpulimit" =~ ^[0-9]+$ ]] || + { TAPM_PULSE_FAIL "CPU limit must be zero or a positive whole number."; return 1; } + TAPM_PULSE_PROMPT swap "Swap in MiB" "$swap" + [[ "$swap" =~ ^[0-9]+$ ]] || + { TAPM_PULSE_FAIL "Swap must be zero or a positive whole number."; return 1; } + fi + + echo + default_bridge="$( + ip route 2>/dev/null | + awk '/^default/ { print $5; exit }' + )" + [[ -n "$default_bridge" ]] || default_bridge='vmbr0' + TAPM_PULSE_SELECT_BRIDGE bridge "$default_bridge" || return 1 TAPM_PULSE_PROMPT address_cidr \ "Static IPv4 address with prefix (leave blank for DHCP)" if [[ -n "$address_cidr" ]]; then @@ -222,6 +292,8 @@ TAPM_DEPLOY_PULSE_LXC() { TAPM_PULSE_VALID_IPV4_CIDR "${gateway}/32" || { TAPM_PULSE_FAIL "The IPv4 gateway is invalid."; return 1; } fi + TAPM_PULSE_PROMPT nameserver \ + "DNS servers, space-separated (leave blank to inherit host settings)" TAPM_PULSE_PROMPT vlan_id "VLAN ID (leave blank for untagged)" if [[ -n "$vlan_id" ]] && { [[ ! "$vlan_id" =~ ^[0-9]+$ ]] || (( vlan_id < 1 || vlan_id > 4094 )); }; then @@ -229,12 +301,26 @@ TAPM_DEPLOY_PULSE_LXC() { return 1 fi + TAPM_PULSE_CLUSTER_HA_ENABLED && add_ha='yes' + default_root_storage="$( pvesm status --content rootdir 2>/dev/null | awk 'NR > 1 && $3 == "active" { print $1; exit }' )" [[ -n "$default_root_storage" ]] || { TAPM_PULSE_FAIL "No active storage supports LXC volumes."; return 1; } + if [[ "$add_ha" == 'yes' ]]; then + while read -r choice; do + [[ -n "$choice" ]] || continue + if TAPM_PULSE_STORAGE_IS_SHARED "$choice"; then + default_root_storage="$choice" + break + fi + done < <( + pvesm status --content rootdir 2>/dev/null | + awk 'NR > 1 && $3 == "active" { print $1 }' + ) + fi TAPM_ISO_NFS_SELECT_STORAGE root_storage \ "Pulse root filesystem storage" "$default_root_storage" || return 1 @@ -246,10 +332,11 @@ TAPM_DEPLOY_PULSE_LXC() { { TAPM_PULSE_FAIL "No active storage supports container templates."; return 1; } template_storage="$default_root_storage" - if TAPM_PULSE_STORAGE_IS_SHARED "$root_storage" && - command -v ha-manager >/dev/null 2>&1; then - read -r -p " Add the Pulse LXC to Proxmox HA after installation? [Y/n] " choice - [[ ! "$choice" =~ ^[Nn]$ ]] && add_ha='yes' + if [[ "$add_ha" == 'yes' ]] && + ! TAPM_PULSE_STORAGE_IS_SHARED "$root_storage"; then + echo -e "${idsCL[LightYellow]}Warning: HA is active, but '${root_storage}' is not marked shared.${idsCL[Default]}" + echo " The LXC will still be added to HA as requested, but automatic failover" + echo " requires shared storage or separately configured storage replication." fi echo @@ -262,11 +349,13 @@ TAPM_DEPLOY_PULSE_LXC() { echo " Network: DHCP on ${bridge}" fi [[ -n "$vlan_id" ]] && echo " VLAN: ${vlan_id}" - echo " Resources: 2 vCPU, 2048 MiB RAM, 512 MiB swap, 100 CPU units" - echo " Root volume: ${root_storage}:8 GiB" + [[ -n "$nameserver" ]] && echo " DNS servers: ${nameserver}" + echo " Resources: ${cores} vCPU (limit ${cpulimit}), ${memory} MiB RAM, ${swap} MiB swap" + echo " Root volume: ${root_storage}:${disk} GiB" echo " Pulse port: ${pulse_port}" - echo " Start at boot: yes" - echo " Automatic update: enabled" + echo " Start at boot: yes, order ${startup}" + echo " Firewall: enabled" + echo " Automatic update: ${auto_updates}" echo " Proxmox HA: ${add_ha}" echo read -r -p " Create this Pulse container (type yes to continue)? " choice @@ -303,26 +392,30 @@ TAPM_DEPLOY_PULSE_LXC() { fi echo -e "\n${idsCL[LightCyan]}Locating a Debian container template...${idsCL[Default]}" - if ! pveam update; then - TAPM_CLEAN_TEMP_DIR "$temp_dir" - TAPM_PULSE_FAIL "Could not refresh the container template catalog." - return 1 - fi - template_name="$( - pveam available --section system | - awk '$2 ~ /^debian-(13|12)-standard_/ { print $2 }' | + template_path="$( + pveam list "$template_storage" 2>/dev/null | + awk 'NR > 1 && $1 ~ /:vztmpl\/debian-(13|12)-standard_/ { print $1 }' | sort -V | tail -1 )" - if [[ -z "$template_name" ]]; then - TAPM_CLEAN_TEMP_DIR "$temp_dir" - TAPM_PULSE_FAIL "No supported Debian 12/13 standard template was found." - return 1 - fi - template_path="${template_storage}:vztmpl/${template_name}" - if ! pveam list "$template_storage" 2>/dev/null | - awk 'NR > 1 { print $1 }' | - grep -Fxq -- "$template_path"; then + if [[ -z "$template_path" ]]; then + if ! pveam update; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + TAPM_PULSE_FAIL "Could not refresh the container template catalog." + return 1 + fi + template_name="$( + pveam available --section system | + awk '$2 ~ /^debian-(13|12)-standard_/ { print $2 }' | + sort -V | + tail -1 + )" + if [[ -z "$template_name" ]]; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + TAPM_PULSE_FAIL "No supported Debian 12/13 standard template was found." + return 1 + fi + template_path="${template_storage}:vztmpl/${template_name}" if ! pveam download "$template_storage" "$template_name"; then TAPM_CLEAN_TEMP_DIR "$temp_dir" TAPM_PULSE_FAIL "The Debian template download failed." @@ -331,27 +424,31 @@ TAPM_DEPLOY_PULSE_LXC() { fi if [[ -n "$address_cidr" ]]; then - network_config="name=eth0,bridge=${bridge},ip=${address_cidr},gw=${gateway},firewall=1,type=veth" + network_config="name=eth0,bridge=${bridge},ip=${address_cidr},gw=${gateway},firewall=${firewall},type=veth" else - network_config="name=eth0,bridge=${bridge},ip=dhcp,firewall=1,type=veth" + network_config="name=eth0,bridge=${bridge},ip=dhcp,firewall=${firewall},type=veth" fi [[ -n "$vlan_id" ]] && network_config+=",tag=${vlan_id}" echo -e "\n${idsCL[LightCyan]}Creating and starting LXC ${ctid}...${idsCL[Default]}" - if ! pct create "$ctid" "$template_path" \ - --hostname "$hostname" \ - --ostype debian \ - --unprivileged 1 \ - --features nesting=1 \ - --cores 2 \ - --cpunits 100 \ - --memory 2048 \ - --swap 512 \ - --rootfs "${root_storage}:8" \ - --net0 "$network_config" \ - --onboot 1 \ - --startup order=10 \ - --tags 'tapm;pulse'; then + create_args=( + pct create "$ctid" "$template_path" + --hostname "$hostname" + --ostype debian + --unprivileged "$unprivileged" + --features nesting=1 + --cores "$cores" + --memory "$memory" + --swap "$swap" + --rootfs "${root_storage}:${disk}" + --net0 "$network_config" + --onboot "$onboot" + --startup "order=${startup}" + --tags 'tapm;pulse' + ) + [[ "$cpulimit" != 0 ]] && create_args+=(--cpulimit "$cpulimit") + [[ -n "$nameserver" ]] && create_args+=(--nameserver "$nameserver") + if ! "${create_args[@]}"; then TAPM_CLEAN_TEMP_DIR "$temp_dir" TAPM_PULSE_FAIL "Container creation failed." return 1 @@ -379,7 +476,7 @@ TAPM_DEPLOY_PULSE_LXC() { --in-container \ --version "$release" \ --archive "/tmp/${archive_name}" \ - --enable-auto-updates || + "$auto_update_flag" || ! pct exec "$ctid" -- systemctl is-active --quiet pulse; then (( container_created == 1 )) && TAPM_PULSE_REMOVE_PARTIAL_LXC "$ctid" TAPM_CLEAN_TEMP_DIR "$temp_dir" @@ -391,6 +488,21 @@ TAPM_DEPLOY_PULSE_LXC() { pct exec "$ctid" -- hostname -I 2>/dev/null | awk '{ print $1; exit }' )" + + if [[ -n "$container_ip" ]]; then + echo -e "\n${idsCL[LightCyan]}Registering the Proxmox cluster with Pulse...${idsCL[Default]}" + ( + trap 'rm -f /tmp/pulse-auto-register-request.json /tmp/pulse-auto-register-response.json' EXIT + # Reuse the verified release's registration implementation so its + # API contract and least-privilege role handling stay in sync. + # shellcheck disable=SC1090 + source "$installer" + IN_CONTAINER=false + wait_for_pulse_ready "http://${container_ip}:${pulse_port}" 120 1 || true + auto_register_pve_node "$ctid" "$container_ip" "$pulse_port" + ) || echo -e "${idsCL[LightYellow]}Pulse is installed, but automatic Proxmox registration did not complete.${idsCL[Default]}" + fi + if [[ "$add_ha" == 'yes' ]] && ! ha-manager add "ct:${ctid}" --state started; then echo -e "${idsCL[LightYellow]}Pulse is running, but it could not be added to HA.${idsCL[Default]}" diff --git a/tests/test-pulse.sh b/tests/test-pulse.sh index adf1ece..6f44214 100644 --- a/tests/test-pulse.sh +++ b/tests/test-pulse.sh @@ -19,6 +19,10 @@ assert_success "valid Pulse CTID" TAPM_PULSE_VALID_CTID 210 assert_failure "invalid Pulse CTID" TAPM_PULSE_VALID_CTID 99 assert_success "valid Pulse hostname" TAPM_PULSE_VALID_HOSTNAME pulse-monitor assert_failure "invalid Pulse hostname" TAPM_PULSE_VALID_HOSTNAME 'pulse monitor' +assert_success "valid positive integer" TAPM_PULSE_VALID_POSITIVE_INTEGER 1024 +assert_failure "zero is not positive" TAPM_PULSE_VALID_POSITIVE_INTEGER 0 +assert_success "valid Pulse port" TAPM_PULSE_VALID_PORT 7655 +assert_failure "Pulse port too high" TAPM_PULSE_VALID_PORT 65536 assert_success "valid Pulse IPv4 CIDR" TAPM_PULSE_VALID_IPV4_CIDR 10.10.1.50/24 assert_failure "invalid Pulse IPv4 CIDR" TAPM_PULSE_VALID_IPV4_CIDR 10.10.1.500/24 @@ -32,4 +36,9 @@ assert_success "legacy Pulse hostname detected" \ assert_failure "unrelated resource not detected" \ TAPM_PULSE_RESOURCE_INSTALLED '[{"type":"qemu","name":"pulse"}]' +ha_status=$'quorum OK\nmaster pve1 (active, Sat Jul 25 12:00:00 2026)\nlrm pve1 (active, Sat Jul 25 12:00:00 2026)' +assert_success "active Proxmox HA detected" TAPM_PULSE_HA_STATUS_ENABLED "$ha_status" +assert_failure "inactive Proxmox HA rejected" \ + TAPM_PULSE_HA_STATUS_ENABLED $'quorum OK\nmaster pve1 (idle)' + finish_tests From f1ed0bb12d7a3a8f010857fa7d746ca0aad1f090 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 20:19:28 -0500 Subject: [PATCH 35/76] update --- defaults.inc | 2 +- inc/deploy-pulse-lxc.sh | 422 +++++++++++++++++++++++++++++++++++++++- tests/test-pulse.sh | 31 +++ 3 files changed, 446 insertions(+), 9 deletions(-) diff --git a/defaults.inc b/defaults.inc index f15bf34..48d55b3 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-47' +VERS='2026.7.25-48' noupdate=' ' diff --git a/inc/deploy-pulse-lxc.sh b/inc/deploy-pulse-lxc.sh index 133ec33..995c132 100644 --- a/inc/deploy-pulse-lxc.sh +++ b/inc/deploy-pulse-lxc.sh @@ -54,6 +54,65 @@ TAPM_PULSE_ARCH() { esac } +TAPM_PULSE_BOOTSTRAP_TOKEN_FROM_OUTPUT() { + local output="${1:-}" + + BOOTSTRAP_OUTPUT="$output" python3 -c ' +import os, re +match = re.search(r"Token:\s*([0-9a-fA-F]{48})(?:\s|$)", os.environ["BOOTSTRAP_OUTPUT"]) +if not match: + raise SystemExit(1) +print(match.group(1).lower()) +' 2>/dev/null +} + +TAPM_PULSE_TOKEN_FROM_RESPONSE() { + local response="${1:-}" + + TOKEN_RESPONSE="$response" python3 -c ' +import json, os, re +try: + token = json.loads(os.environ["TOKEN_RESPONSE"]).get("token", "") +except (AttributeError, TypeError, ValueError): + raise SystemExit(1) +if not isinstance(token, str) or not re.fullmatch(r"[0-9a-fA-F]+", token): + raise SystemExit(1) +print(token.lower()) +' 2>/dev/null +} + +TAPM_PULSE_ONLINE_NODES_FROM_JSON() { + local nodes_json="${1:-[]}" + + NODES_JSON="$nodes_json" python3 -c ' +import json, os +try: + nodes = json.loads(os.environ["NODES_JSON"]) +except (TypeError, ValueError): + raise SystemExit(1) +for item in sorted(nodes, key=lambda value: str(value.get("node", ""))): + node = str(item.get("node", "")).strip() + if item.get("status") == "online" and node: + print(node) +' 2>/dev/null +} + +TAPM_PULSE_OFFLINE_NODES_FROM_JSON() { + local nodes_json="${1:-[]}" + + NODES_JSON="$nodes_json" python3 -c ' +import json, os +try: + nodes = json.loads(os.environ["NODES_JSON"]) +except (TypeError, ValueError): + raise SystemExit(1) +for item in sorted(nodes, key=lambda value: str(value.get("node", ""))): + node = str(item.get("node", "")).strip() + if item.get("status") != "online" and node: + print(node) +' 2>/dev/null +} + TAPM_PULSE_RESOURCE_INSTALLED() { local resources_json="${1:-[]}" @@ -202,6 +261,323 @@ TAPM_PULSE_REMOVE_PARTIAL_LXC() { pct destroy "$ctid" --purge 1 >/dev/null 2>&1 || true } +TAPM_PULSE_CONFIGURE_SECURITY() { + local ctid="$1" + local pulse_url="$2" + local temp_dir="$3" + local username_variable="$4" + local password_variable="$5" + local token_variable="$6" + local bootstrap_output bootstrap_token generated_username generated_password generated_api_token + local request_file curl_config response security_ready='no' attempt + + generated_username='admin' + generated_password="Ta!9-$(openssl rand -hex 18)" || return 1 + generated_api_token="$(openssl rand -hex 32)" || return 1 + request_file="${temp_dir}/pulse-quick-setup.json" + curl_config="${temp_dir}/pulse-quick-setup.curl" + + bootstrap_output="$( + pct exec "$ctid" -- env PULSE_DATA_DIR=/etc/pulse \ + /usr/local/bin/pulse bootstrap-token 2>/dev/null + )" || { + TAPM_PULSE_FAIL "Pulse did not provide its first-run bootstrap token." + return 1 + } + bootstrap_token="$(TAPM_PULSE_BOOTSTRAP_TOKEN_FROM_OUTPUT "$bootstrap_output")" || { + unset bootstrap_output + TAPM_PULSE_FAIL "The Pulse bootstrap-token output could not be validated." + return 1 + } + unset bootstrap_output + + TAPM_PULSE_ADMIN_USER="$generated_username" \ + TAPM_PULSE_ADMIN_PASSWORD="$generated_password" \ + TAPM_PULSE_PRIMARY_TOKEN="$generated_api_token" \ + python3 -c ' +import json, os, sys +json.dump({ + "username": os.environ["TAPM_PULSE_ADMIN_USER"], + "password": os.environ["TAPM_PULSE_ADMIN_PASSWORD"], + "apiToken": os.environ["TAPM_PULSE_PRIMARY_TOKEN"], + "enableNotifications": False, + "darkMode": False, + "force": False, +}, sys.stdout) +' >"$request_file" || { + unset bootstrap_token generated_password generated_api_token + TAPM_PULSE_FAIL "Could not prepare the Pulse security configuration." + return 1 + } + chmod 0600 "$request_file" || return 1 + { + printf 'header = "Content-Type: application/json"\n' + printf 'header = "X-Setup-Token: %s"\n' "$bootstrap_token" + printf 'data-binary = "@%s"\n' "$request_file" + } >"$curl_config" || return 1 + chmod 0600 "$curl_config" || return 1 + + response="$( + curl --fail --silent --show-error \ + --request POST \ + --config "$curl_config" \ + "${pulse_url}/api/security/quick-setup" + )" || { + unset bootstrap_token generated_password generated_api_token + TAPM_PULSE_FAIL "Pulse rejected the automated first-time security setup." + return 1 + } + if ! SETUP_RESPONSE="$response" python3 -c ' +import json, os +try: + success = json.loads(os.environ["SETUP_RESPONSE"]).get("success") +except (AttributeError, TypeError, ValueError): + raise SystemExit(1) +raise SystemExit(0 if success is True else 1) +' 2>/dev/null; then + unset bootstrap_token generated_password generated_api_token response + TAPM_PULSE_FAIL "Pulse did not confirm that first-time setup completed." + return 1 + fi + unset bootstrap_token response + + { + printf 'header = "X-API-Token: %s"\n' "$generated_api_token" + } >"$curl_config" || return 1 + for (( attempt = 1; attempt <= 15; attempt++ )); do + if curl --fail --silent --show-error \ + --connect-timeout 3 --max-time 5 \ + --config "$curl_config" \ + "${pulse_url}/api/security/status" >/dev/null 2>&1; then + security_ready='yes' + break + fi + sleep 2 + done + if [[ "$security_ready" != 'yes' ]]; then + unset generated_password generated_api_token + TAPM_PULSE_FAIL "The generated Pulse administrator token could not be verified." + return 1 + fi + + printf -v "$username_variable" '%s' "$generated_username" + printf -v "$password_variable" '%s' "$generated_password" + printf -v "$token_variable" '%s' "$generated_api_token" + unset generated_password generated_api_token +} + +TAPM_PULSE_ISSUE_AGENT_TOKEN() { + local pulse_url="$1" + local primary_token="$2" + local node="$3" + local temp_dir="$4" + local request_file="${temp_dir}/agent-${node}.json" + local curl_config="${temp_dir}/agent-${node}.curl" + local response + + TAPM_PULSE_AGENT_NAME="tapm-pve-${node}" python3 -c ' +import json, os, sys +json.dump({ + "type": "host", + "name": os.environ["TAPM_PULSE_AGENT_NAME"], + "enableCommands": False, +}, sys.stdout) +' >"$request_file" || return 1 + chmod 0600 "$request_file" || return 1 + { + printf 'header = "Content-Type: application/json"\n' + printf 'header = "X-API-Token: %s"\n' "$primary_token" + printf 'data-binary = "@%s"\n' "$request_file" + } >"$curl_config" || return 1 + chmod 0600 "$curl_config" || return 1 + + response="$( + curl --fail --silent --show-error \ + --request POST \ + --config "$curl_config" \ + "${pulse_url}/api/agent-install-command" + )" || return 1 + TAPM_PULSE_TOKEN_FROM_RESPONSE "$response" +} + +TAPM_PULSE_INSTALL_AGENT_LOCAL() { + local pulse_url="$1" + local token="$2" + local token_file installer_file artifact + local status=0 + local -a old_artifacts=( + /usr/local/bin/pulse-agent + /var/lib/pulse-agent + /var/log/pulse-agent.log + /etc/systemd/system/pulse-agent.service + /etc/systemd/system/multi-user.target.wants/pulse-agent.service + ) + + token_file="$(mktemp /tmp/tapm-pulse-agent-token.XXXXXX)" || return 1 + installer_file="$(mktemp /tmp/tapm-pulse-agent-installer.XXXXXX)" || { + rm -f -- "$token_file" + return 1 + } + chmod 0600 "$token_file" "$installer_file" || { + rm -f -- "$token_file" "$installer_file" + return 1 + } + printf '%s' "$token" >"$token_file" || { + rm -f -- "$token_file" "$installer_file" + return 1 + } + curl --fail --silent --show-error --location \ + --output "$installer_file" "${pulse_url}/install.sh" || status=$? + if (( status == 0 )); then + echo " Removing any previous Pulse Unified Agent installation..." + bash "$installer_file" --uninstall --non-interactive || status=$? + fi + if (( status == 0 )) && systemctl is-active --quiet pulse-agent; then + echo " The previous pulse-agent service is still active." >&2 + status=1 + fi + if (( status == 0 )) && command -v pgrep >/dev/null 2>&1 && + pgrep -x pulse-agent >/dev/null 2>&1; then + echo " A previous pulse-agent process is still running." >&2 + status=1 + fi + if (( status == 0 )); then + for artifact in "${old_artifacts[@]}"; do + if [[ -e "$artifact" || -L "$artifact" ]]; then + echo " Previous Pulse agent artifact remains: ${artifact}" >&2 + status=1 + fi + done + fi + if (( status == 0 )); then + bash "$installer_file" \ + --url "$pulse_url" \ + --token-file "$token_file" \ + --enable-proxmox \ + --proxmox-type pve \ + --non-interactive \ + --insecure || status=$? + fi + rm -f -- "$token_file" "$installer_file" + (( status == 0 )) || return "$status" + systemctl is-active --quiet pulse-agent +} + +TAPM_PULSE_INSTALL_AGENT_REMOTE() { + local node="$1" + local pulse_url="$2" + local token="$3" + local remote_token_file + local -a ssh_args=( + ssh + -o BatchMode=yes + -o ConnectTimeout=10 + "root@${node}" + ) + + remote_token_file="$( + printf '%s' "$token" | + "${ssh_args[@]}" \ + 'umask 077; token_file=$(mktemp /tmp/tapm-pulse-agent-token.XXXXXX) || exit 1; cat >"$token_file" || exit 1; printf "%s\n" "$token_file"' | + tail -1 + )" || return 1 + [[ "$remote_token_file" =~ ^/tmp/tapm-pulse-agent-token\.[A-Za-z0-9]+$ ]] || + return 1 + + "${ssh_args[@]}" bash -s -- "$pulse_url" "$remote_token_file" <<'TAPM_PULSE_REMOTE_AGENT' +set -eu -o pipefail +pulse_url="$1" +token_file="$2" +installer_file="$(mktemp /tmp/tapm-pulse-agent-installer.XXXXXX)" +trap 'rm -f -- "$token_file" "$installer_file"' EXIT +chmod 0600 "$token_file" "$installer_file" +curl --fail --silent --show-error --location \ + --output "$installer_file" "${pulse_url}/install.sh" + +echo " Removing any previous Pulse Unified Agent installation..." +bash "$installer_file" --uninstall --non-interactive +if systemctl is-active --quiet pulse-agent; then + echo " The previous pulse-agent service is still active." >&2 + exit 1 +fi +if command -v pgrep >/dev/null 2>&1 && + pgrep -x pulse-agent >/dev/null 2>&1; then + echo " A previous pulse-agent process is still running." >&2 + exit 1 +fi +for artifact in \ + /usr/local/bin/pulse-agent \ + /var/lib/pulse-agent \ + /var/log/pulse-agent.log \ + /etc/systemd/system/pulse-agent.service \ + /etc/systemd/system/multi-user.target.wants/pulse-agent.service; do + if [[ -e "$artifact" || -L "$artifact" ]]; then + echo " Previous Pulse agent artifact remains: ${artifact}" >&2 + exit 1 + fi +done + +bash "$installer_file" \ + --url "$pulse_url" \ + --token-file "$token_file" \ + --enable-proxmox \ + --proxmox-type pve \ + --non-interactive \ + --insecure +systemctl is-active --quiet pulse-agent +TAPM_PULSE_REMOTE_AGENT +} + +TAPM_PULSE_DEPLOY_CLUSTER_AGENTS() { + local pulse_url="$1" + local primary_token="$2" + local temp_dir="$3" + local nodes_json node agent_token current_node + local installed=0 failed=0 + + nodes_json="$(pvesh get /nodes --output-format json 2>/dev/null)" || return 1 + current_node="$(hostname -s)" + while IFS= read -r node; do + [[ "$node" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ ]] || { + echo -e "${idsCL[LightYellow]}Skipping invalid cluster node name '${node}'.${idsCL[Default]}" + ((failed += 1)) + continue + } + echo -e "${idsCL[LightCyan]}Resetting and installing the Pulse Unified Agent on ${node}...${idsCL[Default]}" + agent_token="$( + TAPM_PULSE_ISSUE_AGENT_TOKEN \ + "$pulse_url" "$primary_token" "$node" "$temp_dir" + )" || { + echo -e "${idsCL[LightRed]}Could not create an enrollment token for ${node}.${idsCL[Default]}" + ((failed += 1)) + continue + } + + if [[ "$node" == "$current_node" || "$node" == "$(hostname)" ]]; then + TAPM_PULSE_INSTALL_AGENT_LOCAL "$pulse_url" "$agent_token" + else + TAPM_PULSE_INSTALL_AGENT_REMOTE "$node" "$pulse_url" "$agent_token" + fi + if (( $? == 0 )); then + echo -e "${idsCL[Green]}Pulse Unified Agent is active on ${node}.${idsCL[Default]}" + ((installed += 1)) + else + echo -e "${idsCL[LightRed]}Pulse Unified Agent installation failed on ${node}.${idsCL[Default]}" + ((failed += 1)) + fi + unset agent_token + done < <(TAPM_PULSE_ONLINE_NODES_FROM_JSON "$nodes_json") + while IFS= read -r node; do + [[ -n "$node" ]] || continue + echo -e "${idsCL[LightYellow]}Pulse agent installation skipped on offline node ${node}.${idsCL[Default]}" + ((failed += 1)) + done < <(TAPM_PULSE_OFFLINE_NODES_FROM_JSON "$nodes_json") + + TAPM_PULSE_AGENTS_INSTALLED="$installed" + TAPM_PULSE_AGENTS_FAILED="$failed" + (( installed > 0 || failed == 0 )) +} + TAPM_DEPLOY_PULSE_LXC() { local release="${PULSE_RELEASE:-v6.1.1}" local pulse_port="${PULSE_PORT:-7655}" auto_update_flag='--disable-auto-updates' @@ -210,7 +586,7 @@ TAPM_DEPLOY_PULSE_LXC() { local arch archive_name base_url installer archive signature installer_signature local memory disk cores cpulimit swap onboot firewall unprivileged nameserver startup local network_config choice add_ha='no' auto_updates='yes' container_ip timezone temp_dir - local default_bridge + local default_bridge pulse_url admin_username admin_password primary_api_token local container_created=0 local -a create_args=() @@ -235,7 +611,7 @@ TAPM_DEPLOY_PULSE_LXC() { [[ $EUID -eq 0 ]] || { TAPM_PULSE_FAIL "Run this action as root on a Proxmox VE host."; return 1; } - for command in pct pvesm pveam pvesh ssh-keygen python3; do + for command in pct pvesm pveam pvesh ssh-keygen python3 curl openssl ssh; do command -v "$command" >/dev/null 2>&1 || { TAPM_PULSE_FAIL "Required command '${command}' was not found."; return 1; } done @@ -490,6 +866,7 @@ TAPM_DEPLOY_PULSE_LXC() { )" if [[ -n "$container_ip" ]]; then + pulse_url="http://${container_ip}:${pulse_port}" echo -e "\n${idsCL[LightCyan]}Registering the Proxmox cluster with Pulse...${idsCL[Default]}" ( trap 'rm -f /tmp/pulse-auto-register-request.json /tmp/pulse-auto-register-response.json' EXIT @@ -498,7 +875,7 @@ TAPM_DEPLOY_PULSE_LXC() { # shellcheck disable=SC1090 source "$installer" IN_CONTAINER=false - wait_for_pulse_ready "http://${container_ip}:${pulse_port}" 120 1 || true + wait_for_pulse_ready "$pulse_url" 120 1 || true auto_register_pve_node "$ctid" "$container_ip" "$pulse_port" ) || echo -e "${idsCL[LightYellow]}Pulse is installed, but automatic Proxmox registration did not complete.${idsCL[Default]}" fi @@ -508,6 +885,33 @@ TAPM_DEPLOY_PULSE_LXC() { echo -e "${idsCL[LightYellow]}Pulse is running, but it could not be added to HA.${idsCL[Default]}" fi + if [[ -z "$container_ip" ]]; then + pct exec "$ctid" -- rm -f \ + /tmp/install.sh "/tmp/${archive_name}" "/tmp/${archive_name}.sshsig" || true + TAPM_CLEAN_TEMP_DIR "$temp_dir" + TAPM_PULSE_FAIL "Pulse is running, but its LXC address could not be determined. The LXC was kept." + return 1 + fi + + echo -e "\n${idsCL[LightCyan]}Configuring Pulse administrator security...${idsCL[Default]}" + if ! TAPM_PULSE_CONFIGURE_SECURITY \ + "$ctid" "$pulse_url" "$temp_dir" \ + admin_username admin_password primary_api_token; then + pct exec "$ctid" -- rm -f \ + /tmp/install.sh "/tmp/${archive_name}" "/tmp/${archive_name}.sshsig" || true + TAPM_CLEAN_TEMP_DIR "$temp_dir" + echo " The Pulse LXC was kept so setup can be recovered without reinstalling it." + echo " Run this on the Proxmox host to request a fresh setup token:" + echo " pct exec ${ctid} -- env PULSE_DATA_DIR=/etc/pulse /usr/local/bin/pulse bootstrap-token" + return 1 + fi + + echo -e "\n${idsCL[LightCyan]}Deploying clean Pulse Unified Agents to cluster nodes...${idsCL[Default]}" + TAPM_PULSE_AGENTS_INSTALLED=0 + TAPM_PULSE_AGENTS_FAILED=0 + TAPM_PULSE_DEPLOY_CLUSTER_AGENTS \ + "$pulse_url" "$primary_api_token" "$temp_dir" || true + pct exec "$ctid" -- rm -f \ /tmp/install.sh "/tmp/${archive_name}" "/tmp/${archive_name}.sshsig" || true TAPM_CLEAN_TEMP_DIR "$temp_dir" @@ -515,9 +919,11 @@ TAPM_DEPLOY_PULSE_LXC() { echo echo -e "${idsCL[Green]}Pulse ${release} was installed and its service is active.${idsCL[Default]}" - if [[ -n "$container_ip" ]]; then - echo -e " Open ${idsCL[LightCyan]}http://${container_ip}:${pulse_port}${idsCL[Default]} to finish setup." - else - echo " Open the Pulse LXC address on port ${pulse_port} to finish setup." - fi + echo -e " Open: ${idsCL[LightCyan]}${pulse_url}${idsCL[Default]}" + echo " Username: ${admin_username}" + echo " Password: ${admin_password}" + echo " API token: ${primary_api_token}" + echo " Agents: ${TAPM_PULSE_AGENTS_INSTALLED} installed, ${TAPM_PULSE_AGENTS_FAILED} failed or offline" + echo + echo -e "${idsCL[LightYellow]}Save the generated password and API token now; they are only shown once.${idsCL[Default]}" } diff --git a/tests/test-pulse.sh b/tests/test-pulse.sh index 6f44214..cb61d80 100644 --- a/tests/test-pulse.sh +++ b/tests/test-pulse.sh @@ -15,6 +15,37 @@ assert_equal amd64 "$(TAPM_PULSE_ARCH x86_64)" "x86 architecture mapping" assert_equal arm64 "$(TAPM_PULSE_ARCH aarch64)" "ARM architecture mapping" assert_failure "unsupported Pulse architecture" TAPM_PULSE_ARCH riscv64 +bootstrap_token='0123456789abcdef0123456789abcdef0123456789abcdef' +bootstrap_output="║ Token: ${bootstrap_token} ║" +assert_equal "$bootstrap_token" \ + "$(TAPM_PULSE_BOOTSTRAP_TOKEN_FROM_OUTPUT "$bootstrap_output")" \ + "Pulse bootstrap token parsed" +assert_failure "malformed Pulse bootstrap output rejected" \ + TAPM_PULSE_BOOTSTRAP_TOKEN_FROM_OUTPUT 'Token: not-a-token' + +agent_token='0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +assert_equal "$agent_token" \ + "$(TAPM_PULSE_TOKEN_FROM_RESPONSE "{\"token\":\"${agent_token}\"}")" \ + "Pulse agent token parsed" +assert_failure "missing Pulse agent token rejected" \ + TAPM_PULSE_TOKEN_FROM_RESPONSE '{"command":"install"}' +assert_failure "Pulse agent token containing whitespace rejected" \ + TAPM_PULSE_TOKEN_FROM_RESPONSE '{"token":"invalid token"}' +assert_failure "non-hex Pulse agent token rejected" \ + TAPM_PULSE_TOKEN_FROM_RESPONSE '{"token":"not-a-token"}' + +nodes_json='[ + {"node":"pve3","status":"offline"}, + {"node":"pve2","status":"online"}, + {"node":"pve1","status":"online"} +]' +assert_equal $'pve1\npve2' \ + "$(TAPM_PULSE_ONLINE_NODES_FROM_JSON "$nodes_json")" \ + "online Pulse nodes selected and sorted" +assert_equal 'pve3' \ + "$(TAPM_PULSE_OFFLINE_NODES_FROM_JSON "$nodes_json")" \ + "offline Pulse nodes reported" + assert_success "valid Pulse CTID" TAPM_PULSE_VALID_CTID 210 assert_failure "invalid Pulse CTID" TAPM_PULSE_VALID_CTID 99 assert_success "valid Pulse hostname" TAPM_PULSE_VALID_HOSTNAME pulse-monitor From bb44ca81f7d90479b2aba57f20e1b4cacf84df87 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 20:24:46 -0500 Subject: [PATCH 36/76] update pulse installer --- defaults.inc | 2 +- inc/deploy-pulse-lxc.sh | 31 +++++++++++++++++++++---------- tests/test-pulse.sh | 11 +++++++++++ 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/defaults.inc b/defaults.inc index 48d55b3..a85c975 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-48' +VERS='2026.7.25-49' noupdate=' ' diff --git a/inc/deploy-pulse-lxc.sh b/inc/deploy-pulse-lxc.sh index 995c132..029c5a3 100644 --- a/inc/deploy-pulse-lxc.sh +++ b/inc/deploy-pulse-lxc.sh @@ -152,18 +152,29 @@ TAPM_PULSE_FAIL() { return 1 } +TAPM_PULSE_SET_BRIDGE_FROM_SELECTION() { + local variable="$1" + local selection="${2:-}" + local selected_bridge + + [[ "$selection" == bridge:* ]] || return 1 + selected_bridge="${selection#bridge:}" + [[ -n "$selected_bridge" ]] || return 1 + printf -v "$variable" '%s' "$selected_bridge" +} + TAPM_PULSE_SELECT_BRIDGE() { local variable="$1" - local bridge default_bridge="${2:-vmbr0}" + local bridge_name default_bridge="${2:-vmbr0}" local default_found=0 local -a bridges=() local -a labels=() local -a values=() - while IFS= read -r bridge; do - [[ -n "$bridge" ]] || continue - bridges+=("$bridge") - [[ "$bridge" == "$default_bridge" ]] && default_found=1 + while IFS= read -r bridge_name; do + [[ -n "$bridge_name" ]] || continue + bridges+=("$bridge_name") + [[ "$bridge_name" == "$default_bridge" ]] && default_found=1 done < <( { for bridge_path in /sys/class/net/*/bridge; do @@ -182,15 +193,15 @@ TAPM_PULSE_SELECT_BRIDGE() { labels+=("${default_bridge} — default") values+=("bridge:${default_bridge}") fi - for bridge in "${bridges[@]}"; do - [[ $default_found == 1 && "$bridge" == "$default_bridge" ]] && continue - labels+=("$bridge") - values+=("bridge:${bridge}") + for bridge_name in "${bridges[@]}"; do + [[ $default_found == 1 && "$bridge_name" == "$default_bridge" ]] && continue + labels+=("$bridge_name") + values+=("bridge:${bridge_name}") done SELECT_MENU "Pulse network bridge" labels values case "$MENU_SELECTION" in - bridge:*) printf -v "$variable" '%s' "${MENU_SELECTION#bridge:}";; + bridge:*) TAPM_PULSE_SET_BRIDGE_FROM_SELECTION "$variable" "$MENU_SELECTION";; quit) EXIT1; exit 0;; *) return 1;; esac diff --git a/tests/test-pulse.sh b/tests/test-pulse.sh index cb61d80..3235392 100644 --- a/tests/test-pulse.sh +++ b/tests/test-pulse.sh @@ -15,6 +15,17 @@ assert_equal amd64 "$(TAPM_PULSE_ARCH x86_64)" "x86 architecture mapping" assert_equal arm64 "$(TAPM_PULSE_ARCH aarch64)" "ARM architecture mapping" assert_failure "unsupported Pulse architecture" TAPM_PULSE_ARCH riscv64 +test_bridge_selection_assignment() { + local bridge='' + + TAPM_PULSE_SET_BRIDGE_FROM_SELECTION bridge 'bridge:vmbr0' || return 1 + assert_equal vmbr0 "$bridge" "selected Pulse bridge assigned to caller" +} + +assert_success "Pulse bridge selection assignment" test_bridge_selection_assignment +assert_failure "empty Pulse bridge selection rejected" \ + TAPM_PULSE_SET_BRIDGE_FROM_SELECTION selected_bridge 'bridge:' + bootstrap_token='0123456789abcdef0123456789abcdef0123456789abcdef' bootstrap_output="║ Token: ${bootstrap_token} ║" assert_equal "$bootstrap_token" \ From 2b8b2c8f6f35bd0161c4058c3d44ede7e5fae274 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 21:09:23 -0500 Subject: [PATCH 37/76] update --- defaults.inc | 2 +- inc/deploy-pulse-lxc.sh | 155 ++++++++++++++++++++++++++++------------ tests/test-pulse.sh | 10 +++ 3 files changed, 120 insertions(+), 47 deletions(-) diff --git a/defaults.inc b/defaults.inc index a85c975..00ed7d8 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-49' +VERS='2026.7.25-50' noupdate=' ' diff --git a/inc/deploy-pulse-lxc.sh b/inc/deploy-pulse-lxc.sh index 029c5a3..eeed006 100644 --- a/inc/deploy-pulse-lxc.sh +++ b/inc/deploy-pulse-lxc.sh @@ -26,6 +26,10 @@ TAPM_PULSE_VALID_POSITIVE_INTEGER() { [[ "${1:-}" =~ ^[1-9][0-9]*$ ]] } +TAPM_PULSE_VALID_NONNEGATIVE_INTEGER() { + [[ "${1:-}" =~ ^[0-9]+$ ]] +} + TAPM_PULSE_VALID_PORT() { [[ "${1:-}" =~ ^[0-9]+$ ]] && (( 10#$1 >= 1 && 10#$1 <= 65535 )) } @@ -46,6 +50,23 @@ TAPM_PULSE_VALID_IPV4_CIDR() { done } +TAPM_PULSE_VALID_IPV4() { + local value="${1:-}" + + [[ "$value" != */* ]] && TAPM_PULSE_VALID_IPV4_CIDR "${value}/32" +} + +TAPM_PULSE_VALID_OPTIONAL_IPV4_CIDR() { + [[ -z "${1:-}" ]] || TAPM_PULSE_VALID_IPV4_CIDR "$1" +} + +TAPM_PULSE_VALID_OPTIONAL_VLAN() { + local value="${1:-}" + + [[ -z "$value" ]] || + { [[ "$value" =~ ^[0-9]+$ ]] && (( 10#$value >= 1 && 10#$value <= 4094 )); } +} + TAPM_PULSE_ARCH() { case "${1:-}" in x86_64|amd64) printf 'amd64\n';; @@ -147,11 +168,57 @@ TAPM_PULSE_PROMPT() { fi } +TAPM_PULSE_PROMPT_UNTIL_VALID() { + local variable="$1" + local label="$2" + local default_value="$3" + local validator="$4" + local error_message="$5" + local candidate + + while true; do + TAPM_PULSE_PROMPT candidate "$label" "$default_value" + if "$validator" "$candidate"; then + printf -v "$variable" '%s' "$candidate" + return 0 + fi + TAPM_PULSE_FAIL "$error_message" + done +} + TAPM_PULSE_FAIL() { echo -e "\n${idsCL[LightRed]}$1${idsCL[Default]}" return 1 } +TAPM_PULSE_CONFIRM_CREDENTIALS_SAVED() { + local pulse_url="$1" + local admin_username="$2" + local admin_password="$3" + local primary_api_token="$4" + local installed="$5" + local failed="$6" + local acknowledgement + + while true; do + echo + echo -e "${idsCL[LightYellow]}============================================================================${idsCL[Default]}" + echo -e "${idsCL[LightYellow]} IMPORTANT — SAVE THESE PULSE CREDENTIALS NOW${idsCL[Default]}" + echo -e "${idsCL[LightYellow]} They are generated for this installation and will not be displayed again.${idsCL[Default]}" + echo -e "${idsCL[LightYellow]}============================================================================${idsCL[Default]}" + echo -e " Pulse URL: ${idsCL[LightCyan]}${pulse_url}${idsCL[Default]}" + echo -e " Username: ${idsCL[White]}${admin_username}${idsCL[Default]}" + echo -e " Password: ${idsCL[LightGreen]}${admin_password}${idsCL[Default]}" + echo -e " API token: ${idsCL[LightGreen]}${primary_api_token}${idsCL[Default]}" + echo " Agents: ${installed} installed, ${failed} failed or offline" + echo -e "${idsCL[LightYellow]}============================================================================${idsCL[Default]}" + echo + read -r -p " Type saved after recording the password and API token: " acknowledgement + [[ "$acknowledgement" =~ ^[Ss][Aa][Vv][Ee][Dd]$ ]] && return 0 + echo -e "\n${idsCL[LightYellow]}The credentials remain above. Save them before continuing.${idsCL[Default]}" + done +} + TAPM_PULSE_SET_BRIDGE_FROM_SELECTION() { local variable="$1" local selection="${2:-}" @@ -632,35 +699,38 @@ TAPM_DEPLOY_PULSE_LXC() { { TAPM_PULSE_FAIL "Configured Pulse port '${pulse_port}' is invalid."; return 1; } default_ctid="$(pvesh get /cluster/nextid 2>/dev/null || true)" - TAPM_PULSE_PROMPT ctid "Container ID" "$default_ctid" - TAPM_PULSE_VALID_CTID "$ctid" || - { TAPM_PULSE_FAIL "The container ID is invalid."; return 1; } - if pct status "$ctid" >/dev/null 2>&1; then - TAPM_PULSE_FAIL "Container ${ctid} already exists; no changes were made." - return 1 - fi + while true; do + TAPM_PULSE_PROMPT_UNTIL_VALID ctid "Container ID" "$default_ctid" \ + TAPM_PULSE_VALID_CTID "The container ID must be a whole number of at least three digits." + if ! pct status "$ctid" >/dev/null 2>&1; then + break + fi + TAPM_PULSE_FAIL "Container ${ctid} already exists. Choose another container ID." + default_ctid="$(pvesh get /cluster/nextid 2>/dev/null || true)" + done - TAPM_PULSE_PROMPT hostname "Container hostname" "Pulse-Monitor" - TAPM_PULSE_VALID_HOSTNAME "$hostname" || - { TAPM_PULSE_FAIL "The hostname is invalid."; return 1; } + TAPM_PULSE_PROMPT_UNTIL_VALID hostname "Container hostname" "Pulse-Monitor" \ + TAPM_PULSE_VALID_HOSTNAME \ + "The hostname must contain only letters, numbers, periods, and hyphens." - read -r -p " Customize CPU, memory, disk, or swap? [y/N] " choice + while true; do + read -r -p " Customize CPU, memory, disk, or swap? [y/N] " choice + [[ -z "$choice" || "$choice" =~ ^[YyNn]$ ]] && break + TAPM_PULSE_FAIL "Enter y or n." + done if [[ "$choice" =~ ^[Yy]$ ]]; then - TAPM_PULSE_PROMPT memory "Memory in MiB" "$memory" - TAPM_PULSE_VALID_POSITIVE_INTEGER "$memory" || - { TAPM_PULSE_FAIL "Memory must be a positive whole number."; return 1; } - TAPM_PULSE_PROMPT disk "Root disk size in GiB" "$disk" - TAPM_PULSE_VALID_POSITIVE_INTEGER "$disk" || - { TAPM_PULSE_FAIL "Disk size must be a positive whole number."; return 1; } - TAPM_PULSE_PROMPT cores "CPU cores" "$cores" - TAPM_PULSE_VALID_POSITIVE_INTEGER "$cores" || - { TAPM_PULSE_FAIL "CPU cores must be a positive whole number."; return 1; } - TAPM_PULSE_PROMPT cpulimit "CPU limit (0 for unlimited)" "$cpulimit" - [[ "$cpulimit" =~ ^[0-9]+$ ]] || - { TAPM_PULSE_FAIL "CPU limit must be zero or a positive whole number."; return 1; } - TAPM_PULSE_PROMPT swap "Swap in MiB" "$swap" - [[ "$swap" =~ ^[0-9]+$ ]] || - { TAPM_PULSE_FAIL "Swap must be zero or a positive whole number."; return 1; } + TAPM_PULSE_PROMPT_UNTIL_VALID memory "Memory in MiB" "$memory" \ + TAPM_PULSE_VALID_POSITIVE_INTEGER "Memory must be a positive whole number." + TAPM_PULSE_PROMPT_UNTIL_VALID disk "Root disk size in GiB" "$disk" \ + TAPM_PULSE_VALID_POSITIVE_INTEGER "Disk size must be a positive whole number." + TAPM_PULSE_PROMPT_UNTIL_VALID cores "CPU cores" "$cores" \ + TAPM_PULSE_VALID_POSITIVE_INTEGER "CPU cores must be a positive whole number." + TAPM_PULSE_PROMPT_UNTIL_VALID cpulimit "CPU limit (0 for unlimited)" "$cpulimit" \ + TAPM_PULSE_VALID_NONNEGATIVE_INTEGER \ + "CPU limit must be zero or a positive whole number." + TAPM_PULSE_PROMPT_UNTIL_VALID swap "Swap in MiB" "$swap" \ + TAPM_PULSE_VALID_NONNEGATIVE_INTEGER \ + "Swap must be zero or a positive whole number." fi echo @@ -670,23 +740,20 @@ TAPM_DEPLOY_PULSE_LXC() { )" [[ -n "$default_bridge" ]] || default_bridge='vmbr0' TAPM_PULSE_SELECT_BRIDGE bridge "$default_bridge" || return 1 - TAPM_PULSE_PROMPT address_cidr \ - "Static IPv4 address with prefix (leave blank for DHCP)" + TAPM_PULSE_PROMPT_UNTIL_VALID address_cidr \ + "Static IPv4 address with prefix (leave blank for DHCP)" '' \ + TAPM_PULSE_VALID_OPTIONAL_IPV4_CIDR \ + "Enter a valid IPv4 CIDR such as 10.10.2.30/16, or leave it blank for DHCP." if [[ -n "$address_cidr" ]]; then - TAPM_PULSE_VALID_IPV4_CIDR "$address_cidr" || - { TAPM_PULSE_FAIL "The static IPv4 address is invalid."; return 1; } - TAPM_PULSE_PROMPT gateway "IPv4 gateway" - TAPM_PULSE_VALID_IPV4_CIDR "${gateway}/32" || - { TAPM_PULSE_FAIL "The IPv4 gateway is invalid."; return 1; } + TAPM_PULSE_PROMPT_UNTIL_VALID gateway "IPv4 gateway" '' \ + TAPM_PULSE_VALID_IPV4 "Enter a valid IPv4 gateway." fi TAPM_PULSE_PROMPT nameserver \ "DNS servers, space-separated (leave blank to inherit host settings)" - TAPM_PULSE_PROMPT vlan_id "VLAN ID (leave blank for untagged)" - if [[ -n "$vlan_id" ]] && - { [[ ! "$vlan_id" =~ ^[0-9]+$ ]] || (( vlan_id < 1 || vlan_id > 4094 )); }; then - TAPM_PULSE_FAIL "The VLAN ID must be between 1 and 4094." - return 1 - fi + TAPM_PULSE_PROMPT_UNTIL_VALID vlan_id \ + "VLAN ID (leave blank for untagged)" '' \ + TAPM_PULSE_VALID_OPTIONAL_VLAN \ + "The VLAN ID must be between 1 and 4094, or blank for untagged." TAPM_PULSE_CLUSTER_HA_ENABLED && add_ha='yes' @@ -930,11 +997,7 @@ TAPM_DEPLOY_PULSE_LXC() { echo echo -e "${idsCL[Green]}Pulse ${release} was installed and its service is active.${idsCL[Default]}" - echo -e " Open: ${idsCL[LightCyan]}${pulse_url}${idsCL[Default]}" - echo " Username: ${admin_username}" - echo " Password: ${admin_password}" - echo " API token: ${primary_api_token}" - echo " Agents: ${TAPM_PULSE_AGENTS_INSTALLED} installed, ${TAPM_PULSE_AGENTS_FAILED} failed or offline" - echo - echo -e "${idsCL[LightYellow]}Save the generated password and API token now; they are only shown once.${idsCL[Default]}" + TAPM_PULSE_CONFIRM_CREDENTIALS_SAVED \ + "$pulse_url" "$admin_username" "$admin_password" "$primary_api_token" \ + "$TAPM_PULSE_AGENTS_INSTALLED" "$TAPM_PULSE_AGENTS_FAILED" } diff --git a/tests/test-pulse.sh b/tests/test-pulse.sh index 3235392..5c7213b 100644 --- a/tests/test-pulse.sh +++ b/tests/test-pulse.sh @@ -63,10 +63,20 @@ assert_success "valid Pulse hostname" TAPM_PULSE_VALID_HOSTNAME pulse-monitor assert_failure "invalid Pulse hostname" TAPM_PULSE_VALID_HOSTNAME 'pulse monitor' assert_success "valid positive integer" TAPM_PULSE_VALID_POSITIVE_INTEGER 1024 assert_failure "zero is not positive" TAPM_PULSE_VALID_POSITIVE_INTEGER 0 +assert_success "zero is a valid nonnegative integer" \ + TAPM_PULSE_VALID_NONNEGATIVE_INTEGER 0 +assert_failure "negative integer rejected" TAPM_PULSE_VALID_NONNEGATIVE_INTEGER -1 assert_success "valid Pulse port" TAPM_PULSE_VALID_PORT 7655 assert_failure "Pulse port too high" TAPM_PULSE_VALID_PORT 65536 assert_success "valid Pulse IPv4 CIDR" TAPM_PULSE_VALID_IPV4_CIDR 10.10.1.50/24 assert_failure "invalid Pulse IPv4 CIDR" TAPM_PULSE_VALID_IPV4_CIDR 10.10.1.500/24 +assert_success "valid Pulse gateway" TAPM_PULSE_VALID_IPV4 10.10.0.1 +assert_failure "gateway CIDR rejected" TAPM_PULSE_VALID_IPV4 10.10.0.1/16 +assert_success "blank optional Pulse address accepted" \ + TAPM_PULSE_VALID_OPTIONAL_IPV4_CIDR '' +assert_success "valid Pulse VLAN" TAPM_PULSE_VALID_OPTIONAL_VLAN 4094 +assert_failure "Pulse VLAN zero rejected" TAPM_PULSE_VALID_OPTIONAL_VLAN 0 +assert_success "blank Pulse VLAN accepted" TAPM_PULSE_VALID_OPTIONAL_VLAN '' resources='[ {"type":"lxc","name":"pulse-a","tags":"tapm;pulse"}, From fd23ec525a459303242994b8425cdee444c9be6e Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 21:26:23 -0500 Subject: [PATCH 38/76] update --- defaults.inc | 2 +- inc/deploy-pulse-lxc.sh | 198 +++++++++++++++++++++++++++++++++------- tests/test-pulse.sh | 10 ++ 3 files changed, 178 insertions(+), 32 deletions(-) diff --git a/defaults.inc b/defaults.inc index 00ed7d8..606d485 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-50' +VERS='2026.7.25-51' noupdate=' ' diff --git a/inc/deploy-pulse-lxc.sh b/inc/deploy-pulse-lxc.sh index eeed006..0811d5a 100644 --- a/inc/deploy-pulse-lxc.sh +++ b/inc/deploy-pulse-lxc.sh @@ -102,6 +102,24 @@ print(token.lower()) ' 2>/dev/null } +TAPM_PULSE_AGENT_REGISTERED_FROM_RESPONSE() { + local response="${1:-}" + + AGENT_RESPONSE="$response" python3 -c ' +import json, os +try: + agent = json.loads(os.environ["AGENT_RESPONSE"]).get("agent", {}) +except (AttributeError, TypeError, ValueError): + raise SystemExit(1) +if not isinstance(agent, dict) or not str(agent.get("id", "")).strip(): + raise SystemExit(1) +' 2>/dev/null +} + +TAPM_PULSE_AGENT_TOKEN_REQUEST_JSON() { + printf '%s\n' '{"type":"pve","enableCommands":false}' +} + TAPM_PULSE_ONLINE_NODES_FROM_JSON() { local nodes_json="${1:-[]}" @@ -146,7 +164,8 @@ except (TypeError, ValueError): for item in resources: tags = str(item.get("tags", "")).split(";") if item.get("type") == "lxc" and ( - "pulse" in tags or str(item.get("name", "")).lower() == "pulse" + "pulse" in tags or + str(item.get("name", "")).lower() in {"pulse", "pulse-monitor"} ): raise SystemExit(0) raise SystemExit(1) @@ -198,6 +217,7 @@ TAPM_PULSE_CONFIRM_CREDENTIALS_SAVED() { local primary_api_token="$4" local installed="$5" local failed="$6" + local cluster_status="$7" local acknowledgement while true; do @@ -210,6 +230,7 @@ TAPM_PULSE_CONFIRM_CREDENTIALS_SAVED() { echo -e " Username: ${idsCL[White]}${admin_username}${idsCL[Default]}" echo -e " Password: ${idsCL[LightGreen]}${admin_password}${idsCL[Default]}" echo -e " API token: ${idsCL[LightGreen]}${primary_api_token}${idsCL[Default]}" + echo " Cluster: ${cluster_status}" echo " Agents: ${installed} installed, ${failed} failed or offline" echo -e "${idsCL[LightYellow]}============================================================================${idsCL[Default]}" echo @@ -444,6 +465,48 @@ raise SystemExit(0 if success is True else 1) unset generated_password generated_api_token } +TAPM_PULSE_REGISTER_CLUSTER() { + local ctid="$1" + local container_ip="$2" + local pulse_port="$3" + local installer="$4" + local primary_token="$5" + local temp_dir="$6" + local pulse_url="http://${container_ip}:${pulse_port}" + local curl_config="${temp_dir}/pulse-cluster-registration.curl" + + printf 'header = "X-API-Token: %s"\n' "$primary_token" >"$curl_config" || + return 1 + chmod 0600 "$curl_config" || return 1 + + ( + trap 'rm -f /tmp/pulse-auto-register-request.json /tmp/pulse-auto-register-response.json' EXIT + # Reuse the verified release's registration implementation so its + # Proxmox roles, token contract, and compatibility checks stay in sync. + # shellcheck disable=SC1090 + source "$installer" + IN_CONTAINER=false + + # Pulse v6.1.1 requires authentication for setup-token creation. Add + # the primary token only to that request; never send it to Proxmox. + curl() { + local curl_argument + + for curl_argument in "$@"; do + if [[ "$curl_argument" == "${pulse_url}/api/setup-script-url" ]]; then + command curl --config "$curl_config" "$@" + return + fi + done + command curl "$@" + } + + wait_for_pulse_ready "$pulse_url" 120 1 || return 1 + auto_register_pve_node "$ctid" "$container_ip" "$pulse_port" + [[ "${AUTO_NODE_REGISTERED:-false}" == true ]] + ) +} + TAPM_PULSE_ISSUE_AGENT_TOKEN() { local pulse_url="$1" local primary_token="$2" @@ -453,14 +516,7 @@ TAPM_PULSE_ISSUE_AGENT_TOKEN() { local curl_config="${temp_dir}/agent-${node}.curl" local response - TAPM_PULSE_AGENT_NAME="tapm-pve-${node}" python3 -c ' -import json, os, sys -json.dump({ - "type": "host", - "name": os.environ["TAPM_PULSE_AGENT_NAME"], - "enableCommands": False, -}, sys.stdout) -' >"$request_file" || return 1 + TAPM_PULSE_AGENT_TOKEN_REQUEST_JSON >"$request_file" || return 1 chmod 0600 "$request_file" || return 1 { printf 'header = "Content-Type: application/json"\n' @@ -478,10 +534,44 @@ json.dump({ TAPM_PULSE_TOKEN_FROM_RESPONSE "$response" } -TAPM_PULSE_INSTALL_AGENT_LOCAL() { +TAPM_PULSE_WAIT_AGENT_REGISTERED() { local pulse_url="$1" local token="$2" - local token_file installer_file artifact + local node="$3" + local temp_dir="$4" + local curl_config="${temp_dir}/agent-${node}-verify.curl" + local encoded_node response attempt + + printf 'header = "X-API-Token: %s"\n' "$token" >"$curl_config" || return 1 + chmod 0600 "$curl_config" || return 1 + encoded_node="$( + TAPM_PULSE_NODE_NAME="$node" python3 -c ' +import os, urllib.parse +print(urllib.parse.quote(os.environ["TAPM_PULSE_NODE_NAME"], safe="")) +' + )" || return 1 + + for (( attempt = 1; attempt <= 20; attempt++ )); do + response="$( + curl --fail --silent --show-error \ + --connect-timeout 3 --max-time 5 \ + --config "$curl_config" \ + "${pulse_url}/api/agents/agent/lookup?hostname=${encoded_node}" \ + 2>/dev/null + )" || response='' + if TAPM_PULSE_AGENT_REGISTERED_FROM_RESPONSE "$response"; then + return 0 + fi + sleep 3 + done + return 1 +} + +TAPM_PULSE_INSTALL_AGENT_LOCAL() { + local node="$1" + local pulse_url="$2" + local token="$3" + local token_file installer_file cleanup_state_dir artifact local status=0 local -a old_artifacts=( /usr/local/bin/pulse-agent @@ -496,19 +586,39 @@ TAPM_PULSE_INSTALL_AGENT_LOCAL() { rm -f -- "$token_file" return 1 } + cleanup_state_dir="$(mktemp -d /tmp/tapm-pulse-agent-cleanup.XXXXXX)" || { + rm -f -- "$token_file" "$installer_file" + return 1 + } chmod 0600 "$token_file" "$installer_file" || { rm -f -- "$token_file" "$installer_file" + rm -rf -- "$cleanup_state_dir" return 1 } printf '%s' "$token" >"$token_file" || { rm -f -- "$token_file" "$installer_file" + rm -rf -- "$cleanup_state_dir" return 1 } curl --fail --silent --show-error --location \ --output "$installer_file" "${pulse_url}/install.sh" || status=$? if (( status == 0 )); then echo " Removing any previous Pulse Unified Agent installation..." - bash "$installer_file" --uninstall --non-interactive || status=$? + # Remove its connection sources before invoking the supported cleanup + # routine. This is a replacement install, so contacting the retired + # Pulse server to unregister is unnecessary. + systemctl stop pulse-agent >/dev/null 2>&1 || true + systemctl disable pulse-agent >/dev/null 2>&1 || true + pkill -x pulse-agent >/dev/null 2>&1 || true + rm -f -- \ + /etc/systemd/system/pulse-agent.service \ + /etc/systemd/system/multi-user.target.wants/pulse-agent.service + systemctl daemon-reload >/dev/null 2>&1 || true + rm -rf -- /var/lib/pulse-agent + bash "$installer_file" \ + --uninstall \ + --non-interactive \ + --state-dir "$cleanup_state_dir" || status=$? fi if (( status == 0 )) && systemctl is-active --quiet pulse-agent; then echo " The previous pulse-agent service is still active." >&2 @@ -531,12 +641,14 @@ TAPM_PULSE_INSTALL_AGENT_LOCAL() { bash "$installer_file" \ --url "$pulse_url" \ --token-file "$token_file" \ + --hostname "$node" \ --enable-proxmox \ --proxmox-type pve \ --non-interactive \ --insecure || status=$? fi rm -f -- "$token_file" "$installer_file" + rm -rf -- "$cleanup_state_dir" (( status == 0 )) || return "$status" systemctl is-active --quiet pulse-agent } @@ -562,18 +674,34 @@ TAPM_PULSE_INSTALL_AGENT_REMOTE() { [[ "$remote_token_file" =~ ^/tmp/tapm-pulse-agent-token\.[A-Za-z0-9]+$ ]] || return 1 - "${ssh_args[@]}" bash -s -- "$pulse_url" "$remote_token_file" <<'TAPM_PULSE_REMOTE_AGENT' + "${ssh_args[@]}" bash -s -- \ + "$pulse_url" "$remote_token_file" "$node" <<'TAPM_PULSE_REMOTE_AGENT' set -eu -o pipefail pulse_url="$1" token_file="$2" +node="$3" installer_file="$(mktemp /tmp/tapm-pulse-agent-installer.XXXXXX)" -trap 'rm -f -- "$token_file" "$installer_file"' EXIT +cleanup_state_dir="$(mktemp -d /tmp/tapm-pulse-agent-cleanup.XXXXXX)" +trap 'rm -f -- "$token_file" "$installer_file"; rm -rf -- "$cleanup_state_dir"' EXIT chmod 0600 "$token_file" "$installer_file" curl --fail --silent --show-error --location \ --output "$installer_file" "${pulse_url}/install.sh" echo " Removing any previous Pulse Unified Agent installation..." -bash "$installer_file" --uninstall --non-interactive +# This is a replacement install. Remove the old connection sources first so +# the supported cleanup routine cannot contact the retired Pulse server. +systemctl stop pulse-agent >/dev/null 2>&1 || true +systemctl disable pulse-agent >/dev/null 2>&1 || true +pkill -x pulse-agent >/dev/null 2>&1 || true +rm -f -- \ + /etc/systemd/system/pulse-agent.service \ + /etc/systemd/system/multi-user.target.wants/pulse-agent.service +systemctl daemon-reload >/dev/null 2>&1 || true +rm -rf -- /var/lib/pulse-agent +bash "$installer_file" \ + --uninstall \ + --non-interactive \ + --state-dir "$cleanup_state_dir" if systemctl is-active --quiet pulse-agent; then echo " The previous pulse-agent service is still active." >&2 exit 1 @@ -598,6 +726,7 @@ done bash "$installer_file" \ --url "$pulse_url" \ --token-file "$token_file" \ + --hostname "$node" \ --enable-proxmox \ --proxmox-type pve \ --non-interactive \ @@ -632,13 +761,20 @@ TAPM_PULSE_DEPLOY_CLUSTER_AGENTS() { } if [[ "$node" == "$current_node" || "$node" == "$(hostname)" ]]; then - TAPM_PULSE_INSTALL_AGENT_LOCAL "$pulse_url" "$agent_token" + TAPM_PULSE_INSTALL_AGENT_LOCAL "$node" "$pulse_url" "$agent_token" else TAPM_PULSE_INSTALL_AGENT_REMOTE "$node" "$pulse_url" "$agent_token" fi if (( $? == 0 )); then - echo -e "${idsCL[Green]}Pulse Unified Agent is active on ${node}.${idsCL[Default]}" - ((installed += 1)) + echo " Waiting for Pulse to confirm registration from ${node}..." + if TAPM_PULSE_WAIT_AGENT_REGISTERED \ + "$pulse_url" "$agent_token" "$node" "$temp_dir"; then + echo -e "${idsCL[Green]}Pulse confirmed Unified Agent registration for ${node}.${idsCL[Default]}" + ((installed += 1)) + else + echo -e "${idsCL[LightRed]}pulse-agent is active on ${node}, but Pulse did not confirm its registration.${idsCL[Default]}" + ((failed += 1)) + fi else echo -e "${idsCL[LightRed]}Pulse Unified Agent installation failed on ${node}.${idsCL[Default]}" ((failed += 1)) @@ -665,6 +801,7 @@ TAPM_DEPLOY_PULSE_LXC() { local memory disk cores cpulimit swap onboot firewall unprivileged nameserver startup local network_config choice add_ha='no' auto_updates='yes' container_ip timezone temp_dir local default_bridge pulse_url admin_username admin_password primary_api_token + local cluster_status='Registration failed' local container_created=0 local -a create_args=() @@ -898,7 +1035,6 @@ TAPM_DEPLOY_PULSE_LXC() { --net0 "$network_config" --onboot "$onboot" --startup "order=${startup}" - --tags 'tapm;pulse' ) [[ "$cpulimit" != 0 ]] && create_args+=(--cpulimit "$cpulimit") [[ -n "$nameserver" ]] && create_args+=(--nameserver "$nameserver") @@ -945,17 +1081,6 @@ TAPM_DEPLOY_PULSE_LXC() { if [[ -n "$container_ip" ]]; then pulse_url="http://${container_ip}:${pulse_port}" - echo -e "\n${idsCL[LightCyan]}Registering the Proxmox cluster with Pulse...${idsCL[Default]}" - ( - trap 'rm -f /tmp/pulse-auto-register-request.json /tmp/pulse-auto-register-response.json' EXIT - # Reuse the verified release's registration implementation so its - # API contract and least-privilege role handling stay in sync. - # shellcheck disable=SC1090 - source "$installer" - IN_CONTAINER=false - wait_for_pulse_ready "$pulse_url" 120 1 || true - auto_register_pve_node "$ctid" "$container_ip" "$pulse_port" - ) || echo -e "${idsCL[LightYellow]}Pulse is installed, but automatic Proxmox registration did not complete.${idsCL[Default]}" fi if [[ "$add_ha" == 'yes' ]] && @@ -984,6 +1109,16 @@ TAPM_DEPLOY_PULSE_LXC() { return 1 fi + echo -e "\n${idsCL[LightCyan]}Registering the Proxmox cluster with authenticated Pulse access...${idsCL[Default]}" + if TAPM_PULSE_REGISTER_CLUSTER \ + "$ctid" "$container_ip" "$pulse_port" "$installer" \ + "$primary_api_token" "$temp_dir"; then + cluster_status='Registered' + echo -e "${idsCL[Green]}Pulse confirmed Proxmox cluster registration.${idsCL[Default]}" + else + echo -e "${idsCL[LightRed]}Pulse is secured, but Proxmox cluster registration did not complete.${idsCL[Default]}" + fi + echo -e "\n${idsCL[LightCyan]}Deploying clean Pulse Unified Agents to cluster nodes...${idsCL[Default]}" TAPM_PULSE_AGENTS_INSTALLED=0 TAPM_PULSE_AGENTS_FAILED=0 @@ -999,5 +1134,6 @@ TAPM_DEPLOY_PULSE_LXC() { echo -e "${idsCL[Green]}Pulse ${release} was installed and its service is active.${idsCL[Default]}" TAPM_PULSE_CONFIRM_CREDENTIALS_SAVED \ "$pulse_url" "$admin_username" "$admin_password" "$primary_api_token" \ - "$TAPM_PULSE_AGENTS_INSTALLED" "$TAPM_PULSE_AGENTS_FAILED" + "$TAPM_PULSE_AGENTS_INSTALLED" "$TAPM_PULSE_AGENTS_FAILED" \ + "$cluster_status" } diff --git a/tests/test-pulse.sh b/tests/test-pulse.sh index 5c7213b..a4b75c0 100644 --- a/tests/test-pulse.sh +++ b/tests/test-pulse.sh @@ -44,6 +44,14 @@ assert_failure "Pulse agent token containing whitespace rejected" \ TAPM_PULSE_TOKEN_FROM_RESPONSE '{"token":"invalid token"}' assert_failure "non-hex Pulse agent token rejected" \ TAPM_PULSE_TOKEN_FROM_RESPONSE '{"token":"not-a-token"}' +assert_success "registered Pulse agent response accepted" \ + TAPM_PULSE_AGENT_REGISTERED_FROM_RESPONSE \ + '{"agent":{"id":"agent-123","hostname":"pve1"}}' +assert_failure "missing Pulse agent ID rejected" \ + TAPM_PULSE_AGENT_REGISTERED_FROM_RESPONSE '{"agent":{"hostname":"pve1"}}' +assert_equal '{"type":"pve","enableCommands":false}' \ + "$(TAPM_PULSE_AGENT_TOKEN_REQUEST_JSON)" \ + "Proxmox-specific agent enrollment requested with commands disabled" nodes_json='[ {"node":"pve3","status":"offline"}, @@ -85,6 +93,8 @@ resources='[ assert_success "tagged Pulse LXC detected" TAPM_PULSE_RESOURCE_INSTALLED "$resources" assert_success "legacy Pulse hostname detected" \ TAPM_PULSE_RESOURCE_INSTALLED '[{"type":"lxc","name":"Pulse"}]' +assert_success "untagged default Pulse hostname detected" \ + TAPM_PULSE_RESOURCE_INSTALLED '[{"type":"lxc","name":"Pulse-Monitor"}]' assert_failure "unrelated resource not detected" \ TAPM_PULSE_RESOURCE_INSTALLED '[{"type":"qemu","name":"pulse"}]' From d8d27e596693ca19baf63770794d0c12bd22049c Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 21:35:13 -0500 Subject: [PATCH 39/76] update pulse installer --- defaults.inc | 2 +- inc/deploy-pulse-lxc.sh | 74 ++++++++++++++++++++++++++++++++--------- 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/defaults.inc b/defaults.inc index 606d485..8f05176 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-51' +VERS='2026.7.25-53' noupdate=' ' diff --git a/inc/deploy-pulse-lxc.sh b/inc/deploy-pulse-lxc.sh index 0811d5a..00d5019 100644 --- a/inc/deploy-pulse-lxc.sh +++ b/inc/deploy-pulse-lxc.sh @@ -215,9 +215,8 @@ TAPM_PULSE_CONFIRM_CREDENTIALS_SAVED() { local admin_username="$2" local admin_password="$3" local primary_api_token="$4" - local installed="$5" - local failed="$6" - local cluster_status="$7" + local cluster_status="$5" + local agent_status="$6" local acknowledgement while true; do @@ -231,7 +230,7 @@ TAPM_PULSE_CONFIRM_CREDENTIALS_SAVED() { echo -e " Password: ${idsCL[LightGreen]}${admin_password}${idsCL[Default]}" echo -e " API token: ${idsCL[LightGreen]}${primary_api_token}${idsCL[Default]}" echo " Cluster: ${cluster_status}" - echo " Agents: ${installed} installed, ${failed} failed or offline" + echo " Agents: ${agent_status}" echo -e "${idsCL[LightYellow]}============================================================================${idsCL[Default]}" echo read -r -p " Type saved after recording the password and API token: " acknowledgement @@ -356,10 +355,37 @@ TAPM_PULSE_REMOVE_PARTIAL_LXC() { local ctid="$1" echo -e "${idsCL[LightYellow]}Removing incomplete LXC ${ctid} created by this deployment...${idsCL[Default]}" + if command -v ha-manager >/dev/null 2>&1; then + ha-manager remove "ct:${ctid}" >/dev/null 2>&1 || true + fi pct stop "$ctid" --skiplock 1 >/dev/null 2>&1 || true pct destroy "$ctid" --purge 1 >/dev/null 2>&1 || true } +TAPM_PULSE_OFFER_FAILED_LXC_REMOVAL() { + local ctid="$1" + local choice + + while true; do + echo + read -r -p " Remove the incomplete Pulse LXC ${ctid} now? [Y/n] " choice + case "$choice" in + ''|[Yy]) + TAPM_PULSE_REMOVE_PARTIAL_LXC "$ctid" + echo -e "${idsCL[Green]}Incomplete Pulse LXC ${ctid} was removed.${idsCL[Default]}" + return 0 + ;; + [Nn]) + echo -e "${idsCL[LightYellow]}Pulse LXC ${ctid} was kept for troubleshooting.${idsCL[Default]}" + return 1 + ;; + *) + TAPM_PULSE_FAIL "Enter y or n." + ;; + esac + done +} + TAPM_PULSE_CONFIGURE_SECURITY() { local ctid="$1" local pulse_url="$2" @@ -486,6 +512,12 @@ TAPM_PULSE_REGISTER_CLUSTER() { # shellcheck disable=SC1090 source "$installer" IN_CONTAINER=false + # Pulse v6.1.1's installer omits backup_perms from its expected + # artifact URLs even when it requests backupPerms=true. That causes it + # to reject Pulse's otherwise valid response as "missing setup token." + # Use the internally consistent least-privilege request until a newer + # pinned Pulse release fixes that upstream contract mismatch. + PULSE_AUTO_BACKUP_PERMS=false # Pulse v6.1.1 requires authentication for setup-token creation. Add # the primary token only to that request; never send it to Proxmox. @@ -802,6 +834,7 @@ TAPM_DEPLOY_PULSE_LXC() { local network_config choice add_ha='no' auto_updates='yes' container_ip timezone temp_dir local default_bridge pulse_url admin_username admin_password primary_api_token local cluster_status='Registration failed' + local agent_status='Skipped because cluster registration failed' local container_created=0 local -a create_args=() @@ -1092,7 +1125,8 @@ TAPM_DEPLOY_PULSE_LXC() { pct exec "$ctid" -- rm -f \ /tmp/install.sh "/tmp/${archive_name}" "/tmp/${archive_name}.sshsig" || true TAPM_CLEAN_TEMP_DIR "$temp_dir" - TAPM_PULSE_FAIL "Pulse is running, but its LXC address could not be determined. The LXC was kept." + TAPM_PULSE_FAIL "Pulse is running, but its LXC address could not be determined." + TAPM_PULSE_OFFER_FAILED_LXC_REMOVAL "$ctid" || true return 1 fi @@ -1103,9 +1137,11 @@ TAPM_DEPLOY_PULSE_LXC() { pct exec "$ctid" -- rm -f \ /tmp/install.sh "/tmp/${archive_name}" "/tmp/${archive_name}.sshsig" || true TAPM_CLEAN_TEMP_DIR "$temp_dir" - echo " The Pulse LXC was kept so setup can be recovered without reinstalling it." - echo " Run this on the Proxmox host to request a fresh setup token:" - echo " pct exec ${ctid} -- env PULSE_DATA_DIR=/etc/pulse /usr/local/bin/pulse bootstrap-token" + if ! TAPM_PULSE_OFFER_FAILED_LXC_REMOVAL "$ctid"; then + echo " Setup can be recovered without reinstalling the retained LXC." + echo " Run this on the Proxmox host to request a fresh setup token:" + echo " pct exec ${ctid} -- env PULSE_DATA_DIR=/etc/pulse /usr/local/bin/pulse bootstrap-token" + fi return 1 fi @@ -1115,16 +1151,23 @@ TAPM_DEPLOY_PULSE_LXC() { "$primary_api_token" "$temp_dir"; then cluster_status='Registered' echo -e "${idsCL[Green]}Pulse confirmed Proxmox cluster registration.${idsCL[Default]}" + + echo -e "\n${idsCL[LightCyan]}Deploying clean Pulse Unified Agents to cluster nodes...${idsCL[Default]}" + TAPM_PULSE_AGENTS_INSTALLED=0 + TAPM_PULSE_AGENTS_FAILED=0 + TAPM_PULSE_DEPLOY_CLUSTER_AGENTS \ + "$pulse_url" "$primary_api_token" "$temp_dir" || true + agent_status="${TAPM_PULSE_AGENTS_INSTALLED} registered, ${TAPM_PULSE_AGENTS_FAILED} failed or offline" else echo -e "${idsCL[LightRed]}Pulse is secured, but Proxmox cluster registration did not complete.${idsCL[Default]}" + echo -e "${idsCL[LightYellow]}Skipping Unified Agent deployment until cluster registration succeeds.${idsCL[Default]}" + if TAPM_PULSE_OFFER_FAILED_LXC_REMOVAL "$ctid"; then + TAPM_CLEAN_TEMP_DIR "$temp_dir" + unset admin_password primary_api_token + return 1 + fi fi - echo -e "\n${idsCL[LightCyan]}Deploying clean Pulse Unified Agents to cluster nodes...${idsCL[Default]}" - TAPM_PULSE_AGENTS_INSTALLED=0 - TAPM_PULSE_AGENTS_FAILED=0 - TAPM_PULSE_DEPLOY_CLUSTER_AGENTS \ - "$pulse_url" "$primary_api_token" "$temp_dir" || true - pct exec "$ctid" -- rm -f \ /tmp/install.sh "/tmp/${archive_name}" "/tmp/${archive_name}.sshsig" || true TAPM_CLEAN_TEMP_DIR "$temp_dir" @@ -1134,6 +1177,5 @@ TAPM_DEPLOY_PULSE_LXC() { echo -e "${idsCL[Green]}Pulse ${release} was installed and its service is active.${idsCL[Default]}" TAPM_PULSE_CONFIRM_CREDENTIALS_SAVED \ "$pulse_url" "$admin_username" "$admin_password" "$primary_api_token" \ - "$TAPM_PULSE_AGENTS_INSTALLED" "$TAPM_PULSE_AGENTS_FAILED" \ - "$cluster_status" + "$cluster_status" "$agent_status" } From 854309c3a164a3252bc3d9069acc5c0c3d2d026f Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 22:02:07 -0500 Subject: [PATCH 40/76] update pulse installer --- defaults.inc | 2 +- inc/deploy-pulse-lxc.sh | 77 ++++++++++++++++++++++++++++++++++++++++- tests/test-pulse.sh | 39 +++++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/defaults.inc b/defaults.inc index 8f05176..91c628c 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-53' +VERS='2026.7.25-54' noupdate=' ' diff --git a/inc/deploy-pulse-lxc.sh b/inc/deploy-pulse-lxc.sh index 00d5019..c7dc9d3 100644 --- a/inc/deploy-pulse-lxc.sh +++ b/inc/deploy-pulse-lxc.sh @@ -491,6 +491,76 @@ raise SystemExit(0 if success is True else 1) unset generated_password generated_api_token } +TAPM_PULSE_NORMALIZE_V611_SETUP_ARTIFACT() { + local response="$1" + local pulse_url="$2" + local expected_host="${3:-}" + + PULSE_SETUP_RESPONSE="$response" \ + PULSE_SETUP_URL="$pulse_url" \ + PULSE_SETUP_HOST="$expected_host" \ + python3 -c ' +import json +import os +import re +import sys +import time +from urllib.parse import quote + +try: + data = json.loads(os.environ["PULSE_SETUP_RESPONSE"]) +except (TypeError, ValueError): + raise SystemExit("Pulse returned invalid setup-token JSON") + +token = str(data.get("setupToken", "")).strip() +returned_host = str(data.get("host", "")).strip() +host = os.environ["PULSE_SETUP_HOST"].strip() or returned_host +expires = data.get("expires", 0) +if not re.fullmatch(r"[0-9a-fA-F]{32,128}", token): + raise SystemExit("Pulse returned no valid setup token") +if data.get("type") != "pve" or not returned_host or not host: + raise SystemExit("Pulse returned an invalid PVE setup artifact") +try: + if int(expires) <= int(time.time()): + raise SystemExit("Pulse returned an expired setup token") +except (TypeError, ValueError): + raise SystemExit("Pulse returned an invalid setup-token expiry") + +pulse_url = os.environ["PULSE_SETUP_URL"].rstrip("/") +empty = "" +expected_url = ( + f"{pulse_url}/api/setup-script?" + f"host={quote(host, safe=empty)}&" + f"pulse_url={quote(pulse_url, safe=empty)}&type=pve" +) +expected_download_url = ( + f"{pulse_url}/api/setup-script?" + f"host={quote(host, safe=empty)}&" + f"pulse_url={quote(pulse_url, safe=empty)}&" + f"setup_token={quote(token, safe=empty)}&type=pve" +) + +# Pulse v6.1.1 rejects its own valid artifact if the server selected a +# different public base URL or included an optional query parameter. These +# presentation fields are not executed by auto_register_pve_node; align them +# with the helper contract while preserving the server-issued token. +old_url = str(data.get("url", "")) +if not old_url: + raise SystemExit("Pulse returned no setup-script URL") +for field in ("command", "commandWithEnv", "commandWithoutEnv"): + value = str(data.get(field, "")) + if not value or old_url not in value: + raise SystemExit(f"Pulse returned an invalid {field} field") + data[field] = value.replace(old_url, expected_url) + +data["url"] = expected_url +data["downloadURL"] = expected_download_url +data["host"] = host +data["scriptFileName"] = "pulse-setup-pve.sh" +json.dump(data, sys.stdout, separators=(",", ":")) +' +} + TAPM_PULSE_REGISTER_CLUSTER() { local ctid="$1" local container_ip="$2" @@ -523,10 +593,15 @@ TAPM_PULSE_REGISTER_CLUSTER() { # the primary token only to that request; never send it to Proxmox. curl() { local curl_argument + local setup_response for curl_argument in "$@"; do if [[ "$curl_argument" == "${pulse_url}/api/setup-script-url" ]]; then - command curl --config "$curl_config" "$@" + setup_response="$(command curl --config "$curl_config" "$@")" || + return 1 + TAPM_PULSE_NORMALIZE_V611_SETUP_ARTIFACT \ + "$setup_response" "$pulse_url" \ + "${normalized_host_url:-}" return fi done diff --git a/tests/test-pulse.sh b/tests/test-pulse.sh index a4b75c0..dce502c 100644 --- a/tests/test-pulse.sh +++ b/tests/test-pulse.sh @@ -53,6 +53,45 @@ assert_equal '{"type":"pve","enableCommands":false}' \ "$(TAPM_PULSE_AGENT_TOKEN_REQUEST_JSON)" \ "Proxmox-specific agent enrollment requested with commands disabled" +setup_token='0123456789abcdef0123456789abcdef' +setup_host='https://pve1.example.test:8006' +setup_old_url='https://pulse.example.test/api/setup-script?backup_perms=true' +setup_response="$( + SETUP_TOKEN="$setup_token" SETUP_HOST="$setup_host" SETUP_OLD_URL="$setup_old_url" \ + python3 -c ' +import json, os, sys, time +old_url = os.environ["SETUP_OLD_URL"] +token = os.environ["SETUP_TOKEN"] +json.dump({ + "type": "pve", + "host": os.environ["SETUP_HOST"], + "url": old_url, + "downloadURL": old_url + "&setup_token=" + token, + "scriptFileName": "pulse-setup-pve.sh", + "command": old_url + " PULSE_SETUP_TOKEN=" + token + " if", + "commandWithEnv": old_url + " PULSE_SETUP_TOKEN=" + token + " if", + "commandWithoutEnv": old_url + " if", + "expires": int(time.time()) + 300, + "setupToken": token, + "tokenHint": token[:3] + "..." + token[-3:], +}, sys.stdout) +' +)" +normalized_setup="$( + TAPM_PULSE_NORMALIZE_V611_SETUP_ARTIFACT \ + "$setup_response" 'http://10.10.2.30:7655' +)" +assert_equal "$setup_token" \ + "$(SETUP_RESPONSE="$normalized_setup" python3 -c 'import json, os; print(json.loads(os.environ["SETUP_RESPONSE"])["setupToken"])')" \ + "Pulse v6.1.1 compatibility preserves server-issued setup token" +assert_equal \ + 'http://10.10.2.30:7655/api/setup-script?host=https%3A%2F%2Fpve1.example.test%3A8006&pulse_url=http%3A%2F%2F10.10.2.30%3A7655&type=pve' \ + "$(SETUP_RESPONSE="$normalized_setup" python3 -c 'import json, os; print(json.loads(os.environ["SETUP_RESPONSE"])["url"])')" \ + "Pulse v6.1.1 compatibility normalizes setup artifact URL" +assert_failure "Pulse v6.1.1 compatibility rejects missing setup token" \ + TAPM_PULSE_NORMALIZE_V611_SETUP_ARTIFACT \ + '{"type":"pve","host":"https://pve1:8006"}' 'http://10.10.2.30:7655' + nodes_json='[ {"node":"pve3","status":"offline"}, {"node":"pve2","status":"online"}, From 55babe1ba21affe8a120b5f04a9078dfe0f170da Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 22:05:50 -0500 Subject: [PATCH 41/76] update --- defaults.inc | 2 +- inc/deploy-pulse-lxc.sh | 41 +++++++++++++++++++++++++++++++++++++++++ tests/test-pulse.sh | 22 ++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/defaults.inc b/defaults.inc index 91c628c..9d713e2 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-54' +VERS='2026.7.25-55' noupdate=' ' diff --git a/inc/deploy-pulse-lxc.sh b/inc/deploy-pulse-lxc.sh index c7dc9d3..fe75d30 100644 --- a/inc/deploy-pulse-lxc.sh +++ b/inc/deploy-pulse-lxc.sh @@ -561,6 +561,41 @@ json.dump(data, sys.stdout, separators=(",", ":")) ' } +TAPM_PULSE_CREATE_PVE_AUTO_REGISTER_TOKEN() { + local token_name="$1" + local output_variable="$2" + local status_variable="$3" + local command_output='' + local command_status=0 + + if command_output="$( + pveum user token add pulse-monitor@pve "$token_name" \ + --privsep 1 --output-format json 2>&1 + )"; then + command_status=0 + else + command_status=$? + fi + + # Older pveum versions reject --output-format and require table parsing. + if (( command_status != 0 )) && + grep -Eqi \ + 'unknown option|unknown command|no such option|unable to parse option|output-format' \ + <<<"$command_output"; then + if command_output="$( + pveum user token add pulse-monitor@pve "$token_name" \ + --privsep 1 2>&1 + )"; then + command_status=0 + else + command_status=$? + fi + fi + + printf -v "$output_variable" '%s' "$command_output" + printf -v "$status_variable" '%s' "$command_status" +} + TAPM_PULSE_REGISTER_CLUSTER() { local ctid="$1" local container_ip="$2" @@ -582,6 +617,12 @@ TAPM_PULSE_REGISTER_CLUSTER() { # shellcheck disable=SC1090 source "$installer" IN_CONTAINER=false + # Pulse v6.1.1's helper shadows the caller's token_output and + # token_status variables, discarding a successful pveum result. + # Override only that helper so auto_register_pve_node receives them. + create_pve_auto_register_token() { + TAPM_PULSE_CREATE_PVE_AUTO_REGISTER_TOKEN "$@" + } # Pulse v6.1.1's installer omits backup_perms from its expected # artifact URLs even when it requests backupPerms=true. That causes it # to reject Pulse's otherwise valid response as "missing setup token." diff --git a/tests/test-pulse.sh b/tests/test-pulse.sh index dce502c..1b8d691 100644 --- a/tests/test-pulse.sh +++ b/tests/test-pulse.sh @@ -92,6 +92,28 @@ assert_failure "Pulse v6.1.1 compatibility rejects missing setup token" \ TAPM_PULSE_NORMALIZE_V611_SETUP_ARTIFACT \ '{"type":"pve","host":"https://pve1:8006"}' 'http://10.10.2.30:7655' +test_pve_token_output_assignment() { + local token_output='' + local token_status=99 + + pveum() { + printf '%s\n' \ + '{"full-tokenid":"pulse-monitor@pve!pulse-test","value":"secret-value"}' + } + TAPM_PULSE_CREATE_PVE_AUTO_REGISTER_TOKEN \ + 'pulse-test' token_output token_status || return 1 + unset -f pveum + + assert_equal \ + '{"full-tokenid":"pulse-monitor@pve!pulse-test","value":"secret-value"}' \ + "$token_output" \ + "Pulse receives pveum token output" + assert_equal 0 "$token_status" "Pulse receives pveum token status" +} + +assert_success "Pulse v6.1.1 pveum output-shadowing workaround" \ + test_pve_token_output_assignment + nodes_json='[ {"node":"pve3","status":"offline"}, {"node":"pve2","status":"online"}, From eb88282cb116a43e7e2e783e6af98435d6b18a09 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 22:26:10 -0500 Subject: [PATCH 42/76] update --- defaults.inc | 2 +- inc/deploy-pulse-lxc.sh | 98 +++++++++++++++++++++++++++++++++++++---- tests/test-pulse.sh | 17 ++++++- 3 files changed, 105 insertions(+), 12 deletions(-) diff --git a/defaults.inc b/defaults.inc index 9d713e2..a4b2310 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-55' +VERS='2026.7.25-57' noupdate=' ' diff --git a/inc/deploy-pulse-lxc.sh b/inc/deploy-pulse-lxc.sh index fe75d30..8bacd18 100644 --- a/inc/deploy-pulse-lxc.sh +++ b/inc/deploy-pulse-lxc.sh @@ -117,7 +117,7 @@ if not isinstance(agent, dict) or not str(agent.get("id", "")).strip(): } TAPM_PULSE_AGENT_TOKEN_REQUEST_JSON() { - printf '%s\n' '{"type":"pve","enableCommands":false}' + printf '%s\n' '{"type":"host","enableCommands":false}' } TAPM_PULSE_ONLINE_NODES_FROM_JSON() { @@ -205,6 +205,58 @@ TAPM_PULSE_PROMPT_UNTIL_VALID() { done } +TAPM_PULSE_VALID_ADMIN_PASSWORD() { + local password="${1:-}" + + TAPM_PULSE_PASSWORD="$password" python3 -c ' +import os +password = os.environ["TAPM_PULSE_PASSWORD"] +raise SystemExit( + 0 if len(password) >= 12 and len(password.encode("utf-8")) <= 72 else 1 +) +' 2>/dev/null +} + +TAPM_PULSE_SELECT_ADMIN_PASSWORD() { + local output_variable="$1" + local mode_variable="$2" + local selection candidate confirmation + local -a labels=( + "Generate a strong random password (recommended)" + "Enter a custom password" + ) + local -a values=("generated" "custom") + + SELECT_MENU "Pulse administrator password" labels values 0 + selection="$MENU_SELECTION" + if [[ "$selection" == 'generated' ]]; then + printf -v "$output_variable" '%s' '' + printf -v "$mode_variable" '%s' 'Generated' + return 0 + fi + + while true; do + echo + read -r -s -p " Enter custom Pulse admin password (12+ characters): " candidate + echo + if ! TAPM_PULSE_VALID_ADMIN_PASSWORD "$candidate"; then + TAPM_PULSE_FAIL \ + "The password must be at least 12 characters and no more than 72 bytes." + continue + fi + read -r -s -p " Confirm custom Pulse admin password: " confirmation + echo + if [[ "$candidate" != "$confirmation" ]]; then + TAPM_PULSE_FAIL "The passwords did not match. Please try again." + continue + fi + printf -v "$output_variable" '%s' "$candidate" + printf -v "$mode_variable" '%s' 'Custom' + unset candidate confirmation + return 0 + done +} + TAPM_PULSE_FAIL() { echo -e "\n${idsCL[LightRed]}$1${idsCL[Default]}" return 1 @@ -223,7 +275,7 @@ TAPM_PULSE_CONFIRM_CREDENTIALS_SAVED() { echo echo -e "${idsCL[LightYellow]}============================================================================${idsCL[Default]}" echo -e "${idsCL[LightYellow]} IMPORTANT — SAVE THESE PULSE CREDENTIALS NOW${idsCL[Default]}" - echo -e "${idsCL[LightYellow]} They are generated for this installation and will not be displayed again.${idsCL[Default]}" + echo -e "${idsCL[LightYellow]} They will not be displayed again by this installer.${idsCL[Default]}" echo -e "${idsCL[LightYellow]}============================================================================${idsCL[Default]}" echo -e " Pulse URL: ${idsCL[LightCyan]}${pulse_url}${idsCL[Default]}" echo -e " Username: ${idsCL[White]}${admin_username}${idsCL[Default]}" @@ -393,11 +445,16 @@ TAPM_PULSE_CONFIGURE_SECURITY() { local username_variable="$4" local password_variable="$5" local token_variable="$6" + local requested_password="${7:-}" local bootstrap_output bootstrap_token generated_username generated_password generated_api_token local request_file curl_config response security_ready='no' attempt generated_username='admin' - generated_password="Ta!9-$(openssl rand -hex 18)" || return 1 + if [[ -n "$requested_password" ]]; then + generated_password="$requested_password" + else + generated_password="Ta!9-$(openssl rand -hex 18)" || return 1 + fi generated_api_token="$(openssl rand -hex 32)" || return 1 request_file="${temp_dir}/pulse-quick-setup.json" curl_config="${temp_dir}/pulse-quick-setup.curl" @@ -786,12 +843,19 @@ TAPM_PULSE_INSTALL_AGENT_LOCAL() { done fi if (( status == 0 )); then + if ! command -v sensors >/dev/null 2>&1; then + echo " Installing lm-sensors for Pulse host temperature telemetry..." + if ! DEBIAN_FRONTEND=noninteractive apt-get install -y \ + --no-install-recommends lm-sensors; then + echo " lm-sensors could not be installed; Pulse will continue without host temperature telemetry." >&2 + fi + fi bash "$installer_file" \ --url "$pulse_url" \ --token-file "$token_file" \ --hostname "$node" \ - --enable-proxmox \ - --proxmox-type pve \ + --enable-host \ + --disable-proxmox \ --non-interactive \ --insecure || status=$? fi @@ -871,12 +935,20 @@ for artifact in \ fi done +if ! command -v sensors >/dev/null 2>&1; then + echo " Installing lm-sensors for Pulse host temperature telemetry..." + if ! DEBIAN_FRONTEND=noninteractive apt-get install -y \ + --no-install-recommends lm-sensors; then + echo " lm-sensors could not be installed; Pulse will continue without host temperature telemetry." >&2 + fi +fi + bash "$installer_file" \ --url "$pulse_url" \ --token-file "$token_file" \ --hostname "$node" \ - --enable-proxmox \ - --proxmox-type pve \ + --enable-host \ + --disable-proxmox \ --non-interactive \ --insecure systemctl is-active --quiet pulse-agent @@ -948,7 +1020,8 @@ TAPM_DEPLOY_PULSE_LXC() { local arch archive_name base_url installer archive signature installer_signature local memory disk cores cpulimit swap onboot firewall unprivileged nameserver startup local network_config choice add_ha='no' auto_updates='yes' container_ip timezone temp_dir - local default_bridge pulse_url admin_username admin_password primary_api_token + local default_bridge pulse_url admin_username admin_password admin_password_mode + local primary_api_token local cluster_status='Registration failed' local agent_status='Skipped because cluster registration failed' local container_created=0 @@ -1064,6 +1137,11 @@ TAPM_DEPLOY_PULSE_LXC() { TAPM_ISO_NFS_SELECT_STORAGE root_storage \ "Pulse root filesystem storage" "$default_root_storage" || return 1 + admin_password='' + admin_password_mode='Generated' + TAPM_PULSE_SELECT_ADMIN_PASSWORD \ + admin_password admin_password_mode || return 1 + default_root_storage="$( pvesm status --content vztmpl 2>/dev/null | awk 'NR > 1 && $3 == "active" { print $1; exit }' @@ -1097,6 +1175,7 @@ TAPM_DEPLOY_PULSE_LXC() { echo " Firewall: enabled" echo " Automatic update: ${auto_updates}" echo " Proxmox HA: ${add_ha}" + echo " Admin password: ${admin_password_mode}" echo read -r -p " Create this Pulse container (type yes to continue)? " choice [[ "$choice" =~ ^[Yy][Ee][Ss]$ ]] || { @@ -1249,7 +1328,8 @@ TAPM_DEPLOY_PULSE_LXC() { echo -e "\n${idsCL[LightCyan]}Configuring Pulse administrator security...${idsCL[Default]}" if ! TAPM_PULSE_CONFIGURE_SECURITY \ "$ctid" "$pulse_url" "$temp_dir" \ - admin_username admin_password primary_api_token; then + admin_username admin_password primary_api_token \ + "$admin_password"; then pct exec "$ctid" -- rm -f \ /tmp/install.sh "/tmp/${archive_name}" "/tmp/${archive_name}.sshsig" || true TAPM_CLEAN_TEMP_DIR "$temp_dir" diff --git a/tests/test-pulse.sh b/tests/test-pulse.sh index 1b8d691..f524940 100644 --- a/tests/test-pulse.sh +++ b/tests/test-pulse.sh @@ -49,9 +49,9 @@ assert_success "registered Pulse agent response accepted" \ '{"agent":{"id":"agent-123","hostname":"pve1"}}' assert_failure "missing Pulse agent ID rejected" \ TAPM_PULSE_AGENT_REGISTERED_FROM_RESPONSE '{"agent":{"hostname":"pve1"}}' -assert_equal '{"type":"pve","enableCommands":false}' \ +assert_equal '{"type":"host","enableCommands":false}' \ "$(TAPM_PULSE_AGENT_TOKEN_REQUEST_JSON)" \ - "Proxmox-specific agent enrollment requested with commands disabled" + "host telemetry enrollment requested with commands disabled" setup_token='0123456789abcdef0123456789abcdef' setup_host='https://pve1.example.test:8006' @@ -147,6 +147,19 @@ assert_success "valid Pulse VLAN" TAPM_PULSE_VALID_OPTIONAL_VLAN 4094 assert_failure "Pulse VLAN zero rejected" TAPM_PULSE_VALID_OPTIONAL_VLAN 0 assert_success "blank Pulse VLAN accepted" TAPM_PULSE_VALID_OPTIONAL_VLAN '' +assert_success "12-character Pulse admin password accepted" \ + TAPM_PULSE_VALID_ADMIN_PASSWORD '123456789012' +assert_success "72-byte Pulse admin password accepted" \ + TAPM_PULSE_VALID_ADMIN_PASSWORD \ + '123456789012345678901234567890123456789012345678901234567890123456789012' +assert_failure "short Pulse admin password rejected" \ + TAPM_PULSE_VALID_ADMIN_PASSWORD '12345678901' +assert_failure "multibyte Pulse admin password still requires 12 characters" \ + TAPM_PULSE_VALID_ADMIN_PASSWORD 'éééééé' +assert_failure "Pulse admin password above bcrypt limit rejected" \ + TAPM_PULSE_VALID_ADMIN_PASSWORD \ + '1234567890123456789012345678901234567890123456789012345678901234567890123' + resources='[ {"type":"lxc","name":"pulse-a","tags":"tapm;pulse"}, {"type":"qemu","name":"unrelated"} From 6b5cbe928dccf888b813a0841fa06e478a904763 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 22:45:52 -0500 Subject: [PATCH 43/76] update --- defaults.inc | 2 +- inc/deploy-pulse-lxc.sh | 15 +++++++++++---- tests/test-pulse.sh | 4 ++-- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/defaults.inc b/defaults.inc index a4b2310..7e679b3 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-57' +VERS='2026.7.25-58' noupdate=' ' diff --git a/inc/deploy-pulse-lxc.sh b/inc/deploy-pulse-lxc.sh index 8bacd18..68906f8 100644 --- a/inc/deploy-pulse-lxc.sh +++ b/inc/deploy-pulse-lxc.sh @@ -117,7 +117,7 @@ if not isinstance(agent, dict) or not str(agent.get("id", "")).strip(): } TAPM_PULSE_AGENT_TOKEN_REQUEST_JSON() { - printf '%s\n' '{"type":"host","enableCommands":false}' + printf '%s\n' '{"type":"pve","enableCommands":true}' } TAPM_PULSE_ONLINE_NODES_FROM_JSON() { @@ -756,7 +756,10 @@ print(urllib.parse.quote(os.environ["TAPM_PULSE_NODE_NAME"], safe="")) ' )" || return 1 - for (( attempt = 1; attempt <= 20; attempt++ )); do + # Pulse v6.1.1 completes its Proxmox setup before sending the first host + # report. Its built-in registration retries can span at least 135 seconds, + # so allow up to four minutes before treating the active agent as unconfirmed. + for (( attempt = 1; attempt <= 80; attempt++ )); do response="$( curl --fail --silent --show-error \ --connect-timeout 3 --max-time 5 \ @@ -855,7 +858,9 @@ TAPM_PULSE_INSTALL_AGENT_LOCAL() { --token-file "$token_file" \ --hostname "$node" \ --enable-host \ - --disable-proxmox \ + --enable-proxmox \ + --proxmox-type pve \ + --enable-commands \ --non-interactive \ --insecure || status=$? fi @@ -948,7 +953,9 @@ bash "$installer_file" \ --token-file "$token_file" \ --hostname "$node" \ --enable-host \ - --disable-proxmox \ + --enable-proxmox \ + --proxmox-type pve \ + --enable-commands \ --non-interactive \ --insecure systemctl is-active --quiet pulse-agent diff --git a/tests/test-pulse.sh b/tests/test-pulse.sh index f524940..f8dbb03 100644 --- a/tests/test-pulse.sh +++ b/tests/test-pulse.sh @@ -49,9 +49,9 @@ assert_success "registered Pulse agent response accepted" \ '{"agent":{"id":"agent-123","hostname":"pve1"}}' assert_failure "missing Pulse agent ID rejected" \ TAPM_PULSE_AGENT_REGISTERED_FROM_RESPONSE '{"agent":{"hostname":"pve1"}}' -assert_equal '{"type":"host","enableCommands":false}' \ +assert_equal '{"type":"pve","enableCommands":true}' \ "$(TAPM_PULSE_AGENT_TOKEN_REQUEST_JSON)" \ - "host telemetry enrollment requested with commands disabled" + "Pulse PVE unified-agent enrollment requested with commands enabled" setup_token='0123456789abcdef0123456789abcdef' setup_host='https://pve1.example.test:8006' From e0214033ec68ce9a1b86cf7f4c12fb0db7cba4e9 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 23:04:29 -0500 Subject: [PATCH 44/76] update pulse installer, removing host registration --- defaults.inc | 2 +- inc/deploy-pulse-lxc.sh | 78 +++++++++++++++++++++++------------------ tests/test-pulse.sh | 13 +++++++ 3 files changed, 58 insertions(+), 35 deletions(-) diff --git a/defaults.inc b/defaults.inc index 7e679b3..220c5b2 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-58' +VERS='2026.7.25-60' noupdate=' ' diff --git a/inc/deploy-pulse-lxc.sh b/inc/deploy-pulse-lxc.sh index 68906f8..1d6bc3e 100644 --- a/inc/deploy-pulse-lxc.sh +++ b/inc/deploy-pulse-lxc.sh @@ -18,6 +18,36 @@ TAPM_PULSE_VALID_CTID() { [[ "${1:-}" =~ ^[1-9][0-9]{2,8}$ ]] } +TAPM_PULSE_FIRST_AVAILABLE_CTID_FROM_RESOURCES() { + local resources_json="${1:-[]}" + local starting_id="${2:-200}" + + RESOURCES_JSON="$resources_json" STARTING_ID="$starting_id" python3 -c ' +import json, os +try: + resources = json.loads(os.environ["RESOURCES_JSON"]) + candidate = int(os.environ["STARTING_ID"]) +except (TypeError, ValueError): + raise SystemExit(1) +if not isinstance(resources, list) or candidate < 100 or candidate > 999999999: + raise SystemExit(1) +used = set() +for resource in resources: + if not isinstance(resource, dict): + continue + try: + vmid = int(resource.get("vmid")) + except (TypeError, ValueError): + continue + used.add(vmid) +while candidate in used and candidate <= 999999999: + candidate += 1 +if candidate > 999999999: + raise SystemExit(1) +print(candidate) +' 2>/dev/null +} + TAPM_PULSE_VALID_HOSTNAME() { [[ "${1:-}" =~ ^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$ ]] } @@ -267,8 +297,6 @@ TAPM_PULSE_CONFIRM_CREDENTIALS_SAVED() { local admin_username="$2" local admin_password="$3" local primary_api_token="$4" - local cluster_status="$5" - local agent_status="$6" local acknowledgement while true; do @@ -281,8 +309,6 @@ TAPM_PULSE_CONFIRM_CREDENTIALS_SAVED() { echo -e " Username: ${idsCL[White]}${admin_username}${idsCL[Default]}" echo -e " Password: ${idsCL[LightGreen]}${admin_password}${idsCL[Default]}" echo -e " API token: ${idsCL[LightGreen]}${primary_api_token}${idsCL[Default]}" - echo " Cluster: ${cluster_status}" - echo " Agents: ${agent_status}" echo -e "${idsCL[LightYellow]}============================================================================${idsCL[Default]}" echo read -r -p " Type saved after recording the password and API token: " acknowledgement @@ -1022,15 +1048,13 @@ TAPM_PULSE_DEPLOY_CLUSTER_AGENTS() { TAPM_DEPLOY_PULSE_LXC() { local release="${PULSE_RELEASE:-v6.1.1}" local pulse_port="${PULSE_PORT:-7655}" auto_update_flag='--disable-auto-updates' - local ctid default_ctid hostname bridge address_cidr gateway vlan_id + local ctid default_ctid cluster_resources hostname bridge address_cidr gateway vlan_id local root_storage default_root_storage template_storage template_name template_path local arch archive_name base_url installer archive signature installer_signature local memory disk cores cpulimit swap onboot firewall unprivileged nameserver startup local network_config choice add_ha='no' auto_updates='yes' container_ip timezone temp_dir local default_bridge pulse_url admin_username admin_password admin_password_mode local primary_api_token - local cluster_status='Registration failed' - local agent_status='Skipped because cluster registration failed' local container_created=0 local -a create_args=() @@ -1064,7 +1088,16 @@ TAPM_DEPLOY_PULSE_LXC() { TAPM_PULSE_VALID_PORT "$pulse_port" || { TAPM_PULSE_FAIL "Configured Pulse port '${pulse_port}' is invalid."; return 1; } - default_ctid="$(pvesh get /cluster/nextid 2>/dev/null || true)" + default_ctid="$( + cluster_resources="$( + pvesh get /cluster/resources --type vm --output-format json 2>/dev/null + )" && + TAPM_PULSE_FIRST_AVAILABLE_CTID_FROM_RESOURCES \ + "$cluster_resources" 200 + )" || default_ctid='' + if [[ -z "$default_ctid" ]]; then + default_ctid="$(pvesh get /cluster/nextid 2>/dev/null || true)" + fi while true; do TAPM_PULSE_PROMPT_UNTIL_VALID ctid "Container ID" "$default_ctid" \ TAPM_PULSE_VALID_CTID "The container ID must be a whole number of at least three digits." @@ -1072,7 +1105,7 @@ TAPM_DEPLOY_PULSE_LXC() { break fi TAPM_PULSE_FAIL "Container ${ctid} already exists. Choose another container ID." - default_ctid="$(pvesh get /cluster/nextid 2>/dev/null || true)" + default_ctid="$((ctid + 1))" done TAPM_PULSE_PROMPT_UNTIL_VALID hostname "Container hostname" "Pulse-Monitor" \ @@ -1348,29 +1381,6 @@ TAPM_DEPLOY_PULSE_LXC() { return 1 fi - echo -e "\n${idsCL[LightCyan]}Registering the Proxmox cluster with authenticated Pulse access...${idsCL[Default]}" - if TAPM_PULSE_REGISTER_CLUSTER \ - "$ctid" "$container_ip" "$pulse_port" "$installer" \ - "$primary_api_token" "$temp_dir"; then - cluster_status='Registered' - echo -e "${idsCL[Green]}Pulse confirmed Proxmox cluster registration.${idsCL[Default]}" - - echo -e "\n${idsCL[LightCyan]}Deploying clean Pulse Unified Agents to cluster nodes...${idsCL[Default]}" - TAPM_PULSE_AGENTS_INSTALLED=0 - TAPM_PULSE_AGENTS_FAILED=0 - TAPM_PULSE_DEPLOY_CLUSTER_AGENTS \ - "$pulse_url" "$primary_api_token" "$temp_dir" || true - agent_status="${TAPM_PULSE_AGENTS_INSTALLED} registered, ${TAPM_PULSE_AGENTS_FAILED} failed or offline" - else - echo -e "${idsCL[LightRed]}Pulse is secured, but Proxmox cluster registration did not complete.${idsCL[Default]}" - echo -e "${idsCL[LightYellow]}Skipping Unified Agent deployment until cluster registration succeeds.${idsCL[Default]}" - if TAPM_PULSE_OFFER_FAILED_LXC_REMOVAL "$ctid"; then - TAPM_CLEAN_TEMP_DIR "$temp_dir" - unset admin_password primary_api_token - return 1 - fi - fi - pct exec "$ctid" -- rm -f \ /tmp/install.sh "/tmp/${archive_name}" "/tmp/${archive_name}.sshsig" || true TAPM_CLEAN_TEMP_DIR "$temp_dir" @@ -1378,7 +1388,7 @@ TAPM_DEPLOY_PULSE_LXC() { echo echo -e "${idsCL[Green]}Pulse ${release} was installed and its service is active.${idsCL[Default]}" + echo -e "${idsCL[LightCyan]}Add Proxmox inventory and host telemetry from the Pulse web interface.${idsCL[Default]}" TAPM_PULSE_CONFIRM_CREDENTIALS_SAVED \ - "$pulse_url" "$admin_username" "$admin_password" "$primary_api_token" \ - "$cluster_status" "$agent_status" + "$pulse_url" "$admin_username" "$admin_password" "$primary_api_token" } diff --git a/tests/test-pulse.sh b/tests/test-pulse.sh index f8dbb03..68705d4 100644 --- a/tests/test-pulse.sh +++ b/tests/test-pulse.sh @@ -128,6 +128,19 @@ assert_equal 'pve3' \ assert_success "valid Pulse CTID" TAPM_PULSE_VALID_CTID 210 assert_failure "invalid Pulse CTID" TAPM_PULSE_VALID_CTID 99 +ctid_resources='[ + {"type":"qemu","vmid":200}, + {"type":"lxc","vmid":"201"}, + {"type":"storage","storage":"local"} +]' +assert_equal 202 \ + "$(TAPM_PULSE_FIRST_AVAILABLE_CTID_FROM_RESOURCES "$ctid_resources" 200)" \ + "first available Pulse CTID starts at 200 and skips occupied IDs" +assert_equal 200 \ + "$(TAPM_PULSE_FIRST_AVAILABLE_CTID_FROM_RESOURCES '[]' 200)" \ + "Pulse CTID 200 selected when available" +assert_failure "invalid Pulse CTID resource data rejected" \ + TAPM_PULSE_FIRST_AVAILABLE_CTID_FROM_RESOURCES '{}' 200 assert_success "valid Pulse hostname" TAPM_PULSE_VALID_HOSTNAME pulse-monitor assert_failure "invalid Pulse hostname" TAPM_PULSE_VALID_HOSTNAME 'pulse monitor' assert_success "valid positive integer" TAPM_PULSE_VALID_POSITIVE_INTEGER 1024 From 70f5c09cecf6268aabb7ca549b8882bf03706e3e Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 23:29:44 -0500 Subject: [PATCH 45/76] update --- defaults.inc | 2 +- proxmenu-scripts.sh | 40 +++++++++++++++++++++++++++++++++------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/defaults.inc b/defaults.inc index 220c5b2..83b56fd 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-60' +VERS='2026.7.25-61' noupdate=' ' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 35c3f12..3777f0a 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -1218,17 +1218,43 @@ for guest in json.load(sys.stdin): TAPM_QEMU_CPU_MODEL() { local vmid="$1" + local node="$2" local config_output local cpu_model - config_output="$(qm config "$vmid" 2>/dev/null)" || return 1 + [[ "$vmid" =~ ^[1-9][0-9]{2,8}$ ]] || return 1 + [[ "$node" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ ]] || return 1 + config_output="$( + pvesh get "/nodes/${node}/qemu/${vmid}/config" \ + --output-format json 2>/dev/null + )" || return 1 cpu_model="$( - awk -F': ' '$1 == "cpu" { print $2; exit }' <<< "$config_output" - )" + QEMU_CONFIG_JSON="$config_output" python3 -c ' +import json, os +try: + config = json.loads(os.environ["QEMU_CONFIG_JSON"]) +except (TypeError, ValueError): + raise SystemExit(1) +cpu = config.get("cpu", "kvm64") +print(cpu if isinstance(cpu, str) and cpu.strip() else "kvm64") +' 2>/dev/null + )" || return 1 [[ -n "$cpu_model" ]] || cpu_model='kvm64' printf '%s\n' "$cpu_model" } +TAPM_SET_QEMU_CPU_MODEL() { + local vmid="$1" + local node="$2" + local cpu_model="$3" + + [[ "$vmid" =~ ^[1-9][0-9]{2,8}$ ]] || return 1 + [[ "$node" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ ]] || return 1 + [[ "$cpu_model" =~ ^x86-64-v(1|2-AES|3|4)$ ]] || return 1 + pvesh set "/nodes/${node}/qemu/${vmid}/config" \ + --cpu "$cpu_model" +} + DETECT_CPU() { local answer local cpu_model @@ -1243,7 +1269,7 @@ DETECT_CPU() { local -a failures=() local -a successes=() - for command in apt-get curl gpg install pvesh python3 qm; do + for command in apt-get curl gpg install pvesh python3; do if ! command -v "$command" >/dev/null 2>&1; then echo -e "${idsCL[LightRed]}${command} is required for CPU compatibility detection.${idsCL[Default]}" FINISH_FAILED_ACTION @@ -1283,7 +1309,7 @@ DETECT_CPU() { while IFS=$'\x1f' read -r vmid name node template; do [[ -n "$vmid" ]] || continue - current_cpu="$(TAPM_QEMU_CPU_MODEL "$vmid")" || { + current_cpu="$(TAPM_QEMU_CPU_MODEL "$vmid" "$node")" || { echo -e "${idsCL[LightRed]}Could not read the CPU configuration for VMID ${vmid}.${idsCL[Default]}" FINISH_FAILED_ACTION return @@ -1319,8 +1345,8 @@ DETECT_CPU() { for guest in "${changes[@]}"; do IFS=$'\x1f' read -r vmid name node template current_cpu <<< "$guest" - if qm set "$vmid" --cpu "$cpu_model" && - [[ "$(TAPM_QEMU_CPU_MODEL "$vmid")" == "$cpu_model" ]]; then + if TAPM_SET_QEMU_CPU_MODEL "$vmid" "$node" "$cpu_model" && + [[ "$(TAPM_QEMU_CPU_MODEL "$vmid" "$node")" == "$cpu_model" ]]; then successes+=("$guest") else failures+=("$guest") From baa7846a15b4fb1d8f5a06119f94b2482db639ec Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 23:38:23 -0500 Subject: [PATCH 46/76] update cpu checks --- defaults.inc | 2 +- inc/deploy-pulse-lxc.sh | 25 ++++++++++++++++++++++ proxmenu-scripts.sh | 46 +++++++++++++++++++++++++++++++++-------- tests/test-pulse.sh | 20 ++++++++++++++++++ 4 files changed, 83 insertions(+), 10 deletions(-) diff --git a/defaults.inc b/defaults.inc index 83b56fd..e39ee32 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-61' +VERS='2026.7.25-63' noupdate=' ' diff --git a/inc/deploy-pulse-lxc.sh b/inc/deploy-pulse-lxc.sh index 1d6bc3e..842c227 100644 --- a/inc/deploy-pulse-lxc.sh +++ b/inc/deploy-pulse-lxc.sh @@ -202,6 +202,31 @@ raise SystemExit(1) ' 2>/dev/null } +TAPM_PULSE_RESOURCE_INSTALLED_FROM_CONFIGS() { + local nodes_directory="${1:-/etc/pve/nodes}" + local config hostname tags + local -a configs + + [[ -d "$nodes_directory" ]] || return 2 + configs=("$nodes_directory"/*/lxc/*.conf) + for config in "${configs[@]}"; do + [[ -f "$config" ]] || continue + hostname="$( + awk -F':[[:space:]]*' '$1 == "hostname" { print $2; exit }' "$config" + )" + tags="$( + awk -F':[[:space:]]*' '$1 == "tags" { print $2; exit }' "$config" + )" + case "$hostname" in + [Pp][Uu][Ll][Ss][Ee]|[Pp][Uu][Ll][Ss][Ee]-[Mm][Oo][Nn][Ii][Tt][Oo][Rr]) + return 0 + ;; + esac + [[ ";${tags};" == *";pulse;"* ]] && return 0 + done + return 1 +} + TAPM_PULSE_PROMPT() { local variable="$1" local label="$2" diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 3777f0a..f2ba36a 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -1219,11 +1219,30 @@ for guest in json.load(sys.stdin): TAPM_QEMU_CPU_MODEL() { local vmid="$1" local node="$2" + local config_file local config_output local cpu_model [[ "$vmid" =~ ^[1-9][0-9]{2,8}$ ]] || return 1 [[ "$node" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ ]] || return 1 + config_file="/etc/pve/nodes/${node}/qemu-server/${vmid}.conf" + if [[ -r "$config_file" ]]; then + cpu_model="$( + awk ' +$1 == "cpu:" { + sub(/^[^:]*:[[:space:]]*/, "") + print + exit +} +' "$config_file" + )" || return 1 + [[ -n "$cpu_model" ]] || cpu_model='kvm64' + printf '%s\n' "$cpu_model" + return 0 + fi + + # Fall back to the node-aware API if the shared pmxcfs entry is briefly + # unavailable, such as while cluster membership is changing. config_output="$( pvesh get "/nodes/${node}/qemu/${vmid}/config" \ --output-format json 2>/dev/null @@ -1696,7 +1715,11 @@ FORCE_UPDATE_CHECK() { MENU_HEADER() { local version_display - LOAD_UPDATE_STATUS + # Reuse a settled update result instead of running Git checks on every + # arrow-key redraw. Continue refreshing only while the worker is pending. + if [[ "$UPDATE_STATUS" == 'unknown' || "$UPDATE_STATUS" == 'checking' ]]; then + LOAD_UPDATE_STATUS + fi case "$UPDATE_STATUS" in behind) version_display="${idsCL[LightYellow]}${VERS} ** UPDATE AVAILABLE **${idsCL[Default]}" @@ -1844,12 +1867,8 @@ HOST_SETUP_MENU() { labels+=("Detect CPU model for live migrations") values+=("cpu") - TAPM_REFRESH_ISO_STORAGES >/dev/null 2>&1 || true - if TAPM_ANY_LOCAL_VIRTIO_ISOS; then - labels+=("VirtIO driver downloads (local ISOs available)") - else - labels+=("VirtIO driver downloads") - fi + # Discover ISO storage only after the VirtIO submenu is selected. + labels+=("VirtIO driver downloads") values+=("virtio") command -v glances >/dev/null 2>&1 && @@ -1881,10 +1900,19 @@ MONITORING_MENU() { local -a labels local -a values=("pulse" "rmm" "acronis" "sentinelone" "screenconnect") local cluster_resources + local pulse_status while true; do - cluster_resources="$(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null)" - TAPM_PULSE_RESOURCE_INSTALLED "$cluster_resources" && + TAPM_PULSE_RESOURCE_INSTALLED_FROM_CONFIGS + pulse_status=$? + if (( pulse_status == 2 )); then + cluster_resources="$( + pvesh get /cluster/resources --type vm --output-format json 2>/dev/null + )" + TAPM_PULSE_RESOURCE_INSTALLED "$cluster_resources" + pulse_status=$? + fi + (( pulse_status == 0 )) && labels=("Pulse monitoring (installed)") || labels=("Install Pulse monitoring") diff --git a/tests/test-pulse.sh b/tests/test-pulse.sh index 68705d4..9be6d75 100644 --- a/tests/test-pulse.sh +++ b/tests/test-pulse.sh @@ -185,6 +185,26 @@ assert_success "untagged default Pulse hostname detected" \ assert_failure "unrelated resource not detected" \ TAPM_PULSE_RESOURCE_INSTALLED '[{"type":"qemu","name":"pulse"}]' +test_pulse_config_detection() { + local fixture_root status + + fixture_root="$(mktemp -d /tmp/tapm-pulse-configs.XXXXXX)" || return 1 + mkdir -p "${fixture_root}/pve1/lxc" || return 1 + printf '%s\n' \ + 'arch: amd64' \ + 'hostname: Pulse-Monitor' \ + 'memory: 2048' >"${fixture_root}/pve1/lxc/200.conf" + TAPM_PULSE_RESOURCE_INSTALLED_FROM_CONFIGS "$fixture_root" + status=$? + rm -rf -- "$fixture_root" + return "$status" +} + +assert_success "Pulse LXC detected from shared Proxmox configuration" \ + test_pulse_config_detection +assert_failure "missing Pulse configuration directory is not installed" \ + TAPM_PULSE_RESOURCE_INSTALLED_FROM_CONFIGS /does/not/exist + ha_status=$'quorum OK\nmaster pve1 (active, Sat Jul 25 12:00:00 2026)\nlrm pve1 (active, Sat Jul 25 12:00:00 2026)' assert_success "active Proxmox HA detected" TAPM_PULSE_HA_STATUS_ENABLED "$ha_status" assert_failure "inactive Proxmox HA rejected" \ From 53504cd1672a1f3932d595bf7c1c5d51483d11a7 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 23:41:34 -0500 Subject: [PATCH 47/76] update --- defaults.inc | 2 +- inc/ha-status.inc | 53 +++++++++++++++++++++++++++++++++++++++++ proxmenu-scripts.sh | 14 ++++++++++- tests/test-ha-status.sh | 32 +++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 inc/ha-status.inc create mode 100644 tests/test-ha-status.sh diff --git a/defaults.inc b/defaults.inc index e39ee32..48c82fb 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-63' +VERS='2026.7.25-64' noupdate=' ' diff --git a/inc/ha-status.inc b/inc/ha-status.inc new file mode 100644 index 0000000..db0dc6f --- /dev/null +++ b/inc/ha-status.inc @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +TAPM_HA_NODE_IN_MAINTENANCE() { + local node="${1:-}" + local status_file="${2:-/etc/pve/ha/manager_status}" + + [[ "$node" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ ]] || return 2 + [[ -r "$status_file" ]] || return 2 + TAPM_HA_NODE="$node" python3 - "$status_file" <<'PY' +import json +import os +import sys + +try: + with open(sys.argv[1], encoding="utf-8") as status_handle: + status = json.load(status_handle) +except (OSError, TypeError, ValueError): + raise SystemExit(2) + +node = os.environ["TAPM_HA_NODE"].strip().lower() + +def contains_maintenance(value): + if isinstance(value, str): + return "maintenance" in value.lower() + if isinstance(value, dict): + return any(contains_maintenance(item) for item in value.values()) + if isinstance(value, list): + return any(contains_maintenance(item) for item in value) + return False + +def node_is_in_maintenance(value): + if isinstance(value, dict): + for key, item in value.items(): + if str(key).strip().lower() == node and contains_maintenance(item): + return True + identity = next( + ( + value.get(key) + for key in ("node", "name", "id") + if isinstance(value.get(key), str) + ), + "", + ) + if identity.strip().lower() == node and contains_maintenance(value): + return True + return any(node_is_in_maintenance(item) for item in value.values()) + if isinstance(value, list): + return any(node_is_in_maintenance(item) for item in value) + return False + +raise SystemExit(0 if node_is_in_maintenance(status) else 1) +PY +} diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index f2ba36a..55e1530 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -6,6 +6,7 @@ source /opt/idssys/defaults/default.inc source /opt/idssys/ta-proxmenu/defaults.inc source /opt/idssys/ta-proxmenu/inc/git-update.inc +source /opt/idssys/ta-proxmenu/inc/ha-status.inc source /opt/idssys/ta-proxmenu/inc/deploy-iso-nfs-lxc.sh source /opt/idssys/ta-proxmenu/inc/deploy-pulse-lxc.sh source /opt/idssys/ta-proxmenu/inc/virtio-helpers.inc @@ -1948,9 +1949,20 @@ MONITORING_MENU() { CLUSTER_MENU() { local -a labels local -a values=("maintenance" "services" "keepalived" "iso_nfs") + local maintenance_status + local node while true; do - if ha-manager status | grep -F "$(hostname -s)" | grep -q "maintenance mode"; then + node="$(hostname -s)" + TAPM_HA_NODE_IN_MAINTENANCE "$node" + maintenance_status=$? + if (( maintenance_status == 2 )); then + ha-manager status | + grep -F "$node" | + grep -q "maintenance mode" + maintenance_status=$? + fi + if (( maintenance_status == 0 )); then labels=("Take this host out of maintenance mode") else labels=("Put this host into maintenance mode and evacuate guests") diff --git a/tests/test-ha-status.sh b/tests/test-ha-status.sh new file mode 100644 index 0000000..bec801c --- /dev/null +++ b/tests/test-ha-status.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${TEST_ROOT}/tests/testlib.sh" +source "${TEST_ROOT}/inc/ha-status.inc" + +STATUS_FILE="$(mktemp /tmp/tapm-ha-status.XXXXXX)" +cleanup_ha_status_test() { + [[ "$STATUS_FILE" == /tmp/tapm-ha-status.* ]] && rm -f -- "$STATUS_FILE" +} +trap cleanup_ha_status_test EXIT + +printf '%s\n' \ + '{"node_status":{"pve1":"online","pve2":"maintenance mode"}}' \ + >"$STATUS_FILE" +assert_success "maintenance node detected in HA manager state" \ + TAPM_HA_NODE_IN_MAINTENANCE pve2 "$STATUS_FILE" +assert_failure "active node is not reported as maintenance" \ + TAPM_HA_NODE_IN_MAINTENANCE pve1 "$STATUS_FILE" + +printf '%s\n' \ + '{"nodes":[{"node":"pve3","state":"maintenance"}]}' \ + >"$STATUS_FILE" +assert_success "structured HA node state detected" \ + TAPM_HA_NODE_IN_MAINTENANCE pve3 "$STATUS_FILE" + +printf '%s\n' 'not-json' >"$STATUS_FILE" +assert_failure "malformed HA state is rejected" \ + TAPM_HA_NODE_IN_MAINTENANCE pve1 "$STATUS_FILE" + +finish_tests From eb4206819602639e6161a7f1cff33bf0c7e29196 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 23:44:07 -0500 Subject: [PATCH 48/76] update --- README.md | 2 +- defaults.inc | 2 +- proxmenu-scripts.sh | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2459ad8..fc1aac7 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ than four hours old. If the remote is unavailable, the installed copy is used. Updates are fast-forward-only. Local file changes, local-only commits, and diverged histories are preserved and reported instead of being overwritten. -The Utilities menu can safely switch TA-ProxMenu between branches published on +The TA-ProxMenu Management menu can safely switch TA-ProxMenu between branches published on its Git origin. Branch switching is refused when the installed repository has local changes or when the destination branch has commits that would be discarded. diff --git a/defaults.inc b/defaults.inc index 48c82fb..fadb5ad 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-64' +VERS='2026.7.25-65' noupdate=' ' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 55e1530..3fe1f0f 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -2199,7 +2199,7 @@ UTILITIES_MENU() { "Version and installation information" ) - SELECT_MENU "Utilities" labels values + SELECT_MENU "TA-ProxMenu Management" labels values case "$MENU_SELECTION" in install_update) if [[ "$UPDATE_STATUS" != "behind" ]]; then @@ -2230,7 +2230,7 @@ MAIN_MENU() { "Host Setup" "Monitoring & Agents" "Cluster & Maintenance" - "Utilities" + "TA-ProxMenu Management" "Quit" ) local -a values=("host" "monitoring" "cluster" "utilities" "quit") From 6156d315d8db68e6c10438840c4e8aba24d12d0b Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 23:47:42 -0500 Subject: [PATCH 49/76] update --- defaults.inc | 2 +- proxmenu-scripts.sh | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/defaults.inc b/defaults.inc index fadb5ad..c245875 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-65' +VERS='2026.7.25-66' noupdate=' ' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 3fe1f0f..d881f77 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -1670,6 +1670,22 @@ LOAD_UPDATE_STATUS() { START_UPDATE_CHECK } +UPDATE_LAST_CHECKED_DISPLAY() { + local checked_at + + [[ -r "$UPDATE_CACHE_FILE" ]] || { + printf '%s\n' 'never' + return + } + IFS='|' read -r checked_at _ <"$UPDATE_CACHE_FILE" + [[ "$checked_at" =~ ^[0-9]+$ ]] || { + printf '%s\n' 'unknown' + return + } + date --date="@${checked_at}" '+%Y-%m-%d %H:%M %Z' 2>/dev/null || + printf '%s\n' 'unknown' +} + FORCE_UPDATE_CHECK() { local attempts=0 @@ -2167,6 +2183,7 @@ UTILITIES_MENU() { local -a labels local -a values=("install_update" "check_update" "branches" "about") local choice + local last_checked while true; do LOAD_UPDATE_STATUS @@ -2193,8 +2210,9 @@ UTILITIES_MENU() { labels=("Update status unavailable") ;; esac + last_checked="$(UPDATE_LAST_CHECKED_DISPLAY)" labels+=( - "Check again now" + "Check again now (last checked: ${last_checked})" "Git branch management" "Version and installation information" ) From 8754ba47d708fedf07a36774a22be2996c381877 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 23:53:24 -0500 Subject: [PATCH 50/76] update --- defaults.inc | 2 +- proxmenu-scripts.sh | 44 ++++++++++++++++++++++++++++---------------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/defaults.inc b/defaults.inc index c245875..99b7050 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-66' +VERS='2026.7.25-67' noupdate=' ' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index d881f77..1764d84 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -1929,25 +1929,37 @@ MONITORING_MENU() { TAPM_PULSE_RESOURCE_INSTALLED "$cluster_resources" pulse_status=$? fi - (( pulse_status == 0 )) && - labels=("Pulse monitoring (installed)") || - labels=("Install Pulse monitoring") + if (( pulse_status == 0 )); then + labels=("Pulse monitoring (installed)") + else + labels=("${idsCL[Cyan]}Install Pulse monitoring${idsCL[Default]}") + fi - systemctl is-active --quiet ITSPlatform && - labels+=("ConnectWise RMM agent (installed)") || - labels+=("Install ConnectWise RMM agent") + if systemctl is-active --quiet ITSPlatform; then + labels+=("ConnectWise RMM agent (installed)") + else + labels+=("${idsCL[Cyan]}Install ConnectWise RMM agent${idsCL[Default]}") + fi - dpkg-query -W -f='${Status}' cyberprotect 2>/dev/null | grep -q "install ok installed" && - labels+=("Acronis backup agent (installed)") || - labels+=("Install Acronis backup agent") + if dpkg-query -W -f='${Status}' cyberprotect 2>/dev/null | + grep -q "install ok installed"; then + labels+=("Acronis backup agent (installed)") + else + labels+=("${idsCL[Cyan]}Install Acronis backup agent${idsCL[Default]}") + fi - dpkg-query -W -f='${Status}' sentinelagent 2>/dev/null | grep -q "install ok installed" && - labels+=("SentinelOne agent (installed)") || - labels+=("Install SentinelOne agent") + if dpkg-query -W -f='${Status}' sentinelagent 2>/dev/null | + grep -q "install ok installed"; then + labels+=("SentinelOne agent (installed)") + else + labels+=("${idsCL[Cyan]}Install SentinelOne agent${idsCL[Default]}") + fi - systemctl is-active --quiet 'connectwise*' && - labels+=("ScreenConnect agent (installed)") || - labels+=("Install ScreenConnect agent") + if systemctl is-active --quiet 'connectwise*'; then + labels+=("ScreenConnect agent (installed)") + else + labels+=("${idsCL[Cyan]}Install ScreenConnect agent${idsCL[Default]}") + fi SELECT_MENU "Monitoring & Agents" labels values case "$MENU_SELECTION" in @@ -2212,7 +2224,7 @@ UTILITIES_MENU() { esac last_checked="$(UPDATE_LAST_CHECKED_DISPLAY)" labels+=( - "Check again now (last checked: ${last_checked})" + "Check again now "$'\e[2m'"(last checked: ${last_checked})"$'\e[22m' "Git branch management" "Version and installation information" ) From 0e209b0085542338f27dd4364c82c446d7d1c991 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 23:55:40 -0500 Subject: [PATCH 51/76] update --- defaults.inc | 2 +- proxmenu-scripts.sh | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/defaults.inc b/defaults.inc index 99b7050..5703efc 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-67' +VERS='2026.7.25-68' noupdate=' ' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 1764d84..8989c41 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -1932,33 +1932,33 @@ MONITORING_MENU() { if (( pulse_status == 0 )); then labels=("Pulse monitoring (installed)") else - labels=("${idsCL[Cyan]}Install Pulse monitoring${idsCL[Default]}") + labels=($'\e[36mInstall Pulse monitoring\e[39m') fi if systemctl is-active --quiet ITSPlatform; then labels+=("ConnectWise RMM agent (installed)") else - labels+=("${idsCL[Cyan]}Install ConnectWise RMM agent${idsCL[Default]}") + labels+=($'\e[36mInstall ConnectWise RMM agent\e[39m') fi if dpkg-query -W -f='${Status}' cyberprotect 2>/dev/null | grep -q "install ok installed"; then labels+=("Acronis backup agent (installed)") else - labels+=("${idsCL[Cyan]}Install Acronis backup agent${idsCL[Default]}") + labels+=($'\e[36mInstall Acronis backup agent\e[39m') fi if dpkg-query -W -f='${Status}' sentinelagent 2>/dev/null | grep -q "install ok installed"; then labels+=("SentinelOne agent (installed)") else - labels+=("${idsCL[Cyan]}Install SentinelOne agent${idsCL[Default]}") + labels+=($'\e[36mInstall SentinelOne agent\e[39m') fi if systemctl is-active --quiet 'connectwise*'; then labels+=("ScreenConnect agent (installed)") else - labels+=("${idsCL[Cyan]}Install ScreenConnect agent${idsCL[Default]}") + labels+=($'\e[36mInstall ScreenConnect agent\e[39m') fi SELECT_MENU "Monitoring & Agents" labels values From c51e1ab1deedaa95118b2209c03591c3077762a0 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 23:57:17 -0500 Subject: [PATCH 52/76] update --- defaults.inc | 2 +- proxmenu-scripts.sh | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/defaults.inc b/defaults.inc index 5703efc..066d3e8 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-68' +VERS='2026.7.25-69' noupdate=' ' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 8989c41..74add7e 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -1932,33 +1932,33 @@ MONITORING_MENU() { if (( pulse_status == 0 )); then labels=("Pulse monitoring (installed)") else - labels=($'\e[36mInstall Pulse monitoring\e[39m') + labels=($'Install \e[36mPulse\e[39m monitoring') fi if systemctl is-active --quiet ITSPlatform; then labels+=("ConnectWise RMM agent (installed)") else - labels+=($'\e[36mInstall ConnectWise RMM agent\e[39m') + labels+=($'Install \e[36mConnectWise RMM\e[39m agent') fi if dpkg-query -W -f='${Status}' cyberprotect 2>/dev/null | grep -q "install ok installed"; then labels+=("Acronis backup agent (installed)") else - labels+=($'\e[36mInstall Acronis backup agent\e[39m') + labels+=($'Install \e[36mAcronis\e[39m backup agent') fi if dpkg-query -W -f='${Status}' sentinelagent 2>/dev/null | grep -q "install ok installed"; then labels+=("SentinelOne agent (installed)") else - labels+=($'\e[36mInstall SentinelOne agent\e[39m') + labels+=($'Install \e[36mSentinelOne\e[39m agent') fi if systemctl is-active --quiet 'connectwise*'; then labels+=("ScreenConnect agent (installed)") else - labels+=($'\e[36mInstall ScreenConnect agent\e[39m') + labels+=($'Install \e[36mScreenConnect\e[39m agent') fi SELECT_MENU "Monitoring & Agents" labels values From aa0e74ae8c46d997bfed26a7447eeaf7e462592b Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sun, 26 Jul 2026 01:15:01 -0500 Subject: [PATCH 53/76] update to new post-install script --- README.md | 26 +- defaults.inc | 2 +- inc/post-install.inc | 1054 ++++++++++++++++++++++++++++++++++++ proxmenu-scripts.sh | 492 +++++++++++++---- tests/test-post-install.sh | 168 ++++++ 5 files changed, 1633 insertions(+), 109 deletions(-) create mode 100644 inc/post-install.inc create mode 100644 tests/test-post-install.sh diff --git a/README.md b/README.md index fc1aac7..6e7ad8e 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,8 @@ rmm Install the ConnectWise RMM agent omsa Install legacy Dell OMSA on supported PowerEdge x30/x40 hosts glances Install Glances acronis Install the Acronis agent -post-install Run the ProxMenux post-install configuration -proxmenux Install or open ProxMenux +post-install Open the native TAPM host configuration menu +proxmenux Open TAPM host configuration for migration compatibility virtio Download current VirtIO drivers sentinelone Install the SentinelOne agent screenconnect Install the ScreenConnect agent @@ -85,6 +85,21 @@ CPU compatibility detection previews cluster-wide QEMU VM and template changes before applying the ProxCLMC recommendation through the Proxmox CLI. Running VMs are not restarted automatically. +The native TAPM host configuration workflow replaces the former ProxMenux +post-install dependency. It can audit a host, apply the recommended PVE 9 +profile, customize individual items, migrate recognized ProxMenux +configurations, repair ProxMenux's system gzip replacement, and remove +ProxMenux after validation. Every mutating run first creates a timestamped +backup under `/var/backups/ta-proxmenu/post-install`. + +The recommended profile installs only missing diagnostic utilities, preserves +the configured timezone and NTP servers, enables conservative kernel and +network safeguards, retains up to 1 GiB or 30 days of persistent journal +history, verifies logrotate, and uses Proxmox's native pigz support without +replacing `/bin/gzip`. APT remains dual-stack by default; its conditional IPv4 +compatibility check is optional. Global vzdump bandwidth and I/O-priority +changes are available through a separate explicit menu. + VirtIO downloads are managed from a dedicated submenu. The stable release is checked only when that submenu is opened, and curated compatibility ISOs are available for Windows Server 2008, 2008 R2, 2012/R2, and 2016. Downloads are @@ -105,6 +120,7 @@ Run the local validation suite with Bash 4.3 or newer: The suite checks Bash syntax and Git whitespace, runs ShellCheck when it is installed, and exercises Git update states, LXC input/storage selection, -maintenance evacuation routing, HA affinity parsing, and VirtIO filename -validation. Tests use temporary files and mocked Proxmox output; they do not -download installers or change a Proxmox host. +maintenance evacuation routing, HA affinity parsing, host-profile migration +signatures and backups, and VirtIO filename validation. Tests use temporary +files and mocked Proxmox output; they do not download installers or change a +Proxmox host. diff --git a/defaults.inc b/defaults.inc index 066d3e8..5420f39 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-69' +VERS='2026.7.26-1' noupdate=' ' diff --git a/inc/post-install.inc b/inc/post-install.inc new file mode 100644 index 0000000..c2faa10 --- /dev/null +++ b/inc/post-install.inc @@ -0,0 +1,1054 @@ +#!/usr/bin/env bash +# TA-ProxMenu native Proxmox VE 9 host configuration and ProxMenux migration. + +TAPM_POST_BACKUP_BASE='/var/backups/ta-proxmenu/post-install' +TAPM_POST_STATE_DIR='/var/lib/ta-proxmenu/post-install' +TAPM_POST_LAST_BACKUP='' +TAPM_POST_LAST_ERROR='' + +TAPM_POST_PATH() { + printf '%s%s' "${TAPM_HOST_ROOT:-}" "$1" +} + +TAPM_POST_LIVE_ROOT() { + [[ -z "${TAPM_HOST_ROOT:-}" ]] +} + +TAPM_POST_WRITE_FILE() { + local target + local mode="${2:-0644}" + local temporary + + target="$(TAPM_POST_PATH "$1")" + mkdir -p -- "$(dirname "$target")" || return 1 + temporary="$(mktemp "${target}.tapm.XXXXXX")" || return 1 + if ! cat >"$temporary" || ! chmod "$mode" "$temporary" || + ! mv -f -- "$temporary" "$target"; then + rm -f -- "$temporary" + return 1 + fi +} + +TAPM_POST_FILE_NORMALIZED() { + local file="$1" + + [[ -f "$file" ]] || return 1 + tr -d '[:space:]' <"$file" +} + +TAPM_POST_EXACT_APT_LANGUAGES() { + local file + file="$(TAPM_POST_PATH '/etc/apt/apt.conf.d/99-disable-translations')" + [[ "$(TAPM_POST_FILE_NORMALIZED "$file" 2>/dev/null)" == \ + 'Acquire::Languages"none";' ]] +} + +TAPM_POST_EXACT_APT_IPV4() { + local file + file="$(TAPM_POST_PATH '/etc/apt/apt.conf.d/99-force-ipv4')" + [[ "$(TAPM_POST_FILE_NORMALIZED "$file" 2>/dev/null)" == \ + 'Acquire::ForceIPv4"true";' ]] +} + +TAPM_POST_PROXMENUX_JOURNALD() { + local file + file="$(TAPM_POST_PATH '/etc/systemd/journald.conf')" + [[ -f "$file" ]] && + grep -q '^SystemMaxUse=64M$' "$file" && + grep -q '^RuntimeMaxUse=60M$' "$file" && + grep -q '^Seal=no$' "$file" && + grep -q '^MaxLevelStore=info$' "$file" +} + +TAPM_POST_PROXMENUX_LOGROTATE() { + local file + file="$(TAPM_POST_PATH '/etc/logrotate.conf')" + [[ -f "$file" ]] && + grep -q '^# ProxMenux optimized configuration' "$file" && + grep -q '^size 10M$' "$file" && + grep -q '^copytruncate$' "$file" +} + +TAPM_POST_PROXMENUX_GZIP_WRAPPER() { + local file + file="$(TAPM_POST_PATH '/bin/gzip')" + [[ -f "$file" ]] && + grep -q 'GZIP="-1"' "$file" && + grep -q 'exec /usr/bin/pigz' "$file" +} + +TAPM_POST_PROXMENUX_NETWORK() { + local file + file="$(TAPM_POST_PATH '/etc/sysctl.d/99-network.conf')" + [[ -f "$file" ]] && + grep -q '^# ProxMenux - Network tuning' "$file" +} + +TAPM_POST_PROXMENUX_MENU_LAUNCHER() { + local file + local target + + file="$(TAPM_POST_PATH '/usr/local/bin/menu')" + if [[ -L "$file" ]]; then + target="$(readlink "$file" 2>/dev/null)" + [[ "$target" == *proxmenux* ]] + return + fi + [[ -f "$file" ]] && grep -qi 'proxmenux' "$file" +} + +TAPM_POST_PROXMENUX_DETECTED() { + [[ -d "$(TAPM_POST_PATH '/usr/local/share/proxmenux')" || + -e "$(TAPM_POST_PATH '/etc/systemd/system/proxmenux-monitor.service')" || + -e "$(TAPM_POST_PATH '/opt/.PROXMENUX_POST_INSTALL')" ]] || + TAPM_POST_PROXMENUX_MENU_LAUNCHER || + TAPM_POST_PROXMENUX_GZIP_WRAPPER || + TAPM_POST_PROXMENUX_NETWORK || + TAPM_POST_PROXMENUX_JOURNALD || + TAPM_POST_PROXMENUX_LOGROTATE || + TAPM_POST_EXACT_APT_LANGUAGES || + TAPM_POST_EXACT_APT_IPV4 +} + +TAPM_POST_MANAGED_FILES() { + cat <<'EOF' +/etc/sysctl.d/99-ta-proxmenu-kernel-panic.conf +/etc/sysctl.d/99-ta-proxmenu-limits.conf +/etc/systemd/journald.conf.d/99-ta-proxmenu.conf +/etc/sysctl.d/99-ta-proxmenu-memory.conf +/etc/sysctl.d/99-ta-proxmenu-network.conf +/etc/sysctl.d/99-ta-proxmenu-bbr.conf +/etc/modules-load.d/ta-proxmenu-bbr.conf +/etc/apt/apt.conf.d/99-ta-proxmenu-force-ipv4 +EOF +} + +TAPM_POST_MIGRATION_FILES() { + cat <<'EOF' +/etc/systemd/journald.conf +/etc/systemd/journald.conf.bak +/etc/logrotate.conf +/etc/logrotate.conf.bak +/etc/vzdump.conf +/etc/apt/sources.list +/etc/sysctl.d/99-kernelpanic.conf +/etc/sysctl.d/99-maxwatches.conf +/etc/sysctl.d/99-maxkeys.conf +/etc/sysctl.d/99-fs.conf +/etc/sysctl.d/99-swap.conf +/etc/sysctl.d/99-memory.conf +/etc/sysctl.d/99-network.conf +/etc/sysctl.d/99-kernel-bbr.conf +/etc/sysctl.d/99-tcp-fastopen.conf +/etc/security/limits.d/99-limits.conf +/etc/systemd/system.conf +/etc/systemd/user.conf +/etc/pam.d/common-session +/etc/pam.d/runuser-l +/etc/network/interfaces +/root/.profile +/root/.bashrc +/root/.bashrc.bak +/etc/motd +/etc/motd.bak +/etc/apt/apt.conf.d/99-disable-translations +/etc/apt/apt.conf.d/99-force-ipv4 +/usr/local/sbin/proxmenux-fwbr-tune +/etc/systemd/system/proxmenux-fwbr-tune.service +/etc/udev/rules.d/99-proxmenux-fwbr-tune.rules +/etc/systemd/system/proxmenux-monitor.service +/usr/local/bin/menu +/usr/local/share/proxmenux +/root/.config/proxmenux-monitor +/opt/googletrans-env +/var/lib/proxmenux +/opt/.PROXMENUX_POST_INSTALL +/bin/gzip +/bin/gzip.original +/bin/pigzwrapper +EOF +} + +TAPM_POST_ALL_BACKUP_PATHS() { + { + TAPM_POST_MANAGED_FILES + TAPM_POST_MIGRATION_FILES + } | awk '!seen[$0]++' +} + +TAPM_POST_BACKUP() { + local backup_root + local source + local relative_path + local manifest + local checksum + local timestamp + local service + local enabled + local active + + timestamp="$(date +%Y%m%d-%H%M%S)" + backup_root="$(TAPM_POST_PATH "$TAPM_POST_BACKUP_BASE")" + mkdir -p -m 0700 -- "$backup_root" || return 1 + TAPM_POST_LAST_BACKUP="$(mktemp -d "${backup_root}/${timestamp}.XXXXXX")" || + return 1 + chmod 0700 "$TAPM_POST_LAST_BACKUP" || return 1 + manifest="${TAPM_POST_LAST_BACKUP}/manifest.tsv" + printf 'TA-ProxMenu host configuration backup\nCreated: %s\nHost: %s\n' \ + "$(date --iso-8601=seconds 2>/dev/null || date)" \ + "$(hostname 2>/dev/null || printf unknown)" \ + >"${TAPM_POST_LAST_BACKUP}/README.txt" + + while IFS= read -r relative_path; do + [[ -n "$relative_path" ]] || continue + source="$(TAPM_POST_PATH "$relative_path")" + if [[ -e "$source" || -L "$source" ]]; then + mkdir -p -- "${TAPM_POST_LAST_BACKUP}/files$(dirname "$relative_path")" || + return 1 + cp -a -- "$source" \ + "${TAPM_POST_LAST_BACKUP}/files${relative_path}" || return 1 + if [[ -f "$source" && ! -L "$source" ]]; then + checksum="$(sha256sum "$source" | awk '{print $1}')" + else + checksum='-' + fi + printf 'PRESENT\t%s\t%s\n' "$relative_path" "$checksum" >>"$manifest" + else + printf 'ABSENT\t%s\t-\n' "$relative_path" >>"$manifest" + fi + done < <(TAPM_POST_ALL_BACKUP_PATHS) + + if TAPM_POST_LIVE_ROOT; then + for service in \ + proxmenux-monitor.service proxmenux-fwbr-tune.service \ + chrony.service systemd-timesyncd.service logrotate.timer; do + systemctl is-enabled --quiet "$service" 2>/dev/null && + enabled=enabled || enabled=disabled + systemctl is-active --quiet "$service" 2>/dev/null && + active=active || active=inactive + printf '%s\t%s\t%s\n' "$service" "$enabled" "$active" \ + >>"${TAPM_POST_LAST_BACKUP}/services.tsv" + done + fi + + printf '%s\n' "$TAPM_POST_LAST_BACKUP" +} + +TAPM_POST_RESTORE_BACKUP() { + local backup_dir="$1" + local manifest="${backup_dir}/manifest.tsv" + local status + local relative_path + local checksum + local target + local stored + + [[ -d "$backup_dir" && -f "$manifest" ]] || return 1 + while IFS=$'\t' read -r status relative_path checksum; do + [[ "$relative_path" == /* && "$relative_path" != *'..'* ]] || return 1 + target="$(TAPM_POST_PATH "$relative_path")" + stored="${backup_dir}/files${relative_path}" + case "$status" in + PRESENT) + [[ -e "$stored" || -L "$stored" ]] || return 1 + if [[ "$checksum" != '-' ]]; then + [[ -f "$stored" && ! -L "$stored" ]] || return 1 + [[ "$(sha256sum "$stored" | awk '{print $1}')" == "$checksum" ]] || + return 1 + fi + mkdir -p -- "$(dirname "$target")" || return 1 + rm -rf -- "$target" || return 1 + cp -a -- "$stored" "$target" || return 1 + ;; + ABSENT) + rm -rf -- "$target" || return 1 + ;; + *) + return 1 + ;; + esac + done <"$manifest" + + if TAPM_POST_LIVE_ROOT; then + systemctl daemon-reload >/dev/null 2>&1 || true + if [[ -f "${backup_dir}/services.tsv" ]]; then + while IFS=$'\t' read -r relative_path status checksum; do + if [[ "$status" == enabled ]]; then + systemctl enable "$relative_path" >/dev/null 2>&1 || true + else + systemctl disable "$relative_path" >/dev/null 2>&1 || true + fi + if [[ "$checksum" == active ]]; then + systemctl start "$relative_path" >/dev/null 2>&1 || true + else + systemctl stop "$relative_path" >/dev/null 2>&1 || true + fi + done <"${backup_dir}/services.tsv" + fi + sysctl --system >/dev/null 2>&1 || true + systemctl restart systemd-journald.service >/dev/null 2>&1 || true + fi +} + +TAPM_POST_PRECHECK() { + local pve_major + + if TAPM_POST_LIVE_ROOT; then + if (( EUID != 0 )); then + TAPM_POST_LAST_ERROR='This workflow must be run as root.' + return 1 + fi + pve_major="$( + pveversion 2>/dev/null | + sed -n 's/.*pve-manager\\/\\([0-9][0-9]*\\).*/\\1/p' | + head -1 + )" + if [[ "$pve_major" != '9' ]]; then + TAPM_POST_LAST_ERROR='The native TAPM profile currently supports Proxmox VE 9 only.' + return 1 + fi + if ps -eo comm= 2>/dev/null | + grep -Eq '^(vzdump|pigz|gzip|zstd)$'; then + TAPM_POST_LAST_ERROR='A backup or compression process is active; retry after it finishes.' + return 1 + fi + if command -v flock >/dev/null 2>&1 && + ! flock -n /var/lib/dpkg/lock-frontend true 2>/dev/null; then + TAPM_POST_LAST_ERROR='The package manager is currently busy.' + return 1 + fi + fi +} + +TAPM_POST_DEFAULT_SELECTIONS() { + TAPM_POST_DO_UTILITIES=1 + TAPM_POST_DO_TIME=1 + TAPM_POST_DO_PANIC=1 + TAPM_POST_DO_LIMITS=1 + TAPM_POST_DO_JOURNALD=1 + TAPM_POST_DO_LOGROTATE=1 + TAPM_POST_DO_MEMORY=1 + TAPM_POST_DO_NETWORK=1 + TAPM_POST_DO_BBR=1 + TAPM_POST_DO_PIGZ=1 + TAPM_POST_DO_APT_NETWORK=0 +} + +TAPM_POST_PACKAGE_INSTALLED() { + dpkg-query -W -f='${Status}' "$1" 2>/dev/null | + grep -q '^install ok installed$' +} + +TAPM_POST_INSTALL_PACKAGES() { + local -a packages=( + htop btop iftop iotop iperf3 net-tools unzip zip + tmux mtr-tiny dnsutils lsof jq rsync sysstat ethtool + smartmontools nvme-cli + ) + local -a missing=() + local package + local microcode='' + local vendor + + TAPM_POST_LIVE_ROOT || return 0 + for package in "${packages[@]}"; do + TAPM_POST_PACKAGE_INSTALLED "$package" || missing+=("$package") + done + if (( ${#missing[@]} > 0 )); then + if ! DEBIAN_FRONTEND=noninteractive apt-get install -y \ + --no-install-recommends "${missing[@]}"; then + apt-get update || return 1 + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + --no-install-recommends "${missing[@]}" || return 1 + fi + fi + + vendor="$(awk -F: '/vendor_id/ {gsub(/[[:space:]]/, "", $2); print $2; exit}' \ + /proc/cpuinfo 2>/dev/null)" + case "$vendor" in + GenuineIntel) microcode='intel-microcode' ;; + AuthenticAMD) microcode='amd64-microcode' ;; + esac + if [[ -n "$microcode" ]] && ! TAPM_POST_PACKAGE_INSTALLED "$microcode"; then + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + "$microcode" || + printf 'Warning: %s was unavailable; continuing.\n' "$microcode" >&2 + fi +} + +TAPM_POST_ENSURE_APT_LAYOUT() { + local sources_list + + sources_list="$(TAPM_POST_PATH '/etc/apt/sources.list')" + if [[ -e "$sources_list" && ! -f "$sources_list" ]]; then + return 1 + fi + mkdir -p -- "$(dirname "$sources_list")" || return 1 + : >"$sources_list" || return 1 + chmod 0644 "$sources_list" || return 1 + if TAPM_POST_LIVE_ROOT; then + apt-get update + fi +} + +TAPM_POST_CONFIGURE_TIME() { + TAPM_POST_LIVE_ROOT || return 0 + + if ! TAPM_POST_PACKAGE_INSTALLED chrony; then + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + chrony || return 1 + fi + systemctl enable --now chrony.service || return 1 + if systemctl is-active --quiet systemd-timesyncd.service; then + systemctl disable --now systemd-timesyncd.service >/dev/null 2>&1 || true + fi + chronyc tracking >/dev/null 2>&1 || { + printf 'Warning: chrony is active but has not synchronized yet.\n' >&2 + return 0 + } +} + +TAPM_POST_CONFIGURE_PANIC() { + TAPM_POST_WRITE_FILE '/etc/sysctl.d/99-ta-proxmenu-kernel-panic.conf' <<'EOF' +# Managed by TA-ProxMenu +kernel.panic = 30 +kernel.panic_on_oops = 1 +kernel.hardlockup_panic = 1 +kernel.softlockup_panic = 0 +EOF +} + +TAPM_POST_CONFIGURE_LIMITS() { + TAPM_POST_WRITE_FILE '/etc/sysctl.d/99-ta-proxmenu-limits.conf' <<'EOF' +# Managed by TA-ProxMenu +fs.inotify.max_user_watches = 524288 +fs.inotify.max_user_instances = 1024 +fs.inotify.max_queued_events = 32768 +EOF +} + +TAPM_POST_CONFIGURE_JOURNALD() { + if TAPM_POST_PROXMENUX_JOURNALD; then + printf '[Journal]\n' | + TAPM_POST_WRITE_FILE '/etc/systemd/journald.conf' + fi + TAPM_POST_WRITE_FILE '/etc/systemd/journald.conf.d/99-ta-proxmenu.conf' <<'EOF' +# Managed by TA-ProxMenu +[Journal] +Storage=persistent +Compress=yes +SystemMaxUse=1G +SystemKeepFree=1G +SystemMaxFileSize=64M +MaxRetentionSec=30day +EOF + if TAPM_POST_LIVE_ROOT; then + systemctl restart systemd-journald.service || return 1 + systemd-analyze cat-config systemd/journald.conf 2>/dev/null | + grep -q '^MaxRetentionSec=30day$' || return 1 + fi +} + +TAPM_POST_LOGROTATE_BACKUP_VALID() { + local file="$1" + + [[ -f "$file" ]] || return 1 + ! grep -q '^# ProxMenux optimized configuration' "$file" && + grep -Eq '^[[:space:]]*include[[:space:]]+/etc/logrotate.d' "$file" +} + +TAPM_POST_CONFIGURE_LOGROTATE() { + local backup + + if TAPM_POST_PROXMENUX_LOGROTATE; then + backup="$(TAPM_POST_PATH '/etc/logrotate.conf.bak')" + if TAPM_POST_LOGROTATE_BACKUP_VALID "$backup"; then + cp -a -- "$backup" "$(TAPM_POST_PATH '/etc/logrotate.conf')" || + return 1 + else + TAPM_POST_WRITE_FILE '/etc/logrotate.conf' <<'EOF' +# Debian-compatible baseline restored by TA-ProxMenu +weekly +rotate 4 +create +include /etc/logrotate.d +EOF + fi + fi + if TAPM_POST_LIVE_ROOT; then + systemctl enable --now logrotate.timer >/dev/null 2>&1 || return 1 + logrotate --debug /etc/logrotate.conf >/dev/null 2>&1 || return 1 + fi +} + +TAPM_POST_CONFIGURE_MEMORY() { + TAPM_POST_WRITE_FILE '/etc/sysctl.d/99-ta-proxmenu-memory.conf' <<'EOF' +# Managed by TA-ProxMenu +vm.swappiness = 10 +EOF +} + +TAPM_POST_CONFIGURE_NETWORK() { + TAPM_POST_WRITE_FILE '/etc/sysctl.d/99-ta-proxmenu-network.conf' <<'EOF' +# Managed by TA-ProxMenu +net.ipv4.conf.all.accept_redirects = 0 +net.ipv4.conf.default.accept_redirects = 0 +net.ipv4.conf.all.secure_redirects = 0 +net.ipv4.conf.default.secure_redirects = 0 +net.ipv4.conf.all.accept_source_route = 0 +net.ipv4.conf.default.accept_source_route = 0 +net.ipv4.conf.all.send_redirects = 0 +net.ipv4.conf.default.send_redirects = 0 +net.ipv4.icmp_echo_ignore_broadcasts = 1 +net.ipv4.icmp_ignore_bogus_error_responses = 1 +net.ipv4.tcp_rfc1337 = 1 +net.ipv4.tcp_mtu_probing = 1 +EOF +} + +TAPM_POST_CONFIGURE_BBR() { + TAPM_POST_WRITE_FILE '/etc/sysctl.d/99-ta-proxmenu-bbr.conf' <<'EOF' +# Managed by TA-ProxMenu +net.core.default_qdisc = fq +net.ipv4.tcp_congestion_control = bbr +net.ipv4.tcp_fastopen = 3 +EOF + if TAPM_POST_LIVE_ROOT; then + modprobe tcp_bbr >/dev/null 2>&1 || true + if ! sysctl -n net.ipv4.tcp_available_congestion_control 2>/dev/null | + grep -qw bbr; then + rm -f -- \ + "$(TAPM_POST_PATH '/etc/sysctl.d/99-ta-proxmenu-bbr.conf')" \ + "$(TAPM_POST_PATH '/etc/modules-load.d/ta-proxmenu-bbr.conf')" + printf 'Warning: BBR is unavailable in the running kernel; continuing.\n' >&2 + return 0 + fi + if modinfo tcp_bbr >/dev/null 2>&1; then + printf 'tcp_bbr\n' | + TAPM_POST_WRITE_FILE '/etc/modules-load.d/ta-proxmenu-bbr.conf' + fi + fi +} + +TAPM_POST_REMOVE_COLON_KEY() { + local relative_path="$1" + local key="$2" + local file + local temporary + + file="$(TAPM_POST_PATH "$relative_path")" + [[ -f "$file" ]] || return 0 + temporary="$(mktemp "${file}.tapm.XXXXXX")" || return 1 + if ! awk -v key="$key" ' + $0 ~ "^[[:space:]]*" key "[[:space:]]*:" { next } + { print } + ' "$file" >"$temporary" || + ! { chmod --reference="$file" "$temporary" 2>/dev/null || + chmod 0644 "$temporary"; } || + ! mv -f -- "$temporary" "$file"; then + rm -f -- "$temporary" + return 1 + fi +} + +TAPM_POST_SET_COLON_KEY() { + local relative_path="$1" + local key="$2" + local value="$3" + local file + local temporary + + file="$(TAPM_POST_PATH "$relative_path")" + mkdir -p -- "$(dirname "$file")" || return 1 + touch "$file" || return 1 + temporary="$(mktemp "${file}.tapm.XXXXXX")" || return 1 + if ! awk -v key="$key" -v value="$value" ' + BEGIN { written=0 } + $0 ~ "^[[:space:]]*#?[[:space:]]*" key "[[:space:]]*:" { + if (!written) { + print key ": " value + written=1 + } + next + } + { print } + END { + if (!written) print key ": " value + } + ' "$file" >"$temporary" || + ! chmod 0644 "$temporary" || + ! mv -f -- "$temporary" "$file"; then + rm -f -- "$temporary" + return 1 + fi +} + +TAPM_POST_REPAIR_GZIP() { + local gzip_path + local original + local wrapper + + TAPM_POST_PROXMENUX_GZIP_WRAPPER || return 0 + gzip_path="$(TAPM_POST_PATH '/bin/gzip')" + original="$(TAPM_POST_PATH '/bin/gzip.original')" + wrapper="$(TAPM_POST_PATH '/bin/pigzwrapper')" + + if [[ -x "$original" ]] && + "$original" --version 2>/dev/null | head -1 | grep -qi 'gzip'; then + cp -a -- "$original" "$gzip_path" || return 1 + elif TAPM_POST_LIVE_ROOT; then + DEBIAN_FRONTEND=noninteractive apt-get install --reinstall -y gzip || + return 1 + else + return 1 + fi + + "$gzip_path" --version 2>/dev/null | head -1 | grep -qi 'gzip' || return 1 + if ! TAPM_POST_LIVE_ROOT || + printf 'TA-ProxMenu gzip verification\n' | + "$gzip_path" -c | + "$gzip_path" -dc | + grep -q '^TA-ProxMenu gzip verification$'; then + rm -f -- "$wrapper" "$original" + else + return 1 + fi +} + +TAPM_POST_CONFIGURE_PIGZ() { + if TAPM_POST_LIVE_ROOT && ! TAPM_POST_PACKAGE_INSTALLED pigz; then + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + pigz || return 1 + fi + TAPM_POST_SET_COLON_KEY '/etc/vzdump.conf' pigz 1 +} + +TAPM_POST_APT_NETWORK_CHECK() { + local tapm_ipv4_file='/etc/apt/apt.conf.d/99-ta-proxmenu-force-ipv4' + + TAPM_POST_LIVE_ROOT || return 0 + rm -f -- "$(TAPM_POST_PATH "$tapm_ipv4_file")" + if apt-get update; then + return 0 + fi + if apt-get -o Acquire::ForceIPv4=true update; then + printf 'Acquire::ForceIPv4 "true";\n' | + TAPM_POST_WRITE_FILE "$tapm_ipv4_file" + return + fi + return 1 +} + +TAPM_POST_REMOVE_FILE_IF_MATCHES() { + local relative_path="$1" + local pattern="$2" + local file + + file="$(TAPM_POST_PATH "$relative_path")" + [[ -f "$file" ]] || return 0 + grep -q -- "$pattern" "$file" || return 0 + rm -f -- "$file" +} + +TAPM_POST_PROXMENUX_TOOL_REGISTERED() { + local tool="$1" + local registry + + registry="$(TAPM_POST_PATH '/usr/local/share/proxmenux/installed_tools.json')" + [[ -f "$registry" ]] && grep -q "\"${tool}\"" "$registry" +} + +TAPM_POST_COLON_VALUE_IS() { + local relative_path="$1" + local key="$2" + local expected="$3" + local file + + file="$(TAPM_POST_PATH "$relative_path")" + [[ -f "$file" ]] && + awk -F: -v key="$key" -v expected="$expected" ' + $1 ~ "^[[:space:]]*" key "[[:space:]]*$" { + gsub(/[[:space:]]/, "", $2) + found=($2 == expected) + } + END { exit !found } + ' "$file" +} + +TAPM_POST_REMOVE_EXACT_LINE() { + local relative_path="$1" + local line="$2" + local file + local temporary + + file="$(TAPM_POST_PATH "$relative_path")" + [[ -f "$file" ]] || return 0 + temporary="$(mktemp "${file}.tapm.XXXXXX")" || return 1 + if ! awk -v line="$line" '$0 != line { print }' "$file" >"$temporary" || + ! { chmod --reference="$file" "$temporary" 2>/dev/null || + chmod 0644 "$temporary"; } || + ! mv -f -- "$temporary" "$file"; then + rm -f -- "$temporary" + return 1 + fi +} + +TAPM_POST_RESTORE_FWBR_RUNTIME_DEFAULTS() { + local interface_path + local default_value + local interface_name + + TAPM_POST_LIVE_ROOT || return 0 + default_value="$(cat /proc/sys/net/ipv4/conf/default/rp_filter 2>/dev/null)" || + return 0 + for interface_path in /proc/sys/net/ipv4/conf/*; do + [[ -d "$interface_path" ]] || continue + interface_name="${interface_path##*/}" + case "$interface_name" in + fwbr*|fwln*|fwpr*|tap*) + [[ -w "${interface_path}/rp_filter" ]] && + printf '%s\n' "$default_value" >"${interface_path}/rp_filter" + ;; + esac + done +} + +TAPM_POST_CLEAN_PROXMENUX_SETTINGS() { + local cleanup_status=0 + local file + local limits_were_proxmenux=0 + local vzdump_was_proxmenux=0 + + file="$(TAPM_POST_PATH '/etc/security/limits.d/99-limits.conf')" + [[ -f "$file" ]] && grep -q '# ProxMenux configuration' "$file" && + limits_were_proxmenux=1 + if TAPM_POST_PROXMENUX_TOOL_REGISTERED vzdump_speed || + { [[ -e "$(TAPM_POST_PATH '/opt/.PROXMENUX_POST_INSTALL')" ]] && + TAPM_POST_COLON_VALUE_IS /etc/vzdump.conf bwlimit 0 && + TAPM_POST_COLON_VALUE_IS /etc/vzdump.conf ionice 5; }; then + vzdump_was_proxmenux=1 + fi + + if TAPM_POST_EXACT_APT_LANGUAGES; then + rm -f -- "$(TAPM_POST_PATH '/etc/apt/apt.conf.d/99-disable-translations')" || + cleanup_status=1 + fi + if TAPM_POST_EXACT_APT_IPV4; then + rm -f -- "$(TAPM_POST_PATH '/etc/apt/apt.conf.d/99-force-ipv4')" || + cleanup_status=1 + fi + + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-kernelpanic.conf' \ + '^kernel.core_pattern = /var/crash/' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-maxwatches.conf' \ + 'fs.inotify.max_user_instances = 1048576' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-maxkeys.conf' \ + 'kernel.keys.maxkeys=1000000' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-fs.conf' \ + 'fs.aio-max-nr = 1048576' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-swap.conf' \ + '# ProxMenux configuration' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-memory.conf' \ + '^# Balanced Memory Optimization' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-network.conf' \ + '^# ProxMenux - Network tuning' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-kernel-bbr.conf' \ + 'net.ipv4.tcp_congestion_control = bbr' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/sysctl.d/99-tcp-fastopen.conf' \ + 'net.ipv4.tcp_fastopen = 3' || cleanup_status=1 + TAPM_POST_REMOVE_FILE_IF_MATCHES '/etc/security/limits.d/99-limits.conf' \ + '# ProxMenux configuration' || cleanup_status=1 + + if (( limits_were_proxmenux == 1 )); then + TAPM_POST_REMOVE_EXACT_LINE '/etc/systemd/system.conf' \ + 'DefaultLimitNOFILE=1048576' || cleanup_status=1 + TAPM_POST_REMOVE_EXACT_LINE '/etc/systemd/user.conf' \ + 'DefaultLimitNOFILE=1048576' || cleanup_status=1 + TAPM_POST_REMOVE_EXACT_LINE '/etc/pam.d/common-session' \ + 'session required pam_limits.so' || cleanup_status=1 + TAPM_POST_REMOVE_EXACT_LINE '/etc/pam.d/runuser-l' \ + 'session required pam_limits.so' || cleanup_status=1 + TAPM_POST_REMOVE_EXACT_LINE '/root/.profile' 'ulimit -n 1048576' || + cleanup_status=1 + fi + if (( vzdump_was_proxmenux == 1 )); then + TAPM_POST_REMOVE_COLON_KEY '/etc/vzdump.conf' bwlimit || cleanup_status=1 + TAPM_POST_REMOVE_COLON_KEY '/etc/vzdump.conf' ionice || cleanup_status=1 + fi + + if TAPM_POST_LIVE_ROOT; then + systemctl disable --now proxmenux-fwbr-tune.service >/dev/null 2>&1 || true + fi + rm -f -- \ + "$(TAPM_POST_PATH '/usr/local/sbin/proxmenux-fwbr-tune')" \ + "$(TAPM_POST_PATH '/etc/systemd/system/proxmenux-fwbr-tune.service')" \ + "$(TAPM_POST_PATH '/etc/udev/rules.d/99-proxmenux-fwbr-tune.rules')" || + cleanup_status=1 + TAPM_POST_RESTORE_FWBR_RUNTIME_DEFAULTS || cleanup_status=1 + + file="$(TAPM_POST_PATH '/etc/network/interfaces')" + if [[ -f "$file" ]]; then + awk ' + $0 == "source /etc/network/interfaces.d/*" { + if (seen++) next + } + { print } + ' "$file" >"${file}.tapm" && + { chmod --reference="$file" "${file}.tapm" 2>/dev/null || + chmod 0644 "${file}.tapm"; } && + mv -f -- "${file}.tapm" "$file" || cleanup_status=1 + fi + return "$cleanup_status" +} + +TAPM_POST_REMOVE_PROXMENUX_APP() { + local bashrc_backup + local cleanup_status=0 + local had_app=0 + local motd_backup + local menu_launcher + + [[ -d "$(TAPM_POST_PATH '/usr/local/share/proxmenux')" ]] && had_app=1 + + if TAPM_POST_LIVE_ROOT; then + systemctl disable --now proxmenux-monitor.service >/dev/null 2>&1 || true + fi + rm -f -- \ + "$(TAPM_POST_PATH '/etc/systemd/system/proxmenux-monitor.service')" \ + "$(TAPM_POST_PATH '/opt/.PROXMENUX_POST_INSTALL')" || cleanup_status=1 + menu_launcher="$(TAPM_POST_PATH '/usr/local/bin/menu')" + if TAPM_POST_PROXMENUX_MENU_LAUNCHER; then + rm -f -- "$menu_launcher" || cleanup_status=1 + fi + rm -rf -- \ + "$(TAPM_POST_PATH '/usr/local/share/proxmenux')" \ + "$(TAPM_POST_PATH '/root/.config/proxmenux-monitor')" \ + "$(TAPM_POST_PATH '/var/lib/proxmenux')" || cleanup_status=1 + if (( had_app == 1 )); then + rm -rf -- "$(TAPM_POST_PATH '/opt/googletrans-env')" || cleanup_status=1 + fi + + bashrc_backup="$(TAPM_POST_PATH '/root/.bashrc.bak')" + if [[ -f "$bashrc_backup" ]] && + grep -qi 'proxmenux' "$(TAPM_POST_PATH '/root/.bashrc')" 2>/dev/null; then + mv -f -- "$bashrc_backup" "$(TAPM_POST_PATH '/root/.bashrc')" || + cleanup_status=1 + fi + motd_backup="$(TAPM_POST_PATH '/etc/motd.bak')" + if [[ -f "$motd_backup" ]] && + grep -qi 'proxmenux' "$(TAPM_POST_PATH '/etc/motd')" 2>/dev/null; then + mv -f -- "$motd_backup" "$(TAPM_POST_PATH '/etc/motd')" || + cleanup_status=1 + else + TAPM_POST_REMOVE_EXACT_LINE '/etc/motd' \ + 'This system is optimised by: ProxMenux' || cleanup_status=1 + fi + if TAPM_POST_LIVE_ROOT; then + systemctl daemon-reload >/dev/null 2>&1 || true + systemctl reset-failed >/dev/null 2>&1 || true + fi + return "$cleanup_status" +} + +TAPM_POST_APPLY_SYSCTL() { + TAPM_POST_LIVE_ROOT || return 0 + sysctl --system >/dev/null +} + +TAPM_POST_STATE() { + local status="$1" + local step="$2" + local state_dir + + state_dir="$(TAPM_POST_PATH "$TAPM_POST_STATE_DIR")" + mkdir -p -m 0750 -- "$state_dir" || return 1 + { + printf 'status=%s\n' "$status" + printf 'step=%s\n' "$step" + printf 'updated=%s\n' "$(date --iso-8601=seconds 2>/dev/null || date)" + printf 'backup=%s\n' "$TAPM_POST_LAST_BACKUP" + } >"${state_dir}/state" +} + +TAPM_POST_RUN_STEP() { + local label="$1" + shift + + printf ' - %s...\n' "$label" + TAPM_POST_STATE in_progress "$label" || return 1 + if "$@"; then + printf ' OK\n' + return 0 + fi + TAPM_POST_LAST_ERROR="$label failed." + TAPM_POST_STATE failed "$label" || true + return 1 +} + +TAPM_POST_SAVE_REPORT() { + local mode="$1" + local state_dir + + state_dir="$(TAPM_POST_PATH "$TAPM_POST_STATE_DIR")" + mkdir -p -m 0750 -- "$state_dir" || return 1 + { + printf 'TA-ProxMenu host configuration report\n' + printf 'Completed: %s\n' "$(date --iso-8601=seconds 2>/dev/null || date)" + printf 'Mode: %s\n' "$mode" + printf 'Host: %s\n' "$(hostname 2>/dev/null || printf unknown)" + printf 'Backup: %s\n' "$TAPM_POST_LAST_BACKUP" + printf 'ProxMenux detected after completion: ' + TAPM_POST_PROXMENUX_DETECTED && printf 'yes\n' || printf 'no\n' + printf '\nSelected profile:\n' + printf ' Utilities: %s\n' "$TAPM_POST_DO_UTILITIES" + printf ' Time synchronization: %s\n' "$TAPM_POST_DO_TIME" + printf ' Kernel panic recovery: %s\n' "$TAPM_POST_DO_PANIC" + printf ' Inotify limits: %s\n' "$TAPM_POST_DO_LIMITS" + printf ' Journald: %s\n' "$TAPM_POST_DO_JOURNALD" + printf ' Log rotation: %s\n' "$TAPM_POST_DO_LOGROTATE" + printf ' Memory behavior: %s\n' "$TAPM_POST_DO_MEMORY" + printf ' Network safeguards: %s\n' "$TAPM_POST_DO_NETWORK" + printf ' BBR/TCP Fast Open: %s\n' "$TAPM_POST_DO_BBR" + printf ' Native pigz: %s\n' "$TAPM_POST_DO_PIGZ" + printf ' APT network check: %s\n' "$TAPM_POST_DO_APT_NETWORK" + } >"${state_dir}/last-report.txt" + TAPM_POST_STATE complete complete +} + +TAPM_POST_APPLY_PROFILE() { + local mode="${1:-apply}" + local migrate=0 + + [[ "$mode" == migrate ]] && migrate=1 + TAPM_POST_LAST_ERROR='' + TAPM_POST_PRECHECK || return 1 + if (( migrate == 1 )) && ! TAPM_POST_PROXMENUX_DETECTED; then + TAPM_POST_LAST_ERROR='No ProxMenux installation or recognized artifacts were detected.' + return 1 + fi + TAPM_POST_BACKUP >/dev/null || { + TAPM_POST_LAST_ERROR='The configuration backup could not be created.' + return 1 + } + + TAPM_POST_RUN_STEP 'Validating the TAPM-managed APT source layout' \ + TAPM_POST_ENSURE_APT_LAYOUT || return 1 + if (( migrate == 1 )); then + TAPM_POST_RUN_STEP 'Repairing the ProxMenux gzip replacement' \ + TAPM_POST_REPAIR_GZIP || return 1 + fi + (( TAPM_POST_DO_UTILITIES == 0 )) || + TAPM_POST_RUN_STEP 'Installing missing system utilities' \ + TAPM_POST_INSTALL_PACKAGES || return 1 + (( TAPM_POST_DO_TIME == 0 )) || + TAPM_POST_RUN_STEP 'Configuring time synchronization' \ + TAPM_POST_CONFIGURE_TIME || return 1 + (( TAPM_POST_DO_PANIC == 0 )) || + TAPM_POST_RUN_STEP 'Configuring kernel panic recovery' \ + TAPM_POST_CONFIGURE_PANIC || return 1 + (( TAPM_POST_DO_LIMITS == 0 )) || + TAPM_POST_RUN_STEP 'Configuring conservative inotify limits' \ + TAPM_POST_CONFIGURE_LIMITS || return 1 + (( TAPM_POST_DO_JOURNALD == 0 )) || + TAPM_POST_RUN_STEP 'Configuring journald retention' \ + TAPM_POST_CONFIGURE_JOURNALD || return 1 + (( TAPM_POST_DO_LOGROTATE == 0 )) || + TAPM_POST_RUN_STEP 'Verifying and repairing log rotation' \ + TAPM_POST_CONFIGURE_LOGROTATE || return 1 + (( TAPM_POST_DO_MEMORY == 0 )) || + TAPM_POST_RUN_STEP 'Configuring host memory behavior' \ + TAPM_POST_CONFIGURE_MEMORY || return 1 + (( TAPM_POST_DO_NETWORK == 0 )) || + TAPM_POST_RUN_STEP 'Applying network safeguards' \ + TAPM_POST_CONFIGURE_NETWORK || return 1 + (( TAPM_POST_DO_BBR == 0 )) || + TAPM_POST_RUN_STEP 'Enabling BBR and TCP Fast Open' \ + TAPM_POST_CONFIGURE_BBR || return 1 + (( TAPM_POST_DO_PIGZ == 0 )) || + TAPM_POST_RUN_STEP 'Enabling native parallel gzip for vzdump' \ + TAPM_POST_CONFIGURE_PIGZ || return 1 + (( TAPM_POST_DO_APT_NETWORK == 0 )) || + TAPM_POST_RUN_STEP 'Checking APT network compatibility' \ + TAPM_POST_APT_NETWORK_CHECK || return 1 + + if (( migrate == 1 )); then + TAPM_POST_RUN_STEP 'Removing recognized ProxMenux settings' \ + TAPM_POST_CLEAN_PROXMENUX_SETTINGS || return 1 + # Reassert TAPM files after removing the older ProxMenux equivalents. + (( TAPM_POST_DO_PANIC == 0 )) || TAPM_POST_CONFIGURE_PANIC || return 1 + (( TAPM_POST_DO_LIMITS == 0 )) || TAPM_POST_CONFIGURE_LIMITS || return 1 + (( TAPM_POST_DO_MEMORY == 0 )) || TAPM_POST_CONFIGURE_MEMORY || return 1 + (( TAPM_POST_DO_NETWORK == 0 )) || TAPM_POST_CONFIGURE_NETWORK || return 1 + (( TAPM_POST_DO_BBR == 0 )) || TAPM_POST_CONFIGURE_BBR || return 1 + fi + + TAPM_POST_RUN_STEP 'Applying and validating kernel settings' \ + TAPM_POST_APPLY_SYSCTL || return 1 + if (( migrate == 1 )); then + TAPM_POST_RUN_STEP 'Removing the ProxMenux application' \ + TAPM_POST_REMOVE_PROXMENUX_APP || return 1 + fi + TAPM_POST_SAVE_REPORT "$mode" || return 1 +} + +TAPM_POST_AUDIT() { + local state_file + local report_file + + printf 'TA-ProxMenu host configuration audit\n\n' + printf 'ProxMenux installation/artifacts: ' + TAPM_POST_PROXMENUX_DETECTED && printf 'detected\n' || printf 'not detected\n' + printf 'ProxMenux gzip replacement: ' + TAPM_POST_PROXMENUX_GZIP_WRAPPER && printf 'detected - migration required\n' || + printf 'not detected\n' + printf 'ProxMenux journald replacement: ' + TAPM_POST_PROXMENUX_JOURNALD && printf 'detected\n' || printf 'not detected\n' + printf 'ProxMenux logrotate replacement: ' + TAPM_POST_PROXMENUX_LOGROTATE && printf 'detected\n' || printf 'not detected\n' + printf 'APT language suppression: ' + TAPM_POST_EXACT_APT_LANGUAGES && printf 'enabled by ProxMenux\n' || + printf 'not detected\n' + printf 'APT forced IPv4: ' + TAPM_POST_EXACT_APT_IPV4 && printf 'enabled by ProxMenux\n' || + printf 'not detected\n' + + printf '\nTAPM managed files:\n' + while IFS= read -r report_file; do + printf ' %-58s %s\n' "$report_file" \ + "$([[ -e "$(TAPM_POST_PATH "$report_file")" ]] && + printf configured || printf absent)" + done < <(TAPM_POST_MANAGED_FILES) + + state_file="$(TAPM_POST_PATH "$TAPM_POST_STATE_DIR/state")" + if [[ -f "$state_file" ]]; then + printf '\nLast TAPM state:\n' + sed 's/^/ /' "$state_file" + fi +} + +TAPM_POST_MIGRATION_PLAN() { + printf '\nRecognized migration plan:\n' + printf ' %-34s %s\n' 'ProxMenux application' \ + "$([[ -d "$(TAPM_POST_PATH '/usr/local/share/proxmenux')" ]] && + printf 'remove after validation' || printf 'not detected')" + printf ' %-34s %s\n' 'ProxMenux Monitor service' \ + "$([[ -e "$(TAPM_POST_PATH '/etc/systemd/system/proxmenux-monitor.service')" ]] && + printf 'stop and remove' || printf 'not detected')" + printf ' %-34s %s\n' '/bin/gzip replacement' \ + "$(TAPM_POST_PROXMENUX_GZIP_WRAPPER && + printf 'repair and verify' || printf 'not detected')" + printf ' %-34s %s\n' 'journald replacement' \ + "$(TAPM_POST_PROXMENUX_JOURNALD && + printf 'replace with TAPM drop-in' || printf 'not detected')" + printf ' %-34s %s\n' 'logrotate replacement' \ + "$(TAPM_POST_PROXMENUX_LOGROTATE && + printf 'restore or repair' || printf 'not detected')" + printf ' %-34s %s\n' 'APT language suppression' \ + "$(TAPM_POST_EXACT_APT_LANGUAGES && + printf 'remove' || printf 'not detected')" + printf ' %-34s %s\n' 'Unconditional APT IPv4' \ + "$(TAPM_POST_EXACT_APT_IPV4 && + printf 'remove' || printf 'not detected')" + printf ' %-34s %s\n' 'Shared dependencies' 'retain' + printf ' %-34s %s\n' 'Unknown administrator files' 'retain and report' +} diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 74add7e..2cd36a3 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -7,6 +7,7 @@ source /opt/idssys/defaults/default.inc source /opt/idssys/ta-proxmenu/defaults.inc source /opt/idssys/ta-proxmenu/inc/git-update.inc source /opt/idssys/ta-proxmenu/inc/ha-status.inc +source /opt/idssys/ta-proxmenu/inc/post-install.inc source /opt/idssys/ta-proxmenu/inc/deploy-iso-nfs-lxc.sh source /opt/idssys/ta-proxmenu/inc/deploy-pulse-lxc.sh source /opt/idssys/ta-proxmenu/inc/virtio-helpers.inc @@ -280,97 +281,6 @@ INSTALL_ACRONIS() { FINISH_ACTION } -INSTALL_PROXMENUX() { - local installer - local temp_dir - - if ! TAPM_CREATE_TEMP_DIR proxmenux; then - FINISH_FAILED_ACTION - return - fi - temp_dir="$TAPM_TEMP_DIR" - installer="${temp_dir}/install.sh" - - if ! TAPM_DOWNLOAD_HTTPS \ - 'https://raw.githubusercontent.com/MacRimi/ProxMenux/main/install_proxmenux.sh' \ - "$installer" 'ProxMenux installer' || - ! bash "$installer" || - [[ ! -x /usr/local/bin/menu ]]; then - TAPM_CLEAN_TEMP_DIR "$temp_dir" - echo -e "${idsCL[LightRed]}ProxMenux installation failed or could not be verified.${idsCL[Default]}" - FINISH_FAILED_ACTION - return - fi - - TAPM_CLEAN_TEMP_DIR "$temp_dir" - /usr/local/bin/menu -} - -PROXMENUX_POST_INSTALL() { - local backup_dir='/var/backups/ta-proxmenu' - local backup_file='' - local post_install_dir='/usr/local/share/proxmenux/scripts/post_install' - local post_install_script="${post_install_dir}/customizable_post_install.sh" - local timestamp - - if [[ ! -f "$post_install_script" ]]; then - INSTALL_PROXMENUX - [[ -f "$post_install_script" ]] || return 1 - fi - - if ! bash "$post_install_script"; then - echo -e "${idsCL[LightRed]}The ProxMenux post-install script failed. A completion marker was not created.${idsCL[Default]}" - FINISH_FAILED_ACTION - return - fi - - if [[ -e /etc/apt/sources.list && ! -f /etc/apt/sources.list ]]; then - echo -e "${idsCL[LightRed]}/etc/apt/sources.list is not a regular file and was not changed.${idsCL[Default]}" - FINISH_FAILED_ACTION - return - fi - - if [[ -s /etc/apt/sources.list ]]; then - timestamp="$(date +%Y%m%d-%H%M%S)" - if ! install -d -m 0700 "$backup_dir" || - ! backup_file="$(mktemp "${backup_dir}/sources.list.proxmenux.${timestamp}.XXXXXX")" || - ! install -m 0600 /etc/apt/sources.list "$backup_file"; then - [[ -n "$backup_file" ]] && rm -f -- "$backup_file" - echo -e "${idsCL[LightRed]}Could not back up /etc/apt/sources.list; it was not cleared.${idsCL[Default]}" - FINISH_FAILED_ACTION - return - fi - fi - - if ! : >/etc/apt/sources.list || - ! chmod 0644 /etc/apt/sources.list; then - echo -e "${idsCL[LightRed]}Could not enforce an empty /etc/apt/sources.list.${idsCL[Default]}" - [[ -n "$backup_file" ]] && - echo "Backup retained at: ${backup_file}" - FINISH_FAILED_ACTION - return - fi - - if ! apt-get update; then - echo -e "${idsCL[LightRed]}APT repository validation failed after ProxMenux post-install.${idsCL[Default]}" - [[ -n "$backup_file" ]] && - echo "Removed sources were retained at: ${backup_file}" - FINISH_FAILED_ACTION - return - fi - - if ! touch /opt/.PROXMENUX_POST_INSTALL; then - echo -e "${idsCL[LightRed]}Post-install succeeded, but the completion marker could not be created.${idsCL[Default]}" - FINISH_FAILED_ACTION - return - fi - - echo -e "\n${idsCL[Green]}ProxMenux post-install completed and APT sources were validated.${idsCL[Default]}" - [[ -n "$backup_file" ]] && - echo "ProxMenux additions to sources.list were saved at: ${backup_file}" - FINISH_ACTION -} - INSTALL_GLANCES() { echo if ! DEBIAN_FRONTEND=noninteractive apt-get install glances -y || @@ -1860,6 +1770,389 @@ SELECT_MENU() { done } +TAPM_POST_PROFILE_SUMMARY() { + echo + echo -e " ${idsCL[LightCyan]}Recommended TAPM host profile${idsCL[Default]}" + echo + echo " [x] System utilities and CPU-appropriate microcode" + echo " [x] Chrony time synchronization (existing timezone preserved)" + echo " [x] Kernel panic recovery (30 seconds)" + echo " [x] Conservative inotify limits" + echo " [x] Persistent journald (1 GiB / 30 days)" + echo " [x] Verify and repair log rotation" + echo " [x] Host memory behavior (swappiness 10)" + echo " [x] Network safeguards" + echo " [x] BBR and TCP Fast Open" + echo " [x] Native parallel gzip support for vzdump" + echo " [ ] APT network compatibility / conditional IPv4 (optional)" + echo + echo " Global vzdump bandwidth and I/O-priority overrides are not applied." +} + +TAPM_POST_FAILURE_RECOVERY() { + local backup_dir="$1" + local mode="$2" + local -a labels=( + "Retry the selected profile" + "Restore the pre-change backup" + "Stop and return to the menu" + ) + local -a values=(retry restore stop) + + while true; do + echo + [[ -n "$backup_dir" ]] && echo " Pre-change backup: ${backup_dir}" + SELECT_MENU "Host Configuration Recovery" labels values + case "$MENU_SELECTION" in + retry) + if TAPM_POST_APPLY_PROFILE "$mode"; then + echo -e "\n${idsCL[Green]}The profile retry completed successfully.${idsCL[Default]}" + echo " New backup: ${TAPM_POST_LAST_BACKUP}" + return 0 + fi + echo -e "\n${idsCL[LightRed]}${TAPM_POST_LAST_ERROR:-The retry failed.}${idsCL[Default]}" + ;; + restore) + if [[ -n "$backup_dir" ]] && + TAPM_POST_RESTORE_BACKUP "$backup_dir"; then + echo -e "\n${idsCL[Green]}The pre-change configuration was restored.${idsCL[Default]}" + return 0 + fi + echo -e "\n${idsCL[LightRed]}The pre-change backup could not be restored.${idsCL[Default]}" + ;; + stop|back) return 1 ;; + quit) EXIT1; exit 0 ;; + esac + done +} + +TAPM_POST_APPLY_RECOMMENDED() { + local backup_dir + local confirmation + + TAPM_POST_DEFAULT_SELECTIONS + TAPM_POST_PROFILE_SUMMARY + read -r -p " Apply this profile to the current host (type yes to continue)? " \ + confirmation + [[ "${confirmation,,}" == yes ]] || return + + if TAPM_POST_APPLY_PROFILE apply; then + echo -e "\n${idsCL[Green]}The TAPM host profile was applied and verified.${idsCL[Default]}" + echo " Backup: ${TAPM_POST_LAST_BACKUP}" + FINISH_ACTION + return + fi + echo -e "\n${idsCL[LightRed]}${TAPM_POST_LAST_ERROR:-Host profile application failed.}${idsCL[Default]}" + backup_dir="$TAPM_POST_LAST_BACKUP" + if TAPM_POST_FAILURE_RECOVERY "$backup_dir" apply; then + FINISH_ACTION + else + FINISH_FAILED_ACTION + fi +} + +TAPM_POST_MIGRATE_PROXMENUX() { + local backup_dir + local confirmation + + if ! TAPM_POST_PROXMENUX_DETECTED; then + echo -e "\n${idsCL[LightYellow]}No ProxMenux installation or recognized artifacts were detected.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + TAPM_POST_DEFAULT_SELECTIONS + TAPM_POST_PROFILE_SUMMARY + TAPM_POST_MIGRATION_PLAN + echo + echo -e " ${idsCL[LightYellow]}Migration will repair recognized ProxMenux settings, restore gzip," + echo -e " apply the TAPM profile, and then remove ProxMenux itself.${idsCL[Default]}" + echo " Shared packages such as dialog, jq, curl, Git, and Python are retained." + echo + read -r -p " Migrate this host and remove ProxMenux (type yes to continue)? " \ + confirmation + [[ "${confirmation,,}" == yes ]] || return + + if TAPM_POST_APPLY_PROFILE migrate; then + echo -e "\n${idsCL[Green]}The host was migrated to the TAPM profile and ProxMenux was removed.${idsCL[Default]}" + echo " Backup: ${TAPM_POST_LAST_BACKUP}" + FINISH_ACTION + return + fi + echo -e "\n${idsCL[LightRed]}${TAPM_POST_LAST_ERROR:-ProxMenux migration failed.}${idsCL[Default]}" + echo " ProxMenux removal is the final migration step; earlier failures leave it installed." + backup_dir="$TAPM_POST_LAST_BACKUP" + if TAPM_POST_FAILURE_RECOVERY "$backup_dir" migrate; then + FINISH_ACTION + else + FINISH_FAILED_ACTION + fi +} + +TAPM_POST_FLAG_LABEL() { + local value="$1" + local label="$2" + + (( value == 1 )) && printf '[x] %s' "$label" || printf '[ ] %s' "$label" +} + +TAPM_POST_TOGGLE_FLAG() { + local variable_name="$1" + local current_value="${!variable_name}" + + (( current_value == 1 )) && + printf -v "$variable_name" '%d' 0 || + printf -v "$variable_name" '%d' 1 +} + +TAPM_POST_CUSTOMIZE() { + local -a labels + local -a values + + TAPM_POST_DEFAULT_SELECTIONS + while true; do + labels=( + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_UTILITIES" "System utilities")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_TIME" "Time synchronization")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_PANIC" "Kernel panic recovery")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_LIMITS" "Conservative inotify limits")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_JOURNALD" "Journald — 1 GiB / 30 days")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_LOGROTATE" "Verify and repair log rotation")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_MEMORY" "Host memory behavior")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_NETWORK" "Network safeguards")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_BBR" "BBR and TCP Fast Open")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_PIGZ" "Native parallel gzip for vzdump")" + "$(TAPM_POST_FLAG_LABEL "$TAPM_POST_DO_APT_NETWORK" "APT network compatibility check")" + "Apply selected profile" + ) + values=( + utilities time panic limits journald logrotate memory network bbr pigz + apt_network apply + ) + SELECT_MENU "Customize TAPM Host Profile" labels values + case "$MENU_SELECTION" in + utilities) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_UTILITIES ;; + time) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_TIME ;; + panic) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_PANIC ;; + limits) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_LIMITS ;; + journald) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_JOURNALD ;; + logrotate) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_LOGROTATE ;; + memory) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_MEMORY ;; + network) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_NETWORK ;; + bbr) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_BBR ;; + pigz) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_PIGZ ;; + apt_network) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_APT_NETWORK ;; + apply) + local backup_dir + local confirmation + echo + read -r -p " Apply the selected profile (type yes to continue)? " \ + confirmation + [[ "${confirmation,,}" == yes ]] || continue + if TAPM_POST_APPLY_PROFILE custom; then + echo -e "\n${idsCL[Green]}The custom TAPM profile was applied.${idsCL[Default]}" + echo " Backup: ${TAPM_POST_LAST_BACKUP}" + FINISH_ACTION + return + fi + echo -e "\n${idsCL[LightRed]}${TAPM_POST_LAST_ERROR:-Custom profile application failed.}${idsCL[Default]}" + backup_dir="$TAPM_POST_LAST_BACKUP" + if TAPM_POST_FAILURE_RECOVERY "$backup_dir" custom; then + FINISH_ACTION + else + FINISH_FAILED_ACTION + fi + return + ;; + back) return ;; + quit) EXIT1; exit 0 ;; + esac + done +} + +TAPM_POST_SHOW_AUDIT() { + MENU_HEADER + echo + TAPM_POST_AUDIT + FINISH_ACTION +} + +TAPM_POST_SHOW_REPORT() { + local report='/var/lib/ta-proxmenu/post-install/last-report.txt' + + MENU_HEADER + echo + if [[ -f "$report" ]]; then + cat "$report" + else + echo -e " ${idsCL[LightYellow]}No completed TAPM host configuration report is available.${idsCL[Default]}" + fi + FINISH_ACTION +} + +TAPM_POST_RESTORE_MENU() { + local backup_base='/var/backups/ta-proxmenu/post-install' + local -a labels=() + local -a values=() + local backup_dir + local confirmation + + if [[ -d "$backup_base" ]]; then + while IFS= read -r backup_dir; do + [[ -f "${backup_dir}/manifest.tsv" ]] || continue + labels+=("${backup_dir##*/}") + values+=("$backup_dir") + done < <(find "$backup_base" -mindepth 1 -maxdepth 1 -type d | + sort -r) + fi + if (( ${#labels[@]} == 0 )); then + echo -e "\n${idsCL[LightYellow]}No TAPM host configuration backups were found.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + + SELECT_MENU "Restore TAPM Host Configuration Backup" labels values + case "$MENU_SELECTION" in + back) return ;; + quit) EXIT1; exit 0 ;; + esac + backup_dir="$MENU_SELECTION" + echo + echo " Selected backup: $backup_dir" + echo " Restoring may also reinstate files or services removed during migration." + read -r -p " Restore this backup (type restore to continue)? " confirmation + [[ "${confirmation,,}" == restore ]] || return + if TAPM_POST_RESTORE_BACKUP "$backup_dir"; then + echo -e "\n${idsCL[Green]}The selected configuration backup was restored.${idsCL[Default]}" + FINISH_ACTION + return + fi + echo -e "\n${idsCL[LightRed]}The selected backup could not be fully restored.${idsCL[Default]}" + FINISH_FAILED_ACTION +} + +TAPM_POST_VZDUMP_APPLY() { + local mode="$1" + local bandwidth="${2:-}" + local ionice="${3:-}" + + TAPM_POST_LAST_ERROR='' + if ! TAPM_POST_PRECHECK; then + echo -e "\n${idsCL[LightRed]}$TAPM_POST_LAST_ERROR${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + if ! TAPM_POST_BACKUP >/dev/null; then + echo -e "\n${idsCL[LightRed]}The vzdump configuration backup failed.${idsCL[Default]}" + FINISH_FAILED_ACTION + return + fi + case "$mode" in + defaults) + TAPM_POST_REMOVE_COLON_KEY /etc/vzdump.conf bwlimit && + TAPM_POST_REMOVE_COLON_KEY /etc/vzdump.conf ionice + ;; + maximum) + TAPM_POST_SET_COLON_KEY /etc/vzdump.conf bwlimit 0 && + TAPM_POST_SET_COLON_KEY /etc/vzdump.conf ionice 5 + ;; + custom) + TAPM_POST_SET_COLON_KEY /etc/vzdump.conf bwlimit "$bandwidth" && + TAPM_POST_SET_COLON_KEY /etc/vzdump.conf ionice "$ionice" + ;; + esac + if (( $? == 0 )); then + echo -e "\n${idsCL[Green]}vzdump performance settings were updated.${idsCL[Default]}" + echo " Backup: ${TAPM_POST_LAST_BACKUP}" + FINISH_ACTION + return + fi + echo -e "\n${idsCL[LightRed]}vzdump performance settings could not be updated.${idsCL[Default]}" + FINISH_FAILED_ACTION +} + +TAPM_POST_VZDUMP_MENU() { + local -a labels=( + "Use Proxmox defaults — recommended" + "Maximum throughput — unlimited, ionice 5" + "Set a custom bandwidth limit" + "Restore a TAPM configuration backup" + ) + local -a values=(defaults maximum custom restore) + local confirmation + local bandwidth_mib + local bandwidth_kib + local ionice + + SELECT_MENU "Configure vzdump Performance" labels values + case "$MENU_SELECTION" in + defaults) + TAPM_POST_VZDUMP_APPLY defaults + ;; + maximum) + echo + echo -e "${idsCL[LightYellow]}This can saturate shared storage and increase guest latency.${idsCL[Default]}" + read -r -p " Enable maximum throughput (type yes to continue)? " confirmation + [[ "${confirmation,,}" == yes ]] && + TAPM_POST_VZDUMP_APPLY maximum + ;; + custom) + echo + while true; do + read -r -p " Bandwidth limit in MiB/s (0 means unlimited): " bandwidth_mib + [[ "$bandwidth_mib" =~ ^[0-9]+$ ]] && break + echo " Enter a nonnegative whole number." + done + while true; do + read -r -p " I/O priority [0-8, Proxmox default is 7]: " ionice + [[ "$ionice" =~ ^[0-8]$ ]] && break + echo " Enter a value from 0 through 8." + done + bandwidth_kib=$((bandwidth_mib * 1024)) + TAPM_POST_VZDUMP_APPLY custom "$bandwidth_kib" "$ionice" + ;; + restore) TAPM_POST_RESTORE_MENU ;; + back) return ;; + quit) EXIT1; exit 0 ;; + esac +} + +TAPM_HOST_CONFIGURATION_MENU() { + local -a labels + local -a values + + while true; do + labels=( + "Audit current host" + "Apply/repair recommended TAPM profile" + "Customize TAPM profile" + ) + values=(audit apply customize) + if TAPM_POST_PROXMENUX_DETECTED; then + labels+=("** Migrate from ProxMenux — RECOMMENDED **") + values+=(migrate) + fi + labels+=( + "Configure vzdump performance" + "Restore a TAPM configuration backup" + "View last configuration report" + ) + values+=(vzdump restore report) + + SELECT_MENU "TAPM Host Configuration" labels values + case "$MENU_SELECTION" in + audit) TAPM_POST_SHOW_AUDIT ;; + apply) TAPM_POST_APPLY_RECOMMENDED ;; + customize) TAPM_POST_CUSTOMIZE ;; + migrate) TAPM_POST_MIGRATE_PROXMENUX ;; + vzdump) TAPM_POST_VZDUMP_MENU ;; + restore) TAPM_POST_RESTORE_MENU ;; + report) TAPM_POST_SHOW_REPORT ;; + back) return ;; + quit) EXIT1; exit 0 ;; + esac + done +} + SHOW_ABOUT() { MENU_HEADER echo @@ -1877,10 +2170,10 @@ HOST_SETUP_MENU() { local -a values while true; do - labels=("Run ProxMenux post-install configuration") + labels=("TAPM Host Configuration") values=("post_install") - [ -f /opt/.PROXMENUX_POST_INSTALL ] && - labels[0]="Run ProxMenux post-install configuration (previously run)" + TAPM_POST_PROXMENUX_DETECTED && + labels[0]="TAPM Host Configuration — ProxMenux migration recommended" labels+=("Detect CPU model for live migrations") values+=("cpu") @@ -1902,7 +2195,7 @@ HOST_SETUP_MENU() { SELECT_MENU "Host Setup" labels values case "$MENU_SELECTION" in - post_install) PROXMENUX_POST_INSTALL;; + post_install) TAPM_HOST_CONFIGURATION_MENU;; cpu) DETECT_CPU;; virtio) VIRTIO_MENU;; glances) INSTALL_GLANCES;; @@ -2285,14 +2578,7 @@ if (( ACTION_REQUESTED == 1 )); then omsa) INSTALL_OMSA;; glances) INSTALL_GLANCES;; acronis) INSTALL_ACRONIS;; - post-install|post_install) PROXMENUX_POST_INSTALL;; - proxmenux) - if [[ ! -f /usr/local/bin/menu ]]; then - INSTALL_PROXMENUX - else - /usr/local/bin/menu - fi - ;; + post-install|post_install|proxmenux) TAPM_HOST_CONFIGURATION_MENU;; virtio) VIRTIO_MENU;; sentinelone|s1) INSTALL_S1;; screenconnect) INSTALL_SCREENCONNECT;; diff --git a/tests/test-post-install.sh b/tests/test-post-install.sh new file mode 100644 index 0000000..331a903 --- /dev/null +++ b/tests/test-post-install.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${TEST_ROOT}/tests/testlib.sh" +source "${TEST_ROOT}/inc/post-install.inc" + +POST_TEST_ROOT="$(mktemp -d /tmp/tapm-post-install.XXXXXX)" +TAPM_HOST_ROOT="$POST_TEST_ROOT" + +cleanup_post_install_tests() { + if [[ "$POST_TEST_ROOT" == /tmp/tapm-post-install.* && + -d "$POST_TEST_ROOT" ]]; then + rm -rf -- "$POST_TEST_ROOT" + fi +} +trap cleanup_post_install_tests EXIT + +mkdir -p \ + "${POST_TEST_ROOT}/etc/apt/apt.conf.d" \ + "${POST_TEST_ROOT}/etc/systemd" \ + "${POST_TEST_ROOT}/etc/logrotate.d" \ + "${POST_TEST_ROOT}/etc/sysctl.d" \ + "${POST_TEST_ROOT}/etc/network" \ + "${POST_TEST_ROOT}/etc" \ + "${POST_TEST_ROOT}/bin" + +printf 'Acquire::Languages "none";\n' \ + >"${POST_TEST_ROOT}/etc/apt/apt.conf.d/99-disable-translations" +assert_success "ProxMenux APT language setting recognized" \ + TAPM_POST_EXACT_APT_LANGUAGES + +printf 'Acquire::Languages "en";\n' \ + >"${POST_TEST_ROOT}/etc/apt/apt.conf.d/99-disable-translations" +assert_failure "custom APT language setting preserved" \ + TAPM_POST_EXACT_APT_LANGUAGES + +cat >"${POST_TEST_ROOT}/etc/systemd/journald.conf" <<'EOF' +[Journal] +Storage=persistent +Seal=no +SystemMaxUse=64M +RuntimeMaxUse=60M +MaxLevelStore=info +EOF +assert_success "ProxMenux journald replacement recognized" \ + TAPM_POST_PROXMENUX_JOURNALD + +cat >"${POST_TEST_ROOT}/etc/logrotate.conf" <<'EOF' +# ProxMenux optimized configuration (Log2RAM-friendly) +daily +rotate 7 +size 10M +copytruncate +include /etc/logrotate.d +EOF +assert_success "ProxMenux logrotate replacement recognized" \ + TAPM_POST_PROXMENUX_LOGROTATE + +cat >"${POST_TEST_ROOT}/bin/gzip" <<'EOF' +#!/bin/sh +GZIP="-1" +exec /usr/bin/pigz "$@" +EOF +cat >"${POST_TEST_ROOT}/bin/gzip.original" <<'EOF' +#!/bin/sh +if [ "${1:-}" = "--version" ]; then + printf 'gzip 1.13\n' +else + cat +fi +EOF +cp "${POST_TEST_ROOT}/bin/gzip" "${POST_TEST_ROOT}/bin/pigzwrapper" +chmod 0755 \ + "${POST_TEST_ROOT}/bin/gzip" \ + "${POST_TEST_ROOT}/bin/gzip.original" \ + "${POST_TEST_ROOT}/bin/pigzwrapper" +assert_success "ProxMenux gzip wrapper recognized" \ + TAPM_POST_PROXMENUX_GZIP_WRAPPER +assert_success "ProxMenux gzip wrapper repaired from valid original" \ + TAPM_POST_REPAIR_GZIP +assert_failure "repaired gzip is no longer recognized as wrapper" \ + TAPM_POST_PROXMENUX_GZIP_WRAPPER +assert_failure "obsolete gzip original removed after verification" \ + test -e "${POST_TEST_ROOT}/bin/gzip.original" + +assert_success "kernel panic profile written" TAPM_POST_CONFIGURE_PANIC +assert_success "inotify profile written" TAPM_POST_CONFIGURE_LIMITS +assert_success "memory profile written" TAPM_POST_CONFIGURE_MEMORY +assert_success "network profile written" TAPM_POST_CONFIGURE_NETWORK +assert_success "BBR profile written" TAPM_POST_CONFIGURE_BBR +assert_equal 30 \ + "$(awk '/kernel.panic =/ {print $3}' \ + "${POST_TEST_ROOT}/etc/sysctl.d/99-ta-proxmenu-kernel-panic.conf")" \ + "kernel panic delay" +assert_equal 524288 \ + "$(awk '/max_user_watches/ {print $3}' \ + "${POST_TEST_ROOT}/etc/sysctl.d/99-ta-proxmenu-limits.conf")" \ + "inotify watch limit" +assert_equal 10 \ + "$(awk '/vm.swappiness/ {print $3}' \ + "${POST_TEST_ROOT}/etc/sysctl.d/99-ta-proxmenu-memory.conf")" \ + "host swappiness" + +cat >"${POST_TEST_ROOT}/etc/vzdump.conf" <<'EOF' +# existing setting +bwlimit: 50000 +pigz: 4 +pigz: 8 +EOF +assert_success "vzdump key replacement" \ + TAPM_POST_SET_COLON_KEY /etc/vzdump.conf pigz 1 +assert_equal 1 \ + "$(awk -F: '/^pigz:/ {gsub(/[[:space:]]/, "", $2); print $2}' \ + "${POST_TEST_ROOT}/etc/vzdump.conf")" \ + "vzdump pigz setting is unique" +assert_success "vzdump key removal" \ + TAPM_POST_REMOVE_COLON_KEY /etc/vzdump.conf bwlimit +assert_failure "vzdump bandwidth setting removed" \ + grep -q '^bwlimit:' "${POST_TEST_ROOT}/etc/vzdump.conf" + +printf 'deb should-be-backed-up\n' \ + >"${POST_TEST_ROOT}/etc/apt/sources.list" +TAPM_POST_BACKUP_BASE='/var/backups/ta-proxmenu/post-install' +test_post_backup() { + TAPM_POST_BACKUP >/dev/null +} +assert_success "host configuration backup created" test_post_backup +printf 'changed\n' >"${POST_TEST_ROOT}/etc/apt/sources.list" +assert_success "host configuration backup restored" \ + TAPM_POST_RESTORE_BACKUP "$TAPM_POST_LAST_BACKUP" +assert_equal 'deb should-be-backed-up' \ + "$(cat "${POST_TEST_ROOT}/etc/apt/sources.list")" \ + "restored APT sources" + +assert_success "APT source layout enforced in fixture root" \ + TAPM_POST_ENSURE_APT_LAYOUT +assert_equal 0 \ + "$(wc -c <"${POST_TEST_ROOT}/etc/apt/sources.list" | tr -d ' ')" \ + "main APT sources list is empty" + +mkdir -p \ + "${POST_TEST_ROOT}/etc/security/limits.d" \ + "${POST_TEST_ROOT}/usr/local/share/proxmenux" \ + "${POST_TEST_ROOT}/usr/local/bin" +cat >"${POST_TEST_ROOT}/etc/sysctl.d/99-network.conf" <<'EOF' +# Custom administrator network profile +net.ipv4.ip_forward = 1 +EOF +cat >"${POST_TEST_ROOT}/etc/security/limits.d/99-limits.conf" <<'EOF' +# ProxMenux configuration +* soft nofile 1048576 +EOF +cat >"${POST_TEST_ROOT}/usr/local/bin/menu" <<'EOF' +#!/bin/sh +exec /usr/local/share/proxmenux/menu.sh +EOF +assert_success "recognized ProxMenux settings cleaned" \ + TAPM_POST_CLEAN_PROXMENUX_SETTINGS +assert_success "custom similarly named network profile retained" \ + test -f "${POST_TEST_ROOT}/etc/sysctl.d/99-network.conf" +assert_failure "recognized ProxMenux limits profile removed" \ + test -e "${POST_TEST_ROOT}/etc/security/limits.d/99-limits.conf" +assert_success "ProxMenux application removed" TAPM_POST_REMOVE_PROXMENUX_APP +assert_failure "ProxMenux menu launcher removed" \ + test -e "${POST_TEST_ROOT}/usr/local/bin/menu" + +finish_tests From 53a6e91218dba48620770f61c3936c3da97ffad2 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sun, 26 Jul 2026 01:20:41 -0500 Subject: [PATCH 54/76] update --- defaults.inc | 2 +- inc/post-install.inc | 2 +- tests/test-post-install.sh | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/defaults.inc b/defaults.inc index 5420f39..3fc68e5 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.26-1' +VERS='2026.7.26-2' noupdate=' ' diff --git a/inc/post-install.inc b/inc/post-install.inc index c2faa10..31ec1f5 100644 --- a/inc/post-install.inc +++ b/inc/post-install.inc @@ -300,7 +300,7 @@ TAPM_POST_PRECHECK() { fi pve_major="$( pveversion 2>/dev/null | - sed -n 's/.*pve-manager\\/\\([0-9][0-9]*\\).*/\\1/p' | + sed -n 's/.*pve-manager\/\([0-9][0-9]*\).*/\1/p' | head -1 )" if [[ "$pve_major" != '9' ]]; then diff --git a/tests/test-post-install.sh b/tests/test-post-install.sh index 331a903..58f8cff 100644 --- a/tests/test-post-install.sh +++ b/tests/test-post-install.sh @@ -5,6 +5,11 @@ TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "${TEST_ROOT}/tests/testlib.sh" source "${TEST_ROOT}/inc/post-install.inc" +assert_equal 9 \ + "$(printf '%s\n' 'pve-manager/9.0.3/0255bb4e9600f70c (running kernel: 6.14.8-2-pve)' | + sed -n 's/.*pve-manager\/\([0-9][0-9]*\).*/\1/p')" \ + "Proxmox major version extraction" + POST_TEST_ROOT="$(mktemp -d /tmp/tapm-post-install.XXXXXX)" TAPM_HOST_ROOT="$POST_TEST_ROOT" From 4c6bd2320f27190f26f9ce0f439b83748cec1c40 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sun, 26 Jul 2026 01:38:44 -0500 Subject: [PATCH 55/76] update --- defaults.inc | 2 +- proxmenu-scripts.sh | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/defaults.inc b/defaults.inc index 3fc68e5..dbe9109 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.26-2' +VERS='2026.7.26-3' noupdate=' ' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 2cd36a3..a64f6f5 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -1682,13 +1682,17 @@ SELECT_MENU() { local labels_name="$2" local values_name="$3" local allow_back="${4:-1}" + local allow_space="${5:-0}" + local initial_selected="${6:-0}" local -n labels_ref="$labels_name" local -n values_ref="$values_name" - local selected=0 + local selected="$initial_selected" local key local sequence local index + [[ "$selected" =~ ^[0-9]+$ ]] || selected=0 + (( selected < ${#labels_ref[@]} )) || selected=0 while true; do MENU_HEADER echo @@ -1704,12 +1708,18 @@ SELECT_MENU() { done echo - if (( allow_back == 1 )); then + if (( allow_back == 1 && allow_space == 1 )); then + echo " ↑/↓ Navigate Space Toggle Enter Select Number Quick Select ←/Esc/B Back Q Quit" + elif (( allow_back == 1 )); then echo " ↑/↓ Navigate Enter Select Number Quick Select ←/Esc/B Back Q Quit" + elif (( allow_space == 1 )); then + echo " ↑/↓ Navigate Space Toggle Enter Select Number Quick Select Q Quit" else echo " ↑/↓ Navigate Enter Select Number Quick Select Q Quit" fi + MENU_INPUT="" + MENU_SELECTED_INDEX="$selected" key="" if [[ "$UPDATE_STATUS" == "checking" ]]; then IFS= read -rsn1 -t 1 key || continue @@ -1718,12 +1728,23 @@ SELECT_MENU() { fi case "$key" in "") + MENU_INPUT="enter" MENU_SELECTION="${values_ref[$selected]}" return 0 ;; + " ") + if (( allow_space == 1 )); then + MENU_INPUT="space" + MENU_SELECTION="${values_ref[$selected]}" + return 0 + fi + ;; [1-9]) index=$((10#$key - 1)) if (( index < ${#values_ref[@]} )); then + selected="$index" + MENU_SELECTED_INDEX="$selected" + MENU_INPUT="number" MENU_SELECTION="${values_ref[$index]}" return 0 fi @@ -1907,6 +1928,7 @@ TAPM_POST_TOGGLE_FLAG() { TAPM_POST_CUSTOMIZE() { local -a labels local -a values + local selected_index=0 TAPM_POST_DEFAULT_SELECTIONS while true; do @@ -1928,7 +1950,12 @@ TAPM_POST_CUSTOMIZE() { utilities time panic limits journald logrotate memory network bbr pigz apt_network apply ) - SELECT_MENU "Customize TAPM Host Profile" labels values + SELECT_MENU "Customize TAPM Host Profile" labels values 1 1 \ + "$selected_index" + selected_index="$MENU_SELECTED_INDEX" + if [[ "$MENU_INPUT" == space && "$MENU_SELECTION" == apply ]]; then + continue + fi case "$MENU_SELECTION" in utilities) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_UTILITIES ;; time) TAPM_POST_TOGGLE_FLAG TAPM_POST_DO_TIME ;; From ffa89f9911c304d88b6db7697e19a4272bf965cf Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sun, 26 Jul 2026 15:37:11 -0500 Subject: [PATCH 56/76] update --- README.md | 25 +++++++++++++++++++++++++ config.env.example | 2 ++ defaults.inc | 15 ++++++++++++++- proxmenu-scripts.sh | 5 +++++ 4 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 config.env.example diff --git a/README.md b/README.md index 6e7ad8e..2177737 100644 --- a/README.md +++ b/README.md @@ -124,3 +124,28 @@ maintenance evacuation routing, HA affinity parsing, host-profile migration signatures and backups, and VirtIO filename validation. Tests use temporary files and mocked Proxmox output; they do not download installers or change a Proxmox host. +## Deployment broker URL + +TA-ProxMenu exchanges deployment codes with the configured TAPM broker. The +URL is selected in this order: + +1. Existing `TAPM_BROKER_URL` environment variable +2. `TAPM_BROKER_URL` in `/etc/ta-proxmenu/config.env` + +For a persistent per-system setting: + +```sh +install -d -m 0755 /etc/ta-proxmenu +install -m 0644 config.env.example /etc/ta-proxmenu/config.env +``` + +The tracked example contains only: + +```dotenv +TAPM_BROKER_URL=https://tapm.example.com +``` + +Replace it with the deployed HTTPS origin, without an API path. There is no +hard-coded operational broker URL; authorization fails with a configuration +message when the variable is missing or invalid. Repository updates do not +overwrite the system configuration file. diff --git a/config.env.example b/config.env.example new file mode 100644 index 0000000..1e8f1d9 --- /dev/null +++ b/config.env.example @@ -0,0 +1,2 @@ +# Copy to /etc/ta-proxmenu/config.env and replace with the deployed broker. +TAPM_BROKER_URL=https://tapm.example.com diff --git a/defaults.inc b/defaults.inc index dbe9109..8ef37e1 100755 --- a/defaults.inc +++ b/defaults.inc @@ -19,7 +19,20 @@ VIRTIO_STABLE_URL="https://fedorapeople.org/groups/virt/virtio-win/direct-downlo PULSE_RELEASE='v6.1.1' PULSE_PORT='7655' -TAPM_BROKER_URL='https://tapm.scity.us' +TAPM_CONFIG_FILE='/etc/ta-proxmenu/config.env' +if [[ -z "${TAPM_BROKER_URL:-}" && -r "$TAPM_CONFIG_FILE" ]]; then + TAPM_BROKER_URL="$( + sed -n 's/^[[:space:]]*TAPM_BROKER_URL[[:space:]]*=[[:space:]]*//p' \ + "$TAPM_CONFIG_FILE" | + tail -n 1 + )" + TAPM_BROKER_URL="${TAPM_BROKER_URL#\"}" + TAPM_BROKER_URL="${TAPM_BROKER_URL%\"}" + TAPM_BROKER_URL="${TAPM_BROKER_URL#\'}" + TAPM_BROKER_URL="${TAPM_BROKER_URL%\'}" +fi +TAPM_BROKER_URL="${TAPM_BROKER_URL:-}" +TAPM_BROKER_URL="${TAPM_BROKER_URL%/}" S1_BROKER_PACKAGE='sentinelone-linux' S1_PACKAGE='tapm-sentinelone.deb' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index a64f6f5..cd8694a 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -161,6 +161,11 @@ TAPM_AUTHORIZE() { local -a access TAPM_CLEAR_AUTHORIZATION + if ! TAPM_VALID_HTTPS_URL "${TAPM_BROKER_URL:-}"; then + echo -e "${idsCL[LightRed]}TAPM_BROKER_URL is not configured with a valid HTTPS origin.${idsCL[Default]}" + echo -e "${idsCL[LightYellow]}Set it in /etc/ta-proxmenu/config.env before authorizing this installation.${idsCL[Default]}" + return 1 + fi if ! command -v python3 >/dev/null 2>&1; then echo -e "${idsCL[LightRed]}Python 3 is required to authorize ${authorization_label}.${idsCL[Default]}" return 1 From 916916384fa72dcc85050f3b3aebda0684974f84 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sun, 26 Jul 2026 15:45:45 -0500 Subject: [PATCH 57/76] update --- README.md | 5 ++++- config.env.example | 1 + defaults.inc | 32 +++++++++++++++++++++----------- run.sh | 34 +++++++++++++++++++++++++++++++--- 4 files changed, 57 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2177737..e1eb704 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,12 @@ The tracked example contains only: ```dotenv TAPM_BROKER_URL=https://tapm.example.com +GITEA_DOMAIN=git.example.com ``` Replace it with the deployed HTTPS origin, without an API path. There is no hard-coded operational broker URL; authorization fails with a configuration -message when the variable is missing or invalid. Repository updates do not +message when the variable is missing or invalid. `GITEA_DOMAIN` is a hostname, +without `https://` or a path, and controls Gitea connectivity plus restoration +of the `voltron/iDS-Defaults.git` repository. Repository updates do not overwrite the system configuration file. diff --git a/config.env.example b/config.env.example index 1e8f1d9..1248320 100644 --- a/config.env.example +++ b/config.env.example @@ -1,2 +1,3 @@ # Copy to /etc/ta-proxmenu/config.env and replace with the deployed broker. TAPM_BROKER_URL=https://tapm.example.com +GITEA_DOMAIN=git.example.com diff --git a/defaults.inc b/defaults.inc index 8ef37e1..504e104 100755 --- a/defaults.inc +++ b/defaults.inc @@ -20,19 +20,29 @@ PULSE_RELEASE='v6.1.1' PULSE_PORT='7655' TAPM_CONFIG_FILE='/etc/ta-proxmenu/config.env' -if [[ -z "${TAPM_BROKER_URL:-}" && -r "$TAPM_CONFIG_FILE" ]]; then - TAPM_BROKER_URL="$( - sed -n 's/^[[:space:]]*TAPM_BROKER_URL[[:space:]]*=[[:space:]]*//p' \ - "$TAPM_CONFIG_FILE" | - tail -n 1 - )" - TAPM_BROKER_URL="${TAPM_BROKER_URL#\"}" - TAPM_BROKER_URL="${TAPM_BROKER_URL%\"}" - TAPM_BROKER_URL="${TAPM_BROKER_URL#\'}" - TAPM_BROKER_URL="${TAPM_BROKER_URL%\'}" -fi +for config_key in TAPM_BROKER_URL GITEA_DOMAIN; do + if [[ -z "${!config_key:-}" && -r "$TAPM_CONFIG_FILE" ]]; then + config_value="$( + sed -n "s/^[[:space:]]*${config_key}[[:space:]]*=[[:space:]]*//p" \ + "$TAPM_CONFIG_FILE" | + tail -n 1 + )" + config_value="${config_value#\"}" + config_value="${config_value%\"}" + config_value="${config_value#\'}" + config_value="${config_value%\'}" + printf -v "$config_key" '%s' "$config_value" + fi +done +unset config_key config_value TAPM_BROKER_URL="${TAPM_BROKER_URL:-}" TAPM_BROKER_URL="${TAPM_BROKER_URL%/}" +GITEA_DOMAIN="${GITEA_DOMAIN:-}" +if [[ "$GITEA_DOMAIN" =~ ^[A-Za-z0-9.-]+(:[0-9]+)?$ ]]; then + GITEA_URL="https://${GITEA_DOMAIN}" +else + GITEA_URL='' +fi S1_BROKER_PACKAGE='sentinelone-linux' S1_PACKAGE='tapm-sentinelone.deb' diff --git a/run.sh b/run.sh index babff3c..bdd1fae 100755 --- a/run.sh +++ b/run.sh @@ -5,6 +5,26 @@ DEFAULTS_REPOSITORY='/opt/idssys/defaults' DEFAULTS_CACHE_DIR='/var/cache/ta-proxmenu' DEFAULTS_CHECK_FILE="${DEFAULTS_CACHE_DIR}/defaults-last-check" DEFAULTS_CHECK_SECONDS=14400 +TAPM_CONFIG_FILE='/etc/ta-proxmenu/config.env' + +if [[ -z "${GITEA_DOMAIN:-}" && -r "$TAPM_CONFIG_FILE" ]]; then + GITEA_DOMAIN="$( + sed -n 's/^[[:space:]]*GITEA_DOMAIN[[:space:]]*=[[:space:]]*//p' \ + "$TAPM_CONFIG_FILE" | + tail -n 1 + )" + GITEA_DOMAIN="${GITEA_DOMAIN#\"}" + GITEA_DOMAIN="${GITEA_DOMAIN%\"}" + GITEA_DOMAIN="${GITEA_DOMAIN#\'}" + GITEA_DOMAIN="${GITEA_DOMAIN%\'}" +fi +if [[ "${GITEA_DOMAIN:-}" =~ ^[A-Za-z0-9.-]+(:[0-9]+)?$ ]]; then + GITEA_URL="https://${GITEA_DOMAIN}" + DEFAULTS_REPOSITORY_URL="${GITEA_URL}/voltron/iDS-Defaults.git" +else + GITEA_URL='' + DEFAULTS_REPOSITORY_URL='' +fi source /opt/idssys/ta-proxmenu/inc/git-update.inc @@ -36,9 +56,13 @@ AUTO_UPDATE_DEFAULTS() { if [[ ! -d "${DEFAULTS_REPOSITORY}/.git" ]]; then echo "iDSSYS Defaults is missing; restoring it from origin..." + if [[ -z "$DEFAULTS_REPOSITORY_URL" ]]; then + echo "WARNING: GITEA_DOMAIN is not configured; unable to restore iDSSYS Defaults." >&2 + return + fi mkdir -p /opt/idssys if ! timeout 60 git clone \ - https://git.scity.us/voltron/iDS-Defaults.git "$DEFAULTS_REPOSITORY"; then + "$DEFAULTS_REPOSITORY_URL" "$DEFAULTS_REPOSITORY"; then echo "WARNING: Unable to restore iDSSYS Defaults." >&2 return fi @@ -234,9 +258,13 @@ INSTALL_UPDATES() { local update_failed=0 echo -e "${idsCL[LightCyan]}Checking for updates...${idsCL[Default]}" + if [[ -z "$GITEA_URL" ]]; then + echo -e "${idsCL[Red]}GITEA_DOMAIN is not configured in ${TAPM_CONFIG_FILE}.${idsCL[Default]}" + return 1 + fi if ! curl --fail --silent --show-error --head \ - --connect-timeout 3 --max-time 10 https://git.scity.us >/dev/null; then - echo -e "${idsCL[Red]}Could not connect to git.scity.us${idsCL[Default]}" + --connect-timeout 3 --max-time 10 "$GITEA_URL" >/dev/null; then + echo -e "${idsCL[Red]}Could not connect to ${GITEA_DOMAIN}${idsCL[Default]}" return 1 fi From b3b092afa4b98518d04b38de756385bdeac9263c Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sun, 26 Jul 2026 15:57:00 -0500 Subject: [PATCH 58/76] update --- defaults.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/defaults.inc b/defaults.inc index 504e104..34462ee 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.26-3' +VERS='2026.7.26-4' noupdate=' ' From 825a35924d7b219806e8daebe5978c38e869d2e4 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sun, 26 Jul 2026 16:06:17 -0500 Subject: [PATCH 59/76] update --- README.md | 7 +++ defaults.inc | 28 +-------- inc/runtime-config.inc | 111 +++++++++++++++++++++++++++++++++++ run.sh | 23 +------- tests/test-runtime-config.sh | 45 ++++++++++++++ 5 files changed, 169 insertions(+), 45 deletions(-) create mode 100644 inc/runtime-config.inc create mode 100644 tests/test-runtime-config.sh diff --git a/README.md b/README.md index e1eb704..bd33e26 100644 --- a/README.md +++ b/README.md @@ -152,3 +152,10 @@ message when the variable is missing or invalid. `GITEA_DOMAIN` is a hostname, without `https://` or a path, and controls Gitea connectivity plus restoration of the `voltron/iDS-Defaults.git` repository. Repository updates do not overwrite the system configuration file. + +On the first V2 launch after installation or upgrade, TA-ProxMenu detects a +missing or incomplete `/etc/ta-proxmenu/config.env` and interactively requests +both values. Inputs are validated and written atomically with mode `0600` +before update checks or menu actions continue. A non-interactive launch without +valid configuration stops with an explicit setup message instead of selecting +a default company URL. diff --git a/defaults.inc b/defaults.inc index 34462ee..b81e636 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.26-4' +VERS='2026.7.26-5' noupdate=' ' @@ -19,30 +19,8 @@ VIRTIO_STABLE_URL="https://fedorapeople.org/groups/virt/virtio-win/direct-downlo PULSE_RELEASE='v6.1.1' PULSE_PORT='7655' -TAPM_CONFIG_FILE='/etc/ta-proxmenu/config.env' -for config_key in TAPM_BROKER_URL GITEA_DOMAIN; do - if [[ -z "${!config_key:-}" && -r "$TAPM_CONFIG_FILE" ]]; then - config_value="$( - sed -n "s/^[[:space:]]*${config_key}[[:space:]]*=[[:space:]]*//p" \ - "$TAPM_CONFIG_FILE" | - tail -n 1 - )" - config_value="${config_value#\"}" - config_value="${config_value%\"}" - config_value="${config_value#\'}" - config_value="${config_value%\'}" - printf -v "$config_key" '%s' "$config_value" - fi -done -unset config_key config_value -TAPM_BROKER_URL="${TAPM_BROKER_URL:-}" -TAPM_BROKER_URL="${TAPM_BROKER_URL%/}" -GITEA_DOMAIN="${GITEA_DOMAIN:-}" -if [[ "$GITEA_DOMAIN" =~ ^[A-Za-z0-9.-]+(:[0-9]+)?$ ]]; then - GITEA_URL="https://${GITEA_DOMAIN}" -else - GITEA_URL='' -fi +source "${FOLDER}/inc/runtime-config.inc" +TAPM_ENSURE_RUNTIME_CONFIG || return 1 2>/dev/null || exit 1 S1_BROKER_PACKAGE='sentinelone-linux' S1_PACKAGE='tapm-sentinelone.deb' diff --git a/inc/runtime-config.inc b/inc/runtime-config.inc new file mode 100644 index 0000000..98b8e1c --- /dev/null +++ b/inc/runtime-config.inc @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +TAPM_CONFIG_FILE="${TAPM_CONFIG_FILE:-/etc/ta-proxmenu/config.env}" + +TAPM_CONFIG_READ_VALUE() { + local key="$1" + local value='' + + if [[ -r "$TAPM_CONFIG_FILE" ]]; then + value="$( + sed -n "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*//p" \ + "$TAPM_CONFIG_FILE" | + tail -n 1 + )" + value="${value#\"}" + value="${value%\"}" + value="${value#\'}" + value="${value%\'}" + fi + printf '%s' "$value" +} + +TAPM_VALID_BROKER_ORIGIN() { + [[ "${1:-}" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?/?$ ]] +} + +TAPM_VALID_GITEA_DOMAIN() { + [[ "${1:-}" =~ ^[A-Za-z0-9.-]+(:[0-9]+)?$ ]] +} + +TAPM_LOAD_RUNTIME_CONFIG() { + if [[ -z "${TAPM_BROKER_URL:-}" ]]; then + TAPM_BROKER_URL="$(TAPM_CONFIG_READ_VALUE TAPM_BROKER_URL)" + fi + if [[ -z "${GITEA_DOMAIN:-}" ]]; then + GITEA_DOMAIN="$(TAPM_CONFIG_READ_VALUE GITEA_DOMAIN)" + fi + + TAPM_BROKER_URL="${TAPM_BROKER_URL:-}" + TAPM_BROKER_URL="${TAPM_BROKER_URL%/}" + GITEA_DOMAIN="${GITEA_DOMAIN:-}" + if TAPM_VALID_GITEA_DOMAIN "$GITEA_DOMAIN"; then + GITEA_URL="https://${GITEA_DOMAIN}" + else + GITEA_URL='' + fi +} + +TAPM_ENSURE_RUNTIME_CONFIG() { + local broker_url + local config_dir + local gitea_domain + local input_device='/dev/tty' + local temporary_file + + TAPM_LOAD_RUNTIME_CONFIG + if [[ -r "$TAPM_CONFIG_FILE" ]] && + TAPM_VALID_BROKER_ORIGIN "$TAPM_BROKER_URL" && + TAPM_VALID_GITEA_DOMAIN "$GITEA_DOMAIN"; then + return 0 + fi + + if [[ "${TAPM_CONFIG_TEST_STDIN:-0}" == 1 ]]; then + input_device='/dev/stdin' + exec 3>&2 + elif [[ ! -r /dev/tty || ! -w /dev/tty ]]; then + printf 'TA-ProxMenu requires %s with TAPM_BROKER_URL and GITEA_DOMAIN.\n' \ + "$TAPM_CONFIG_FILE" >&2 + return 1 + else + exec 3>/dev/tty + fi + + printf '\nTA-ProxMenu V2 requires deployment service configuration.\n' \ + >&3 + while true; do + printf 'TAPM broker HTTPS origin (example: https://tapm.example.com): ' \ + >&3 + IFS= read -r broker_url <"$input_device" || return 1 + broker_url="${broker_url%/}" + TAPM_VALID_BROKER_ORIGIN "$broker_url" && break + printf 'Enter an HTTPS origin without a path.\n' >&3 + done + while true; do + printf 'Gitea hostname (example: git.example.com): ' >&3 + IFS= read -r gitea_domain <"$input_device" || return 1 + TAPM_VALID_GITEA_DOMAIN "$gitea_domain" && break + printf 'Enter a hostname without https:// or a path.\n' >&3 + done + + config_dir="${TAPM_CONFIG_FILE%/*}" + [[ "$config_dir" != "$TAPM_CONFIG_FILE" ]] || config_dir='.' + mkdir -p "$config_dir" || return 1 + temporary_file="$(mktemp "${TAPM_CONFIG_FILE}.tmp.XXXXXX")" || return 1 + if ! { + printf 'TAPM_BROKER_URL=%s\n' "$broker_url" + printf 'GITEA_DOMAIN=%s\n' "$gitea_domain" + } >"$temporary_file" || + ! chmod 0600 "$temporary_file" || + ! mv -f "$temporary_file" "$TAPM_CONFIG_FILE"; then + rm -f "$temporary_file" + return 1 + fi + + TAPM_BROKER_URL="$broker_url" + GITEA_DOMAIN="$gitea_domain" + GITEA_URL="https://${GITEA_DOMAIN}" + printf 'Saved TA-ProxMenu configuration to %s.\n\n' "$TAPM_CONFIG_FILE" \ + >&3 + exec 3>&- +} diff --git a/run.sh b/run.sh index bdd1fae..657c3b6 100755 --- a/run.sh +++ b/run.sh @@ -5,26 +5,9 @@ DEFAULTS_REPOSITORY='/opt/idssys/defaults' DEFAULTS_CACHE_DIR='/var/cache/ta-proxmenu' DEFAULTS_CHECK_FILE="${DEFAULTS_CACHE_DIR}/defaults-last-check" DEFAULTS_CHECK_SECONDS=14400 -TAPM_CONFIG_FILE='/etc/ta-proxmenu/config.env' - -if [[ -z "${GITEA_DOMAIN:-}" && -r "$TAPM_CONFIG_FILE" ]]; then - GITEA_DOMAIN="$( - sed -n 's/^[[:space:]]*GITEA_DOMAIN[[:space:]]*=[[:space:]]*//p' \ - "$TAPM_CONFIG_FILE" | - tail -n 1 - )" - GITEA_DOMAIN="${GITEA_DOMAIN#\"}" - GITEA_DOMAIN="${GITEA_DOMAIN%\"}" - GITEA_DOMAIN="${GITEA_DOMAIN#\'}" - GITEA_DOMAIN="${GITEA_DOMAIN%\'}" -fi -if [[ "${GITEA_DOMAIN:-}" =~ ^[A-Za-z0-9.-]+(:[0-9]+)?$ ]]; then - GITEA_URL="https://${GITEA_DOMAIN}" - DEFAULTS_REPOSITORY_URL="${GITEA_URL}/voltron/iDS-Defaults.git" -else - GITEA_URL='' - DEFAULTS_REPOSITORY_URL='' -fi +source /opt/idssys/ta-proxmenu/inc/runtime-config.inc +TAPM_ENSURE_RUNTIME_CONFIG || exit 1 +DEFAULTS_REPOSITORY_URL="${GITEA_URL}/voltron/iDS-Defaults.git" source /opt/idssys/ta-proxmenu/inc/git-update.inc diff --git a/tests/test-runtime-config.sh b/tests/test-runtime-config.sh new file mode 100644 index 0000000..4a1f36c --- /dev/null +++ b/tests/test-runtime-config.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +test_dir="$(mktemp -d)" +trap 'rm -rf "$test_dir"' EXIT + +TAPM_CONFIG_FILE="${test_dir}/etc/config.env" +TAPM_CONFIG_TEST_STDIN=1 +source "${TEST_ROOT}/inc/runtime-config.inc" + +if ! printf '%s\n%s\n' \ + 'https://tapm.example.com' \ + 'git.example.com' | + TAPM_ENSURE_RUNTIME_CONFIG >/dev/null; then + printf 'FAIL: configuration wizard failed\n' >&2 + exit 1 +fi + +expected=$'TAPM_BROKER_URL=https://tapm.example.com\nGITEA_DOMAIN=git.example.com' +actual="$(cat "$TAPM_CONFIG_FILE")" +if [[ "$actual" != "$expected" ]]; then + printf 'FAIL: unexpected configuration contents\n' >&2 + exit 1 +fi +if stat -c '%a' "$TAPM_CONFIG_FILE" >/dev/null 2>&1; then + config_mode="$(stat -c '%a' "$TAPM_CONFIG_FILE")" +else + config_mode="$(stat -f '%Lp' "$TAPM_CONFIG_FILE")" +fi +if [[ "$config_mode" != 600 ]]; then + printf 'FAIL: configuration mode is not 600\n' >&2 + exit 1 +fi + +unset TAPM_BROKER_URL GITEA_DOMAIN GITEA_URL +TAPM_LOAD_RUNTIME_CONFIG +if [[ "$TAPM_BROKER_URL" != 'https://tapm.example.com' || + "$GITEA_DOMAIN" != 'git.example.com' || + "$GITEA_URL" != 'https://git.example.com' ]]; then + printf 'FAIL: saved configuration did not reload\n' >&2 + exit 1 +fi + +printf 'PASS: runtime configuration wizard\n' From ebddd792b241fcd0697982f8cbb7f6c4fef481b6 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sun, 26 Jul 2026 16:41:16 -0500 Subject: [PATCH 60/76] update security --- README.md | 24 ++++ defaults.inc | 4 +- inc/cluster-update.inc | 128 +++++++++++++++++++++ inc/fleet.inc | 210 +++++++++++++++++++++++++++++++++++ proxmenu-scripts.sh | 3 +- run.sh | 11 +- tests/test-cluster-update.sh | 65 +++++++++++ tests/test-fleet.sh | 89 +++++++++++++++ 8 files changed, 530 insertions(+), 4 deletions(-) create mode 100644 inc/cluster-update.inc create mode 100644 inc/fleet.inc create mode 100644 tests/test-cluster-update.sh create mode 100644 tests/test-fleet.sh diff --git a/README.md b/README.md index bd33e26..e9f7180 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,13 @@ interactive menu. Update availability is checked in the background and cached; updates are installed only when explicitly selected or requested with `tapm update`. +On a clustered Proxmox host, both `tapm update` and the management-menu update +update every online cluster node over Proxmox's root SSH trust, then update the +initiating node. Each node independently performs the same clean-worktree, +branch, and fast-forward safety checks. Offline, unreachable, dirty, ahead, or +diverged nodes are never overwritten and are reported as failures. Standalone +hosts retain the local-only update behavior. + When testing this branch, use `tapm main` to switch the installed copy back to the published main branch. The command refuses to switch when local changes, local-only commits, or diverged branch history would be at risk. @@ -159,3 +166,20 @@ both values. Inputs are validated and written atomically with mode `0600` before update checks or menu actions continue. A non-interactive launch without valid configuration stops with an explicit setup message instead of selecting a default company URL. + +## Installation registry + +V2 creates a random installation UUID and 256-bit credential in +`/var/lib/ta-proxmenu/identity.env`. The directory is mode `0700` and the file +is mode `0600`. On each interactive launch, TA-ProxMenu makes best-effort HTTPS +calls to the configured broker to record installation/upgrade state, start and +completion status, duration, TA-ProxMenu and Git versions, PVE/OS/kernel +versions, architecture, and whether the host is clustered. No background +service is installed, and broker availability never prevents the menu from +running. + +The registry does not send hostnames, machine IDs, MAC addresses, usernames, +customer names, VM/container inventory, deployment codes, authorization +tokens, or command output. The broker stores only a digest of the random host +credential. A successful deployment-code exchange links the already-random +installation ID to a verified registry entry. diff --git a/defaults.inc b/defaults.inc index b81e636..074964a 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.26-5' +VERS='2026.7.26-7' noupdate=' ' @@ -21,6 +21,8 @@ PULSE_PORT='7655' source "${FOLDER}/inc/runtime-config.inc" TAPM_ENSURE_RUNTIME_CONFIG || return 1 2>/dev/null || exit 1 +source "${FOLDER}/inc/fleet.inc" +TAPM_FLEET_ENSURE_IDENTITY || true S1_BROKER_PACKAGE='sentinelone-linux' S1_PACKAGE='tapm-sentinelone.deb' diff --git a/inc/cluster-update.inc b/inc/cluster-update.inc new file mode 100644 index 0000000..f4133d0 --- /dev/null +++ b/inc/cluster-update.inc @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Cluster discovery and remote update helpers for TA-ProxMenu. + +TAPM_COROSYNC_CONFIG="${TAPM_COROSYNC_CONFIG:-/etc/pve/corosync.conf}" + +TAPM_CLUSTER_NODES_FROM_JSON() { + python3 -c ' +import json, re, sys +try: + payload = json.load(sys.stdin) +except (json.JSONDecodeError, OSError): + raise SystemExit(1) +if isinstance(payload, dict): + payload = payload.get("data", []) +if not isinstance(payload, list): + raise SystemExit(1) +valid_name = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$") +rows = [] +for item in payload: + if not isinstance(item, dict): + continue + name = str(item.get("node", "")).strip() + status = str(item.get("status", "unknown")).strip().lower() + if valid_name.fullmatch(name): + rows.append((name, status)) +for name, status in sorted(rows): + print(f"{name}\t{status}") +' +} + +TAPM_CLUSTER_NODE_ROWS() { + local node_json + + node_json="$(timeout 10 pvesh get /nodes --output-format json 2>/dev/null)" || + return 1 + printf '%s' "$node_json" | TAPM_CLUSTER_NODES_FROM_JSON +} + +TAPM_LOCAL_CLUSTER_NODE() { + local local_link='' + + local_link="$(readlink /etc/pve/local 2>/dev/null || true)" + if [[ -n "$local_link" ]]; then + basename "$local_link" + else + hostname -s + fi +} + +TAPM_UPDATE_REMOTE_NODE() { + local node="$1" + + [[ "$node" =~ ^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$ ]] || return 1 + timeout 240 ssh \ + -o BatchMode=yes \ + -o ConnectTimeout=10 \ + -o ServerAliveInterval=15 \ + -o ServerAliveCountMax=2 \ + "root@${node}" \ + '/opt/idssys/ta-proxmenu/run.sh update --local-only' +} + +INSTALL_CLUSTER_UPDATES() { + local index + local local_node + local node + local required_command + local status + local node_rows='' + local cluster_failed=0 + local -a cluster_nodes=() + local -a cluster_statuses=() + + if [[ ! -s "$TAPM_COROSYNC_CONFIG" ]]; then + INSTALL_LOCAL_UPDATES + return $? + fi + for required_command in pvesh python3 ssh timeout; do + if ! command -v "$required_command" >/dev/null 2>&1; then + echo -e "${idsCL[Red]}Cluster update requires ${required_command}; no nodes were updated.${idsCL[Default]}" + return 1 + fi + done + node_rows="$(TAPM_CLUSTER_NODE_ROWS)" || { + echo -e "${idsCL[Red]}Could not retrieve the Proxmox cluster node list; no nodes were updated.${idsCL[Default]}" + return 1 + } + while IFS=$'\t' read -r node status; do + [[ -n "$node" ]] || continue + cluster_nodes+=("$node") + cluster_statuses+=("$status") + done <<<"$node_rows" + if (( ${#cluster_nodes[@]} == 0 )); then + echo -e "${idsCL[Red]}The Proxmox API returned no cluster nodes; no nodes were updated.${idsCL[Default]}" + return 1 + fi + + local_node="$(TAPM_LOCAL_CLUSTER_NODE)" + echo -e "${idsCL[LightCyan]}Updating TA-ProxMenu across ${#cluster_nodes[@]} cluster node(s)...${idsCL[Default]}" + for index in "${!cluster_nodes[@]}"; do + node="${cluster_nodes[$index]}" + status="${cluster_statuses[$index]}" + if [[ "$node" == "$local_node" ]]; then + continue + fi + if [[ "$status" != "online" ]]; then + echo -e "${idsCL[LightYellow]}Skipping ${node}: node status is ${status}.${idsCL[Default]}" + cluster_failed=1 + continue + fi + echo -e "${idsCL[LightCyan]}Updating remote node ${node}...${idsCL[Default]}" + if TAPM_UPDATE_REMOTE_NODE "$node"; then + echo -e "${idsCL[Green]}Remote node ${node} is updated.${idsCL[Default]}" + else + echo -e "${idsCL[Red]}Remote node ${node} failed to update.${idsCL[Default]}" + cluster_failed=1 + fi + done + + echo -e "${idsCL[LightCyan]}Updating local node ${local_node}...${idsCL[Default]}" + INSTALL_LOCAL_UPDATES || cluster_failed=1 + if (( cluster_failed == 0 )); then + echo -e "${idsCL[Green]}TA-ProxMenu is updated on all ${#cluster_nodes[@]} cluster node(s).${idsCL[Default]}" + return 0 + fi + echo -e "${idsCL[LightYellow]}Cluster update completed with failures; review the node messages above.${idsCL[Default]}" + return 1 +} diff --git a/inc/fleet.inc b/inc/fleet.inc new file mode 100644 index 0000000..801bb06 --- /dev/null +++ b/inc/fleet.inc @@ -0,0 +1,210 @@ +#!/usr/bin/env bash + +TAPM_FLEET_STATE_DIR="${TAPM_FLEET_STATE_DIR:-/var/lib/ta-proxmenu}" +TAPM_FLEET_IDENTITY_FILE="${TAPM_FLEET_IDENTITY_FILE:-${TAPM_FLEET_STATE_DIR}/identity.env}" +TAPM_FLEET_INSTALLATION_ID='' +TAPM_FLEET_CREDENTIAL='' +TAPM_FLEET_LAST_VERSION='' +TAPM_FLEET_REGISTERED=0 +TAPM_FLEET_STARTED_AT=0 +TAPM_FLEET_VERSION='' + +TAPM_FLEET_READ_VALUE() { + local key="$1" + local value='' + if [[ -r "$TAPM_FLEET_IDENTITY_FILE" ]]; then + value="$( + sed -n "s/^${key}=//p" "$TAPM_FLEET_IDENTITY_FILE" | + tail -n 1 + )" + fi + printf '%s' "$value" +} + +TAPM_FLEET_LOAD_IDENTITY() { + TAPM_FLEET_INSTALLATION_ID="$(TAPM_FLEET_READ_VALUE INSTALLATION_ID)" + TAPM_FLEET_CREDENTIAL="$(TAPM_FLEET_READ_VALUE CREDENTIAL)" + TAPM_FLEET_LAST_VERSION="$(TAPM_FLEET_READ_VALUE LAST_VERSION)" +} + +TAPM_FLEET_VALID_IDENTITY() { + [[ "$TAPM_FLEET_INSTALLATION_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ && + "$TAPM_FLEET_CREDENTIAL" =~ ^[0-9a-f]{64}$ ]] +} + +TAPM_FLEET_WRITE_IDENTITY() { + local temporary_file + + mkdir -p "$TAPM_FLEET_STATE_DIR" 2>/dev/null || return 1 + chmod 0700 "$TAPM_FLEET_STATE_DIR" 2>/dev/null || return 1 + temporary_file="$(mktemp "${TAPM_FLEET_IDENTITY_FILE}.tmp.XXXXXX")" || return 1 + if ! { + printf 'INSTALLATION_ID=%s\n' "$TAPM_FLEET_INSTALLATION_ID" + printf 'CREDENTIAL=%s\n' "$TAPM_FLEET_CREDENTIAL" + printf 'LAST_VERSION=%s\n' "$TAPM_FLEET_LAST_VERSION" + } >"$temporary_file" || + ! chmod 0600 "$temporary_file" || + ! mv -f "$temporary_file" "$TAPM_FLEET_IDENTITY_FILE"; then + rm -f "$temporary_file" + return 1 + fi +} + +TAPM_FLEET_ENSURE_IDENTITY() { + TAPM_FLEET_LOAD_IDENTITY + TAPM_FLEET_VALID_IDENTITY && return 0 + command -v openssl >/dev/null 2>&1 || return 1 + if [[ -r /proc/sys/kernel/random/uuid ]]; then + IFS= read -r TAPM_FLEET_INSTALLATION_ID /dev/null 2>&1; then + TAPM_FLEET_INSTALLATION_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" + else + return 1 + fi + TAPM_FLEET_CREDENTIAL="$(openssl rand -hex 32)" || return 1 + TAPM_FLEET_LAST_VERSION='' + TAPM_FLEET_WRITE_IDENTITY +} + +TAPM_FLEET_COLLECT_METADATA() { + TAPM_FLEET_GIT_COMMIT="$(git -C "${FOLDER:-/opt/idssys/ta-proxmenu}" rev-parse HEAD 2>/dev/null || true)" + TAPM_FLEET_PVE_VERSION="$(pveversion 2>/dev/null | head -n 1 || true)" + TAPM_FLEET_OS_VERSION="$( + if [[ -r /etc/os-release ]]; then + ( + # shellcheck disable=SC1091 + source /etc/os-release + printf '%s' "${PRETTY_NAME:-}" + ) + fi + )" + TAPM_FLEET_KERNEL_VERSION="$(uname -r 2>/dev/null || true)" + TAPM_FLEET_ARCHITECTURE="$( + dpkg --print-architecture 2>/dev/null || + uname -m 2>/dev/null || + true + )" + if [[ -s /etc/pve/corosync.conf ]]; then + TAPM_FLEET_CLUSTERED=true + else + TAPM_FLEET_CLUSTERED=false + fi +} + +TAPM_FLEET_JSON() { + local event="$1" + local result="$2" + local error_code="${3:-}" + local duration="${4:-0}" + + TAPM_FLEET_EVENT="$event" \ + TAPM_FLEET_RESULT="$result" \ + TAPM_FLEET_ERROR_CODE="$error_code" \ + TAPM_FLEET_DURATION="$duration" \ + TAPM_FLEET_INSTALLATION_ID="$TAPM_FLEET_INSTALLATION_ID" \ + TAPM_FLEET_CREDENTIAL="$TAPM_FLEET_CREDENTIAL" \ + TAPM_FLEET_VERSION="$TAPM_FLEET_VERSION" \ + TAPM_FLEET_GIT_COMMIT="$TAPM_FLEET_GIT_COMMIT" \ + TAPM_FLEET_PVE_VERSION="$TAPM_FLEET_PVE_VERSION" \ + TAPM_FLEET_OS_VERSION="$TAPM_FLEET_OS_VERSION" \ + TAPM_FLEET_KERNEL_VERSION="$TAPM_FLEET_KERNEL_VERSION" \ + TAPM_FLEET_ARCHITECTURE="$TAPM_FLEET_ARCHITECTURE" \ + TAPM_FLEET_CLUSTERED="$TAPM_FLEET_CLUSTERED" \ + TAPM_FLEET_INCLUDE_CREDENTIAL="${TAPM_FLEET_INCLUDE_CREDENTIAL:-0}" \ + python3 -c ' +import json, os, sys +payload = { + "schema_version": 1, + "installation_id": os.environ["TAPM_FLEET_INSTALLATION_ID"], + "event": os.environ["TAPM_FLEET_EVENT"], + "result": os.environ["TAPM_FLEET_RESULT"], + "proxmenu_version": os.environ["TAPM_FLEET_VERSION"], + "git_commit": os.environ["TAPM_FLEET_GIT_COMMIT"], + "pve_version": os.environ["TAPM_FLEET_PVE_VERSION"], + "os_version": os.environ["TAPM_FLEET_OS_VERSION"], + "kernel_version": os.environ["TAPM_FLEET_KERNEL_VERSION"], + "architecture": os.environ["TAPM_FLEET_ARCHITECTURE"], + "clustered": os.environ["TAPM_FLEET_CLUSTERED"] == "true", + "error_code": os.environ["TAPM_FLEET_ERROR_CODE"], + "duration_seconds": int(os.environ["TAPM_FLEET_DURATION"]), +} +if os.environ.get("TAPM_FLEET_INCLUDE_CREDENTIAL") == "1": + payload["credential"] = os.environ["TAPM_FLEET_CREDENTIAL"] +json.dump(payload, sys.stdout, separators=(",", ":")) +' +} + +TAPM_FLEET_REGISTER() { + TAPM_FLEET_INCLUDE_CREDENTIAL=1 TAPM_FLEET_JSON installed success | + curl --fail --silent --show-error \ + --connect-timeout 3 --max-time 10 \ + --header 'Content-Type: application/json' \ + --data-binary @- \ + "${TAPM_BROKER_URL}/api/v1/hosts/register" \ + >/dev/null 2>&1 +} + +TAPM_FLEET_EVENT_SEND() { + local event="$1" + local result="$2" + local error_code="${3:-}" + local duration="${4:-0}" + local event_payload='' + + (( TAPM_FLEET_REGISTERED == 1 )) || return 0 + event_payload="$(mktemp "${TMPDIR:-/tmp}/tapm-fleet-event.XXXXXX")" || return 0 + chmod 0600 "$event_payload" 2>/dev/null || { + rm -f "$event_payload" + return 0 + } + if ! TAPM_FLEET_JSON "$event" "$result" "$error_code" "$duration" >"$event_payload"; then + rm -f "$event_payload" + return 0 + fi + printf 'header = "Content-Type: application/json"\nheader = "Authorization: Bearer %s"\nurl = "%s/api/v1/hosts/events"\n' \ + "$TAPM_FLEET_CREDENTIAL" "$TAPM_BROKER_URL" | + curl --fail --silent --show-error \ + --connect-timeout 3 --max-time 10 \ + --config - --data-binary "@${event_payload}" \ + >/dev/null 2>&1 || true + rm -f "$event_payload" +} + +TAPM_FLEET_START() { + TAPM_FLEET_VERSION="$1" + TAPM_FLEET_STARTED_AT="$(date +%s)" + TAPM_FLEET_ENSURE_IDENTITY || return 0 + command -v python3 >/dev/null 2>&1 || return 0 + command -v curl >/dev/null 2>&1 || return 0 + TAPM_FLEET_COLLECT_METADATA + if TAPM_FLEET_REGISTER; then + TAPM_FLEET_REGISTERED=1 + else + return 0 + fi + if [[ -n "$TAPM_FLEET_LAST_VERSION" && + "$TAPM_FLEET_LAST_VERSION" != "$TAPM_FLEET_VERSION" ]]; then + TAPM_FLEET_EVENT_SEND upgraded success + fi + TAPM_FLEET_EVENT_SEND run_started started +} + +TAPM_FLEET_FINISH() { + local exit_status="${1:-0}" + local duration=0 + + if [[ "$TAPM_FLEET_STARTED_AT" =~ ^[0-9]+$ ]] && + (( TAPM_FLEET_STARTED_AT > 0 )); then + duration="$(( $(date +%s) - TAPM_FLEET_STARTED_AT ))" + fi + if (( exit_status == 0 )); then + TAPM_FLEET_EVENT_SEND run_completed success '' "$duration" + else + TAPM_FLEET_EVENT_SEND run_failed failure exit_nonzero "$duration" + fi + if (( TAPM_FLEET_REGISTERED == 1 )) && TAPM_FLEET_VALID_IDENTITY; then + TAPM_FLEET_LAST_VERSION="$TAPM_FLEET_VERSION" + TAPM_FLEET_WRITE_IDENTITY || true + fi + return 0 +} diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index cd8694a..15cd0fc 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -185,8 +185,9 @@ TAPM_AUTHORIZE() { exchange_response="$( TAPM_CODE="$deploycode" TAPM_FINGERPRINT="$host_fingerprint" TAPM_HOSTNAME="$(hostname)" \ TAPM_LAN_IP="${RNIP:-}" \ + TAPM_INSTALLATION_ID="${TAPM_FLEET_INSTALLATION_ID:-}" \ TAPM_REQUESTED_ACTION="$required_action" TAPM_REQUESTED_PACKAGE="$required_package" \ - python3 -c 'import json, os, sys; json.dump({"code": os.environ["TAPM_CODE"], "host_fingerprint": os.environ["TAPM_FINGERPRINT"], "hostname": os.environ["TAPM_HOSTNAME"], "lan_ip": os.environ["TAPM_LAN_IP"], "requested_action": os.environ["TAPM_REQUESTED_ACTION"], "requested_package": os.environ["TAPM_REQUESTED_PACKAGE"]}, sys.stdout)' | + python3 -c 'import json, os, sys; json.dump({"code": os.environ["TAPM_CODE"], "host_fingerprint": os.environ["TAPM_FINGERPRINT"], "hostname": os.environ["TAPM_HOSTNAME"], "lan_ip": os.environ["TAPM_LAN_IP"], "installation_id": os.environ["TAPM_INSTALLATION_ID"], "requested_action": os.environ["TAPM_REQUESTED_ACTION"], "requested_package": os.environ["TAPM_REQUESTED_PACKAGE"]}, sys.stdout)' | curl --fail --silent --show-error \ --header 'Content-Type: application/json' \ --data-binary @- "${TAPM_BROKER_URL}/api/v1/exchange" diff --git a/run.sh b/run.sh index 657c3b6..04378bc 100755 --- a/run.sh +++ b/run.sh @@ -10,6 +10,7 @@ TAPM_ENSURE_RUNTIME_CONFIG || exit 1 DEFAULTS_REPOSITORY_URL="${GITEA_URL}/voltron/iDS-Defaults.git" source /opt/idssys/ta-proxmenu/inc/git-update.inc +source /opt/idssys/ta-proxmenu/inc/cluster-update.inc AUTO_UPDATE_DEFAULTS() { local checked_at=0 @@ -100,6 +101,8 @@ AUTO_UPDATE_DEFAULTS [ "${2:-}" != "q" ] && source /opt/idssys/defaults/colors.inc source /opt/idssys/defaults/default.inc source /opt/idssys/ta-proxmenu/defaults.inc +TAPM_FLEET_START "$VERS" +trap 'TAPM_FLEET_FINISH "$?"' EXIT UPDATE_REPOSITORY() { local repository="$1" @@ -235,7 +238,7 @@ SWITCH_TAPM_BRANCH() { exec /opt/idssys/ta-proxmenu/run.sh } -INSTALL_UPDATES() { +INSTALL_LOCAL_UPDATES() { local current_branch local defaults_warning=0 local update_failed=0 @@ -279,7 +282,11 @@ INSTALL_UPDATES() { case "${1:-}" in update|u) - INSTALL_UPDATES + if [[ "${2:-}" == "--local-only" ]]; then + INSTALL_LOCAL_UPDATES + else + INSTALL_CLUSTER_UPDATES + fi exit $? ;; main) diff --git a/tests/test-cluster-update.sh b/tests/test-cluster-update.sh new file mode 100644 index 0000000..b742f66 --- /dev/null +++ b/tests/test-cluster-update.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${TEST_ROOT}/tests/testlib.sh" +source "${TEST_ROOT}/inc/cluster-update.inc" + +nodes_json='[ + {"node":"pve3","status":"offline"}, + {"node":"pve1","status":"online"}, + {"node":"pve2","status":"online"}, + {"node":"bad node","status":"online"} +]' +expected=$'pve1\tonline\npve2\tonline\npve3\toffline' +assert_equal "$expected" \ + "$(printf '%s' "$nodes_json" | TAPM_CLUSTER_NODES_FROM_JSON)" \ + "cluster node discovery filters and sorts API output" + +wrapped_json='{"data":[{"node":"pve2.example","status":"ONLINE"}]}' +assert_equal $'pve2.example\tonline' \ + "$(printf '%s' "$wrapped_json" | TAPM_CLUSTER_NODES_FROM_JSON)" \ + "wrapped API response and normalized status" + +if printf '%s' 'not-json' | TAPM_CLUSTER_NODES_FROM_JSON 2>/dev/null; then + printf 'FAIL: malformed cluster JSON was accepted\n' >&2 + exit 1 +fi +assert_failure "remote node rejects shell characters" \ + TAPM_UPDATE_REMOTE_NODE 'pve1;reboot' + +test_dir="$(mktemp -d)" +trap 'rm -rf "$test_dir"' EXIT +TAPM_COROSYNC_CONFIG="${test_dir}/corosync.conf" +printf 'totem {}\n' >"$TAPM_COROSYNC_CONFIG" +update_log="${test_dir}/updates" +LightCyan=0 +LightYellow=0 +Green=0 +Red=0 +Default=0 +idsCL[0]='' +pvesh() { + return 0 +} +TAPM_CLUSTER_NODE_ROWS() { + printf 'pve1\tonline\npve2\tonline\npve3\toffline\n' +} +TAPM_LOCAL_CLUSTER_NODE() { + printf 'pve1\n' +} +TAPM_UPDATE_REMOTE_NODE() { + printf 'remote:%s\n' "$1" >>"$update_log" +} +INSTALL_LOCAL_UPDATES() { + printf 'local:pve1\n' >>"$update_log" +} +if INSTALL_CLUSTER_UPDATES >/dev/null; then + printf 'FAIL: cluster update ignored an offline node\n' >&2 + exit 1 +fi +assert_equal $'remote:pve2\nlocal:pve1' \ + "$(cat "$update_log")" \ + "online remotes and initiating node update once" + +finish_tests diff --git a/tests/test-fleet.sh b/tests/test-fleet.sh new file mode 100644 index 0000000..6ad4134 --- /dev/null +++ b/tests/test-fleet.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +test_dir="$(mktemp -d)" +trap 'rm -rf "$test_dir"' EXIT + +TAPM_FLEET_STATE_DIR="${test_dir}/state" +TAPM_FLEET_IDENTITY_FILE="${TAPM_FLEET_STATE_DIR}/identity.env" +TAPM_BROKER_URL='https://tapm.example.com' +source "${TEST_ROOT}/inc/fleet.inc" + +TAPM_FLEET_ENSURE_IDENTITY +if ! TAPM_FLEET_VALID_IDENTITY; then + printf 'FAIL: generated fleet identity is invalid\n' >&2 + exit 1 +fi +if stat -c '%a' "$TAPM_FLEET_IDENTITY_FILE" >/dev/null 2>&1; then + identity_mode="$(stat -c '%a' "$TAPM_FLEET_IDENTITY_FILE")" +else + identity_mode="$(stat -f '%Lp' "$TAPM_FLEET_IDENTITY_FILE")" +fi +if [[ "$identity_mode" != 600 ]]; then + printf 'FAIL: fleet identity mode is not 600\n' >&2 + exit 1 +fi + +TAPM_FLEET_VERSION='2026.7.26-7' +TAPM_FLEET_GIT_COMMIT='0123456789abcdef0123456789abcdef01234567' +TAPM_FLEET_PVE_VERSION='pve-manager/9.0.3/abc~1' +TAPM_FLEET_OS_VERSION='Debian GNU/Linux 13 (trixie)' +TAPM_FLEET_KERNEL_VERSION='6.14.11-2-pve' +TAPM_FLEET_ARCHITECTURE='amd64' +TAPM_FLEET_CLUSTERED=true + +registration_json="$( + TAPM_FLEET_INCLUDE_CREDENTIAL=1 TAPM_FLEET_JSON installed success +)" +REGISTRATION_JSON="$registration_json" python3 -c ' +import json, os +payload = json.loads(os.environ["REGISTRATION_JSON"]) +assert payload["installation_id"] +assert len(payload["credential"]) == 64 +assert payload["proxmenu_version"] == "2026.7.26-7" +assert payload["clustered"] is True +' + +printf '0\n' >"${test_dir}/curl-count" +curl() { + local count data_path='' previous='' argument='' + count="$(cat "${test_dir}/curl-count")" + count="$((count + 1))" + printf '%s\n' "$count" >"${test_dir}/curl-count" + for argument in "$@"; do + if [[ "$previous" == '--data-binary' ]]; then + data_path="${argument#@}" + fi + previous="$argument" + done + if [[ -n "$data_path" ]]; then + cp "$data_path" "${test_dir}/curl-body-${count}.json" + fi + cat >"${test_dir}/curl-config-${count}" + return 0 +} + +TAPM_FLEET_REGISTERED=1 +TAPM_FLEET_EVENT_SEND run_completed success '' 12 +EVENT_BODY="$(cat "${test_dir}/curl-body-1.json")" \ +EVENT_CONFIG="$(cat "${test_dir}/curl-config-1")" \ +EXPECTED_CREDENTIAL="$TAPM_FLEET_CREDENTIAL" \ +python3 -c ' +import json, os +payload = json.loads(os.environ["EVENT_BODY"]) +assert payload["event"] == "run_completed" +assert payload["result"] == "success" +assert payload["duration_seconds"] == 12 +assert "credential" not in payload +config = os.environ["EVENT_CONFIG"] +assert "Authorization: Bearer " + os.environ["EXPECTED_CREDENTIAL"] in config +assert "https://tapm.example.com/api/v1/hosts/events" in config +' + +if compgen -G "${TMPDIR:-/tmp}/tapm-fleet-event.*" >/dev/null; then + printf 'FAIL: fleet event left a temporary payload file\n' >&2 + exit 1 +fi + +printf 'PASS: fleet identity and event client\n' From dcccb8d83768947d2917438b1986ff165f8940d4 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sun, 26 Jul 2026 20:05:08 -0500 Subject: [PATCH 61/76] fix lxc file nas --- defaults.inc | 2 +- inc/deploy-iso-nfs-lxc.sh | 2 +- tests/test-iso-nfs.sh | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/defaults.inc b/defaults.inc index 074964a..ecaf175 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.26-7' +VERS='2026.7.26-8' noupdate=' ' diff --git a/inc/deploy-iso-nfs-lxc.sh b/inc/deploy-iso-nfs-lxc.sh index 00bee4f..189ee3a 100644 --- a/inc/deploy-iso-nfs-lxc.sh +++ b/inc/deploy-iso-nfs-lxc.sh @@ -222,7 +222,7 @@ TAPM_DEPLOY_ISO_NFS_LXC() { --unprivileged 0 \ --features nesting=1 \ --cores 2 \ - --cpunits 100 \ + --cpuunits 100 \ --memory 2048 \ --swap 512 \ --rootfs "${root_storage}:${root_size}" \ diff --git a/tests/test-iso-nfs.sh b/tests/test-iso-nfs.sh index b62a31f..5c9b099 100755 --- a/tests/test-iso-nfs.sh +++ b/tests/test-iso-nfs.sh @@ -5,6 +5,11 @@ TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "${TEST_ROOT}/tests/testlib.sh" source "${TEST_ROOT}/inc/deploy-iso-nfs-lxc.sh" +assert_success "LXC creation uses the compatible cpuunits option" \ + grep -q -- '--cpuunits 100' "${TEST_ROOT}/inc/deploy-iso-nfs-lxc.sh" +assert_failure "misspelled cpunits option is absent" \ + grep -q -- '--cpunits' "${TEST_ROOT}/inc/deploy-iso-nfs-lxc.sh" + assert_success "valid storage ID" TAPM_ISO_NFS_VALID_ID PVE-Shared-Storage assert_failure "storage ID cannot start with a number" TAPM_ISO_NFS_VALID_ID 1-storage assert_failure "storage ID rejects spaces" TAPM_ISO_NFS_VALID_ID 'shared storage' From 8c73cb7a532efafc4560bffe3da8066fb53337af Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sun, 26 Jul 2026 22:01:55 -0500 Subject: [PATCH 62/76] fixed HA VM timeout during mm --- defaults.inc | 2 +- inc/evacuate-proxmox-node.sh | 17 +++++++++++++---- tests/test-evacuation.sh | 31 +++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/defaults.inc b/defaults.inc index ecaf175..c738125 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.26-8' +VERS='2026.7.26-9' noupdate=' ' diff --git a/inc/evacuate-proxmox-node.sh b/inc/evacuate-proxmox-node.sh index 3afc05d..50a43b2 100644 --- a/inc/evacuate-proxmox-node.sh +++ b/inc/evacuate-proxmox-node.sh @@ -455,6 +455,9 @@ wait_for_ha_evacuation() { local -a ha_ids local -a remaining local guest + local guest_status + local guest_type + local guest_vmid local ha_id log "Waiting for HA-managed guests to leave ${LOCAL_NODE}." @@ -471,8 +474,10 @@ wait_for_ha_evacuation() { remaining=() for guest in "${local_guests[@]}"; do + IFS=$'\x1f' read -r guest_vmid guest_type guest_status _ <<< "$guest" + [[ "$guest_status" == "running" ]] || continue for ha_id in "${ha_ids[@]}"; do - if [[ "${guest%%$'\x1f'*}" == "$ha_id" ]]; then + if [[ "$guest_vmid" == "$ha_id" ]]; then remaining+=("$guest") break fi @@ -842,9 +847,13 @@ main() { policy_nodes policy_strict policy_rule destination route_note <<< "$guest" if guest_is_ha_managed "$vmid"; then - warn "${guest_type} ${vmid} became HA-managed; TAPM will not migrate it manually." - migration_skipped+=("$guest") - continue + status="$(guest_status "$vmid" "$guest_type")" + if [[ "$status" != "stopped" ]]; then + warn "${guest_type} ${vmid} is HA-managed and is not confirmed stopped; TAPM will not migrate it manually." + migration_skipped+=("$guest") + continue + fi + log "${guest_type} ${vmid} is HA-managed but stopped; continuing with offline migration." fi if ! choose_destination "$required_storages" "$policy_nodes" "$policy_strict"; then diff --git a/tests/test-evacuation.sh b/tests/test-evacuation.sh index e999216..665479e 100755 --- a/tests/test-evacuation.sh +++ b/tests/test-evacuation.sh @@ -5,6 +5,37 @@ TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "${TEST_ROOT}/tests/testlib.sh" source "${TEST_ROOT}/inc/evacuate-proxmox-node.sh" +test_stopped_ha_guest_does_not_block_wait() { + LOCAL_NODE=node1 + HA_WAIT_SECONDS=0 + get_local_guests() { + printf '%s\n' \ + $'100\x1fqemu\x1fstopped\x1fshutdown-ha-vm\x1f1024' \ + $'101\x1fqemu\x1frunning\x1fnon-ha-vm\x1f1024' + } + get_ha_guest_ids() { + printf '%s\n' 100 + } + wait_for_ha_evacuation +} + +test_running_ha_guest_blocks_wait() { + LOCAL_NODE=node1 + HA_WAIT_SECONDS=0 + get_local_guests() { + printf '%s\n' $'100\x1fqemu\x1frunning\x1frunning-ha-vm\x1f1024' + } + get_ha_guest_ids() { + printf '%s\n' 100 + } + wait_for_ha_evacuation +} + +assert_success "stopped HA guest does not block evacuation wait" \ + test_stopped_ha_guest_does_not_block_wait +assert_failure "running HA guest still blocks evacuation wait" \ + test_running_ha_guest_blocks_wait + set_routing_fixture() { MIGRATION_NODES=(node2 node3) NODE_STORAGE_CACHE=() From 3ea33a17d8fc64a87b905475ee9fdf52cfdbed3e Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Tue, 28 Jul 2026 08:49:38 -0500 Subject: [PATCH 63/76] update --- README.md | 14 ++ defaults.inc | 2 +- install-pulse.sh | 255 +++++++++++++++++++++++++++++++++ tests/test-pulse-standalone.sh | 24 ++++ 4 files changed, 294 insertions(+), 1 deletion(-) create mode 100755 install-pulse.sh create mode 100755 tests/test-pulse-standalone.sh diff --git a/README.md b/README.md index e9f7180..51c0a64 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,20 @@ replacing `/bin/gzip`. APT remains dual-stack by default; its conditional IPv4 compatibility check is optional. Global vzdump bandwidth and I/O-priority changes are available through a separate explicit menu. +Pulse can also be deployed without installing TA-ProxMenu. Run the standalone +bootstrap as root on a Proxmox VE host: + +```bash +bash <(curl -fsSL https://git.schroedercity.com/TAI/TA-ProxMenu/raw/branch/V2/install-pulse.sh) +``` + +The bootstrap downloads the Pulse deployment module and its LXC storage helper +from the same branch into a protected temporary directory, validates their Bash +syntax and expected entry points, runs the normal interactive Pulse workflow, +and removes the temporary files afterward. It does not clone or install +TA-ProxMenu. Set `TAPM_PULSE_SOURCE_BRANCH` before running it to use another +branch. + VirtIO downloads are managed from a dedicated submenu. The stable release is checked only when that submenu is opened, and curated compatibility ISOs are available for Windows Server 2008, 2008 R2, 2012/R2, and 2016. Downloads are diff --git a/defaults.inc b/defaults.inc index c738125..3d0486e 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.26-9' +VERS='2026.7.28-1' noupdate=' ' diff --git a/install-pulse.sh b/install-pulse.sh new file mode 100755 index 0000000..d86c097 --- /dev/null +++ b/install-pulse.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# Standalone TA-managed Pulse LXC deployment bootstrap for Proxmox VE. + +set -u -o pipefail + +TAPM_PULSE_SOURCE_BRANCH="${TAPM_PULSE_SOURCE_BRANCH:-V2}" +TAPM_PULSE_SOURCE_BASE="${TAPM_PULSE_SOURCE_BASE:-https://git.schroedercity.com/TAI/TA-ProxMenu/raw/branch/${TAPM_PULSE_SOURCE_BRANCH}}" +TAPM_PULSE_BOOTSTRAP_DIR='' +TAPM_TEMP_DIR='' +declare -a TAPM_TEMP_DIRS=() +declare -A idsCL=() + +TAPM_PULSE_VALID_SOURCE_BRANCH() { + local branch="${1:-}" + + [[ -n "$branch" && + "$branch" =~ ^[A-Za-z0-9._/-]+$ && + "$branch" != /* && + "$branch" != */ && + "$branch" != *..* ]] +} + +TAPM_PULSE_VALID_SOURCE_BASE() { + local url="${1:-}" + + [[ "$url" == https://* && + "$url" != *$'\n'* && + "$url" != *$'\r'* && + "$url" != *'"'* && + "$url" != *\\* && + "$url" != *[[:space:]]* ]] +} + +TAPM_PULSE_DEFINE_COLORS() { + if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then + idsCL[Default]=$'\e[0m' + idsCL[Red]=$'\e[31m' + idsCL[Green]=$'\e[32m' + idsCL[White]=$'\e[97m' + idsCL[LightRed]=$'\e[91m' + idsCL[LightGreen]=$'\e[92m' + idsCL[LightYellow]=$'\e[93m' + idsCL[LightCyan]=$'\e[96m' + else + idsCL[Default]='' + idsCL[Red]='' + idsCL[Green]='' + idsCL[White]='' + idsCL[LightRed]='' + idsCL[LightGreen]='' + idsCL[LightYellow]='' + idsCL[LightCyan]='' + fi +} + +TAPM_CLEAN_TEMP_DIR() { + local temp_dir="${1:-}" + + if [[ "$temp_dir" == /tmp/ta-proxmenu-* && -d "$temp_dir" ]]; then + rm -rf -- "$temp_dir" + fi +} + +TAPM_CLEAN_ALL_TEMP_DIRS() { + local temp_dir + + for temp_dir in "${TAPM_TEMP_DIRS[@]}"; do + TAPM_CLEAN_TEMP_DIR "$temp_dir" + done + TAPM_CLEAN_TEMP_DIR "$TAPM_PULSE_BOOTSTRAP_DIR" +} + +TAPM_CREATE_TEMP_DIR() { + local label="${1:-installer}" + + TAPM_TEMP_DIR="$(mktemp -d "/tmp/ta-proxmenu-${label}.XXXXXX")" || { + echo -e "${idsCL[LightRed]}Unable to create a temporary installer directory.${idsCL[Default]}" + return 1 + } + if ! chmod 0700 "$TAPM_TEMP_DIR"; then + TAPM_CLEAN_TEMP_DIR "$TAPM_TEMP_DIR" + echo -e "${idsCL[LightRed]}Unable to secure the temporary installer directory.${idsCL[Default]}" + return 1 + fi + TAPM_TEMP_DIRS+=("$TAPM_TEMP_DIR") +} + +TAPM_DOWNLOAD_HTTPS() { + local url="$1" + local destination="$2" + local label="${3:-Installer}" + + if ! TAPM_PULSE_VALID_SOURCE_BASE "$url"; then + echo -e "${idsCL[LightRed]}${label} requires a valid HTTPS URL.${idsCL[Default]}" + return 1 + fi + if ! printf 'url = "%s"\n' "$url" | + curl --fail --location --silent --show-error \ + --proto '=https' --proto-redir '=https' \ + --output "$destination" --config -; then + echo -e "${idsCL[LightRed]}${label} download failed.${idsCL[Default]}" + return 1 + fi + if [[ ! -s "$destination" ]]; then + echo -e "${idsCL[LightRed]}${label} download was empty.${idsCL[Default]}" + return 1 + fi +} + +EXIT1() { + stty echo 2>/dev/null || true + printf '%s' "${idsCL[Default]}" +} + +SELECT_MENU() { + local title="$1" + local labels_name="$2" + local values_name="$3" + local allow_back="${4:-1}" + local -n labels_ref="$labels_name" + local -n values_ref="$values_name" + local selected=0 + local key sequence index + + while true; do + if [[ -t 1 ]]; then + clear 2>/dev/null || printf '\e[H\e[2J' + fi + echo + echo -e " ${idsCL[LightCyan]}${title}${idsCL[Default]}" + echo + for index in "${!labels_ref[@]}"; do + if (( index == selected )); then + printf '\e[7m %d %-64s\e[0m\n' \ + "$((index + 1))" "${labels_ref[$index]}" + else + printf ' %d %s\n' "$((index + 1))" "${labels_ref[$index]}" + fi + done + echo + if (( allow_back == 1 )); then + echo " ↑/↓ Navigate Enter Select Number Quick Select ←/Esc/B Back Q Quit" + else + echo " ↑/↓ Navigate Enter Select Number Quick Select Q Quit" + fi + + IFS= read -rsn1 key + case "$key" in + "") + MENU_SELECTION="${values_ref[$selected]}" + return 0 + ;; + [1-9]) + index=$((10#$key - 1)) + if (( index < ${#values_ref[@]} )); then + MENU_SELECTION="${values_ref[$index]}" + return 0 + fi + ;; + [Qq]) + MENU_SELECTION=quit + return 0 + ;; + [Bb]) + if (( allow_back == 1 )); then + MENU_SELECTION=back + return 0 + fi + ;; + $'\e') + sequence='' + IFS= read -rsn2 -t 0.1 sequence || true + case "$sequence" in + "[A"|"OA") + ((selected = (selected - 1 + ${#labels_ref[@]}) % ${#labels_ref[@]})) + ;; + "[B"|"OB") + ((selected = (selected + 1) % ${#labels_ref[@]})) + ;; + "[C"|"OC") + MENU_SELECTION="${values_ref[$selected]}" + return 0 + ;; + "[D"|"OD"|"") + if (( allow_back == 1 )); then + MENU_SELECTION=back + return 0 + fi + ;; + esac + ;; + esac + done +} + +TAPM_PULSE_STANDALONE_MAIN() { + local iso_helpers pulse_module + + TAPM_PULSE_DEFINE_COLORS + if (( BASH_VERSINFO[0] < 4 || + (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3) )); then + echo "Pulse deployment requires Bash 4.3 or newer." >&2 + return 1 + fi + if (( EUID != 0 )); then + echo "Run this installer as root on a Proxmox VE host." >&2 + return 1 + fi + for command in curl pct pvesh; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Required command '${command}' was not found." >&2 + return 1 + fi + done + if ! TAPM_PULSE_VALID_SOURCE_BRANCH "$TAPM_PULSE_SOURCE_BRANCH" || + ! TAPM_PULSE_VALID_SOURCE_BASE "$TAPM_PULSE_SOURCE_BASE"; then + echo "The configured TA-ProxMenu source branch or URL is invalid." >&2 + return 1 + fi + + TAPM_PULSE_BOOTSTRAP_DIR="$( + mktemp -d /tmp/ta-proxmenu-pulse-bootstrap.XXXXXX + )" || return 1 + chmod 0700 "$TAPM_PULSE_BOOTSTRAP_DIR" || return 1 + iso_helpers="${TAPM_PULSE_BOOTSTRAP_DIR}/deploy-iso-nfs-lxc.sh" + pulse_module="${TAPM_PULSE_BOOTSTRAP_DIR}/deploy-pulse-lxc.sh" + + echo "Downloading the TA-managed Pulse deployment components..." + TAPM_DOWNLOAD_HTTPS \ + "${TAPM_PULSE_SOURCE_BASE}/inc/deploy-iso-nfs-lxc.sh" \ + "$iso_helpers" "LXC storage helper" || return 1 + TAPM_DOWNLOAD_HTTPS \ + "${TAPM_PULSE_SOURCE_BASE}/inc/deploy-pulse-lxc.sh" \ + "$pulse_module" "Pulse deployment module" || return 1 + bash -n "$iso_helpers" "$pulse_module" || { + echo "A downloaded Pulse deployment component failed validation." >&2 + return 1 + } + grep -q '^TAPM_ISO_NFS_SELECT_STORAGE()' "$iso_helpers" && + grep -q '^TAPM_DEPLOY_PULSE_LXC()' "$pulse_module" || { + echo "The downloaded files were not recognized as TAPM deployment components." >&2 + return 1 + } + + # shellcheck disable=SC1090 + source "$iso_helpers" + # shellcheck disable=SC1090 + source "$pulse_module" + TAPM_DEPLOY_PULSE_LXC +} + +if [[ "${TAPM_PULSE_STANDALONE_NO_MAIN:-0}" != 1 ]]; then + trap TAPM_CLEAN_ALL_TEMP_DIRS EXIT + TAPM_PULSE_STANDALONE_MAIN "$@" +fi diff --git a/tests/test-pulse-standalone.sh b/tests/test-pulse-standalone.sh new file mode 100755 index 0000000..8363613 --- /dev/null +++ b/tests/test-pulse-standalone.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${TEST_ROOT}/tests/testlib.sh" +TAPM_PULSE_STANDALONE_NO_MAIN=1 +source "${TEST_ROOT}/install-pulse.sh" + +assert_success "default V2 source branch accepted" \ + TAPM_PULSE_VALID_SOURCE_BRANCH V2 +assert_success "nested release branch accepted" \ + TAPM_PULSE_VALID_SOURCE_BRANCH releases/pulse-6 +assert_failure "empty source branch rejected" \ + TAPM_PULSE_VALID_SOURCE_BRANCH '' +assert_failure "source branch traversal rejected" \ + TAPM_PULSE_VALID_SOURCE_BRANCH '../main' +assert_success "HTTPS source base accepted" \ + TAPM_PULSE_VALID_SOURCE_BASE \ + https://git.schroedercity.com/TAI/TA-ProxMenu/raw/branch/V2 +assert_failure "HTTP source base rejected" \ + TAPM_PULSE_VALID_SOURCE_BASE \ + http://git.schroedercity.com/TAI/TA-ProxMenu/raw/branch/V2 + +finish_tests From 9b2e7046296d0d874bc0ce8b8572103b64253806 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Tue, 28 Jul 2026 19:05:45 -0500 Subject: [PATCH 64/76] Update defaults.inc --- defaults.inc | 1 + 1 file changed, 1 insertion(+) diff --git a/defaults.inc b/defaults.inc index 3d0486e..9e93d07 100755 --- a/defaults.inc +++ b/defaults.inc @@ -32,3 +32,4 @@ S1_PACKAGE='tapm-sentinelone.deb' # wget -O /etc/apt/trusted.gpg.d/proxlb.asc https://repo.gyptazy.com/repository.gpg # apt-get update # fi + From 8f8002f33eb69ce51ba78f2d818d0d3b4c4390e3 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Tue, 28 Jul 2026 19:18:02 -0500 Subject: [PATCH 65/76] update gir url --- README.md | 24 ++---- colors.inc | 50 ++++++++++++ defaults.inc | 16 +++- install-pulse.sh | 2 +- proxmenu-scripts.sh | 3 +- run.sh | 110 +-------------------------- tests/test-pulse-standalone.sh | 4 +- tests/test-self-contained-runtime.sh | 22 ++++++ 8 files changed, 99 insertions(+), 132 deletions(-) create mode 100755 colors.inc create mode 100755 tests/test-self-contained-runtime.sh diff --git a/README.md b/README.md index 51c0a64..d857a79 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,9 @@ Run as `root` on a Proxmox VE host: bash <(curl -fsSL https://go.scity.us/install-tapm) ``` -The installed launcher loads the shared iDSSYS defaults and opens the -interactive menu. Update availability is checked in the background and cached; -updates are installed only when explicitly selected or requested with -`tapm update`. +The installed launcher loads TA-ProxMenu's bundled defaults and colors, then +opens the interactive menu. Updates are installed only when explicitly +selected or requested with `tapm update`. On a clustered Proxmox host, both `tapm update` and the management-menu update update every online cluster node over Proxmox's root SSH trust, then update the @@ -27,12 +26,6 @@ When testing this branch, use `tapm main` to switch the installed copy back to the published main branch. The command refuses to switch when local changes, local-only commits, or diverged branch history would be at risk. -The required iDSSYS Defaults repository is handled separately: it is -automatically refreshed before launch when its last successful check is more -than four hours old. If the remote is unavailable, the installed copy is used. -Updates are fast-forward-only. Local file changes, local-only commits, and -diverged histories are preserved and reported instead of being overwritten. - The TA-ProxMenu Management menu can safely switch TA-ProxMenu between branches published on its Git origin. Branch switching is refused when the installed repository has local changes or when the destination branch has commits that would be @@ -73,8 +66,7 @@ portal without changing ProxMenu. - Proxmox VE and root privileges - Bash, Git, curl, wget, Python 3, and standard Debian package tools -- `/opt/idssys/defaults/default.inc` -- `/opt/idssys/defaults/colors.inc` +- The bundled `defaults.inc` and `colors.inc` files The Keepalived deployment additionally requires a healthy, quorate Proxmox cluster and passwordless root SSH between cluster nodes. @@ -111,7 +103,7 @@ Pulse can also be deployed without installing TA-ProxMenu. Run the standalone bootstrap as root on a Proxmox VE host: ```bash -bash <(curl -fsSL https://git.schroedercity.com/TAI/TA-ProxMenu/raw/branch/V2/install-pulse.sh) +bash <(curl -fsSL https://tagit.technologyarch.com/taiadmin/TA-ProxMenu/raw/branch/V2/install-pulse.sh) ``` The bootstrap downloads the Pulse deployment module and its LXC storage helper @@ -170,9 +162,9 @@ GITEA_DOMAIN=git.example.com Replace it with the deployed HTTPS origin, without an API path. There is no hard-coded operational broker URL; authorization fails with a configuration message when the variable is missing or invalid. `GITEA_DOMAIN` is a hostname, -without `https://` or a path, and controls Gitea connectivity plus restoration -of the `voltron/iDS-Defaults.git` repository. Repository updates do not -overwrite the system configuration file. +without `https://` or a path. Repository updates use the installed +TA-ProxMenu checkout's configured Git `origin` and do not overwrite the system +configuration file. On the first V2 launch after installation or upgrade, TA-ProxMenu detects a missing or incomplete `/etc/ta-proxmenu/config.env` and interactively requests diff --git a/colors.inc b/colors.inc new file mode 100755 index 0000000..13d461f --- /dev/null +++ b/colors.inc @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# + +declare -A idsCL +idsCL[Default]="\e[39m" +idsCL[White]="\e[97m" +idsCL[LightGray]="\e[37m" +idsCL[DarkGray]="\e[90m" +idsCL[Black]="\e[30m" +idsCL[Red]="\e[31m" +idsCL[RedBold]="\e[31;1m" +idsCL[LightRed]="\e[91m" +idsCL[Magenta]="\e[35m" +idsCL[LightMagenta]="\e[95m" +idsCL[Blue]="\e[34m" +idsCL[LightBlue]="\e[94m" +idsCL[Cyan]="\e[36m" +idsCL[LightCyan]="\e[96m" +idsCL[Green]="\e[32m" +idsCL[LightGreen]="\e[92m" +idsCL[Yellow]="\e[33m" +idsCL[LightYellow]="\e[93m" + +declare -A idsBG +idsBG[Default]="\e[49m" +idsBG[Black]="\e[40m" +idsBG[Red]="\e[41m" +idsBG[Green]="\e[42m" +idsBG[Yellow]="\e[43m" +idsBG[Blue]="\e[44m" +idsBG[Magenta]="\e[45m" +idsBG[Cyan]="\e[46m" +idsBG[LightGray]="\e[47m" +idsBG[DarkGray]="\e[100m" +idsBG[LightRed]="\e[101m" +idsBG[LightGreen]="\e[102m" +idsBG[LightYellow]="\e[103m" +idsBG[LightBlue]="\e[104m" +idsBG[LightMagenta]="\e[105m" +idsBG[LightCyan]="\e[106m" +idsBG[White]="\e[107m" + +declare -A idsST +idsST[Reset]="\e[0m" +idsST[Bold]="\e[1m" +idsST[Dim]="\e[2m" +idsST[UnderLine]="\e[4m" +idsST[Blink]="\e[5m" +idsST[Invert]="\e[7m" +idsST[Hidden]="\e[8m" diff --git a/defaults.inc b/defaults.inc index 9e93d07..a700730 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.28-1' +VERS='2026.7.28-2' noupdate=' ' @@ -26,10 +26,22 @@ TAPM_FLEET_ENSURE_IDENTITY || true S1_BROKER_PACKAGE='sentinelone-linux' S1_PACKAGE='tapm-sentinelone.deb' +# Imported from iDS-Defaults/default.inc. These are the only shared-default +# functions TA-ProxMenu calls. +EXIT1() { + clear + exit 0 +} + +ENTER2CONTINUE() { + echo + read -r -s -p "[Press ENTER to continue]" + echo -e "\e[1A\n\e[0K\r\n" +} + # if [ -f /etc/apt/sources.list.d/gyptazy.list ]; then # rm -f /etc/apt/sources.list.d/gyptazy.list /etc/apt/keyrings/gyptazy.asc # echo "deb https://repo.gyptazy.com/stable /" > /etc/apt/sources.list.d/proxlb.list # wget -O /etc/apt/trusted.gpg.d/proxlb.asc https://repo.gyptazy.com/repository.gpg # apt-get update # fi - diff --git a/install-pulse.sh b/install-pulse.sh index d86c097..bbd2849 100755 --- a/install-pulse.sh +++ b/install-pulse.sh @@ -4,7 +4,7 @@ set -u -o pipefail TAPM_PULSE_SOURCE_BRANCH="${TAPM_PULSE_SOURCE_BRANCH:-V2}" -TAPM_PULSE_SOURCE_BASE="${TAPM_PULSE_SOURCE_BASE:-https://git.schroedercity.com/TAI/TA-ProxMenu/raw/branch/${TAPM_PULSE_SOURCE_BRANCH}}" +TAPM_PULSE_SOURCE_BASE="${TAPM_PULSE_SOURCE_BASE:-https://tagit.technologyarch.com/taiadmin/TA-ProxMenu/raw/branch/${TAPM_PULSE_SOURCE_BRANCH}}" TAPM_PULSE_BOOTSTRAP_DIR='' TAPM_TEMP_DIR='' declare -a TAPM_TEMP_DIRS=() diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 15cd0fc..129daa3 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -2,8 +2,7 @@ # TA-Proxmenu - Proxmox Setup Scripts for TA Use -[ "${2:-}" != "q" ] && source /opt/idssys/defaults/colors.inc -source /opt/idssys/defaults/default.inc +[ "${2:-}" != "q" ] && source /opt/idssys/ta-proxmenu/colors.inc source /opt/idssys/ta-proxmenu/defaults.inc source /opt/idssys/ta-proxmenu/inc/git-update.inc source /opt/idssys/ta-proxmenu/inc/ha-status.inc diff --git a/run.sh b/run.sh index 04378bc..c8e3ffa 100755 --- a/run.sh +++ b/run.sh @@ -1,105 +1,13 @@ #!/usr/bin/env bash # TA-ProxMenu preloader -DEFAULTS_REPOSITORY='/opt/idssys/defaults' -DEFAULTS_CACHE_DIR='/var/cache/ta-proxmenu' -DEFAULTS_CHECK_FILE="${DEFAULTS_CACHE_DIR}/defaults-last-check" -DEFAULTS_CHECK_SECONDS=14400 source /opt/idssys/ta-proxmenu/inc/runtime-config.inc TAPM_ENSURE_RUNTIME_CONFIG || exit 1 -DEFAULTS_REPOSITORY_URL="${GITEA_URL}/voltron/iDS-Defaults.git" source /opt/idssys/ta-proxmenu/inc/git-update.inc source /opt/idssys/ta-proxmenu/inc/cluster-update.inc -AUTO_UPDATE_DEFAULTS() { - local checked_at=0 - local current_branch - local now - local state - - mkdir -p "$DEFAULTS_CACHE_DIR" 2>/dev/null || true - now="$(date +%s)" - [[ -r "$DEFAULTS_CHECK_FILE" ]] && read -r checked_at <"$DEFAULTS_CHECK_FILE" - - if [[ "$checked_at" =~ ^[0-9]+$ ]] && - (( now - checked_at < DEFAULTS_CHECK_SECONDS )); then - return - fi - - exec 9>"${DEFAULTS_CACHE_DIR}/defaults-update.lock" || return - flock -n 9 || return - - # Another process may have completed the update while this one waited. - checked_at=0 - [[ -r "$DEFAULTS_CHECK_FILE" ]] && read -r checked_at <"$DEFAULTS_CHECK_FILE" - if [[ "$checked_at" =~ ^[0-9]+$ ]] && - (( now - checked_at < DEFAULTS_CHECK_SECONDS )); then - return - fi - - if [[ ! -d "${DEFAULTS_REPOSITORY}/.git" ]]; then - echo "iDSSYS Defaults is missing; restoring it from origin..." - if [[ -z "$DEFAULTS_REPOSITORY_URL" ]]; then - echo "WARNING: GITEA_DOMAIN is not configured; unable to restore iDSSYS Defaults." >&2 - return - fi - mkdir -p /opt/idssys - if ! timeout 60 git clone \ - "$DEFAULTS_REPOSITORY_URL" "$DEFAULTS_REPOSITORY"; then - echo "WARNING: Unable to restore iDSSYS Defaults." >&2 - return - fi - else - current_branch="$(git -C "$DEFAULTS_REPOSITORY" branch --show-current 2>/dev/null)" - if [[ "$current_branch" != "master" ]]; then - echo "WARNING: iDSSYS Defaults is not on master; automatic update skipped." >&2 - date +%s >"$DEFAULTS_CHECK_FILE" - return - fi - if TAPM_GIT_WORKTREE_DIRTY "$DEFAULTS_REPOSITORY"; then - echo "WARNING: iDSSYS Defaults has local changes; automatic update skipped." >&2 - date +%s >"$DEFAULTS_CHECK_FILE" - return - fi - if ! TAPM_GIT_FETCH_BRANCH "$DEFAULTS_REPOSITORY" master 20 \ - >/dev/null 2>&1; then - echo "WARNING: Unable to check iDSSYS Defaults; using the installed copy." >&2 - return - fi - - state="$(TAPM_GIT_BRANCH_STATE "$DEFAULTS_REPOSITORY" master)" - case "$state" in - behind) - echo "Updating required iDSSYS Defaults..." - if ! TAPM_GIT_FAST_FORWARD "$DEFAULTS_REPOSITORY" master \ - >/dev/null 2>&1; then - echo "WARNING: Unable to update iDSSYS Defaults; using the installed copy." >&2 - return - fi - ;; - current) - ;; - ahead) - echo "WARNING: iDSSYS Defaults has local commits not on origin; automatic update skipped." >&2 - ;; - diverged) - echo "WARNING: iDSSYS Defaults has diverged from origin; automatic update skipped." >&2 - ;; - *) - echo "WARNING: Unable to update iDSSYS Defaults; using the installed copy." >&2 - return - ;; - esac - fi - - date +%s >"$DEFAULTS_CHECK_FILE" -} - -AUTO_UPDATE_DEFAULTS - -[ "${2:-}" != "q" ] && source /opt/idssys/defaults/colors.inc -source /opt/idssys/defaults/default.inc +[ "${2:-}" != "q" ] && source /opt/idssys/ta-proxmenu/colors.inc source /opt/idssys/ta-proxmenu/defaults.inc TAPM_FLEET_START "$VERS" trap 'TAPM_FLEET_FINISH "$?"' EXIT @@ -240,22 +148,9 @@ SWITCH_TAPM_BRANCH() { INSTALL_LOCAL_UPDATES() { local current_branch - local defaults_warning=0 local update_failed=0 echo -e "${idsCL[LightCyan]}Checking for updates...${idsCL[Default]}" - if [[ -z "$GITEA_URL" ]]; then - echo -e "${idsCL[Red]}GITEA_DOMAIN is not configured in ${TAPM_CONFIG_FILE}.${idsCL[Default]}" - return 1 - fi - if ! curl --fail --silent --show-error --head \ - --connect-timeout 3 --max-time 10 "$GITEA_URL" >/dev/null; then - echo -e "${idsCL[Red]}Could not connect to ${GITEA_DOMAIN}${idsCL[Default]}" - return 1 - fi - - UPDATE_REPOSITORY /opt/idssys/defaults master "iDSSYS Defaults" || - defaults_warning=1 current_branch="$(git -C /opt/idssys/ta-proxmenu branch --show-current)" if [ -z "$current_branch" ]; then @@ -271,9 +166,6 @@ INSTALL_LOCAL_UPDATES() { if (( update_failed == 0 )); then source /opt/idssys/ta-proxmenu/defaults.inc echo -e "\n${idsCL[Green]}Update check complete. Installed version: ${VERS}${idsCL[Default]}" - if (( defaults_warning == 1 )); then - echo -e "${idsCL[LightYellow]}TA-ProxMenu was processed, but iDSSYS Defaults requires attention.${idsCL[Default]}" - fi return 0 fi diff --git a/tests/test-pulse-standalone.sh b/tests/test-pulse-standalone.sh index 8363613..3fac251 100755 --- a/tests/test-pulse-standalone.sh +++ b/tests/test-pulse-standalone.sh @@ -16,9 +16,9 @@ assert_failure "source branch traversal rejected" \ TAPM_PULSE_VALID_SOURCE_BRANCH '../main' assert_success "HTTPS source base accepted" \ TAPM_PULSE_VALID_SOURCE_BASE \ - https://git.schroedercity.com/TAI/TA-ProxMenu/raw/branch/V2 + https://tagit.technologyarch.com/taiadmin/TA-ProxMenu/raw/branch/V2 assert_failure "HTTP source base rejected" \ TAPM_PULSE_VALID_SOURCE_BASE \ - http://git.schroedercity.com/TAI/TA-ProxMenu/raw/branch/V2 + http://tagit.technologyarch.com/taiadmin/TA-ProxMenu/raw/branch/V2 finish_tests diff --git a/tests/test-self-contained-runtime.sh b/tests/test-self-contained-runtime.sh new file mode 100755 index 0000000..023663c --- /dev/null +++ b/tests/test-self-contained-runtime.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${TEST_ROOT}/tests/testlib.sh" +source "${TEST_ROOT}/colors.inc" + +assert_equal '\e[96m' "${idsCL[LightCyan]}" \ + "bundled foreground colors are available" +assert_equal '\e[103m' "${idsBG[LightYellow]}" \ + "bundled background colors are available" +assert_equal '\e[1m' "${idsST[Bold]}" \ + "bundled text styles are available" +assert_success "EXIT1 is bundled in TA-ProxMenu defaults" \ + grep -q '^EXIT1()' "${TEST_ROOT}/defaults.inc" +assert_success "ENTER2CONTINUE is bundled in TA-ProxMenu defaults" \ + grep -q '^ENTER2CONTINUE()' "${TEST_ROOT}/defaults.inc" +assert_failure "runtime launchers do not reference external iDS defaults" \ + grep -E -q '/opt/idssys/defaults|iDS-Defaults' \ + "${TEST_ROOT}/run.sh" "${TEST_ROOT}/proxmenu-scripts.sh" + +finish_tests From eca9bd2be23626ba61b4c4071b199dc4fd27ebc3 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Tue, 28 Jul 2026 20:02:42 -0500 Subject: [PATCH 66/76] update --- README.md | 21 +++++++++++---------- defaults.inc | 2 +- inc/fleet.inc | 4 ++++ tests/test-fleet.sh | 3 +++ 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d857a79..07c1b89 100644 --- a/README.md +++ b/README.md @@ -178,14 +178,15 @@ a default company URL. V2 creates a random installation UUID and 256-bit credential in `/var/lib/ta-proxmenu/identity.env`. The directory is mode `0700` and the file is mode `0600`. On each interactive launch, TA-ProxMenu makes best-effort HTTPS -calls to the configured broker to record installation/upgrade state, start and -completion status, duration, TA-ProxMenu and Git versions, PVE/OS/kernel -versions, architecture, and whether the host is clustered. No background -service is installed, and broker availability never prevents the menu from -running. +calls to the configured broker to record the hostname, installation/upgrade +state, start and completion status, duration, TA-ProxMenu and Git versions, +PVE/OS/kernel versions, architecture, and whether the host is clustered. No +background service is installed, and broker availability never prevents the +menu from running. -The registry does not send hostnames, machine IDs, MAC addresses, usernames, -customer names, VM/container inventory, deployment codes, authorization -tokens, or command output. The broker stores only a digest of the random host -credential. A successful deployment-code exchange links the already-random -installation ID to a verified registry entry. +The hostname is treated as a limited operational identifier. The registry does +not send machine IDs, MAC addresses, usernames, VM/container inventory, +deployment codes, authorization tokens, credentials, or command output. The +broker stores only a digest of the random host credential. A successful +deployment-code exchange carrying the same random installation ID marks that +registry entry as verified. diff --git a/defaults.inc b/defaults.inc index a700730..4fe4dfc 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.28-2' +VERS='2026.7.28-3' noupdate=' ' diff --git a/inc/fleet.inc b/inc/fleet.inc index 801bb06..3d1ed02 100644 --- a/inc/fleet.inc +++ b/inc/fleet.inc @@ -8,6 +8,7 @@ TAPM_FLEET_LAST_VERSION='' TAPM_FLEET_REGISTERED=0 TAPM_FLEET_STARTED_AT=0 TAPM_FLEET_VERSION='' +TAPM_FLEET_HOSTNAME='' TAPM_FLEET_READ_VALUE() { local key="$1" @@ -67,6 +68,7 @@ TAPM_FLEET_ENSURE_IDENTITY() { } TAPM_FLEET_COLLECT_METADATA() { + TAPM_FLEET_HOSTNAME="$(hostname -s 2>/dev/null || hostname 2>/dev/null || true)" TAPM_FLEET_GIT_COMMIT="$(git -C "${FOLDER:-/opt/idssys/ta-proxmenu}" rev-parse HEAD 2>/dev/null || true)" TAPM_FLEET_PVE_VERSION="$(pveversion 2>/dev/null | head -n 1 || true)" TAPM_FLEET_OS_VERSION="$( @@ -102,6 +104,7 @@ TAPM_FLEET_JSON() { TAPM_FLEET_ERROR_CODE="$error_code" \ TAPM_FLEET_DURATION="$duration" \ TAPM_FLEET_INSTALLATION_ID="$TAPM_FLEET_INSTALLATION_ID" \ + TAPM_FLEET_HOSTNAME="$TAPM_FLEET_HOSTNAME" \ TAPM_FLEET_CREDENTIAL="$TAPM_FLEET_CREDENTIAL" \ TAPM_FLEET_VERSION="$TAPM_FLEET_VERSION" \ TAPM_FLEET_GIT_COMMIT="$TAPM_FLEET_GIT_COMMIT" \ @@ -116,6 +119,7 @@ import json, os, sys payload = { "schema_version": 1, "installation_id": os.environ["TAPM_FLEET_INSTALLATION_ID"], + "hostname": os.environ["TAPM_FLEET_HOSTNAME"], "event": os.environ["TAPM_FLEET_EVENT"], "result": os.environ["TAPM_FLEET_RESULT"], "proxmenu_version": os.environ["TAPM_FLEET_VERSION"], diff --git a/tests/test-fleet.sh b/tests/test-fleet.sh index 6ad4134..c700419 100644 --- a/tests/test-fleet.sh +++ b/tests/test-fleet.sh @@ -26,6 +26,7 @@ if [[ "$identity_mode" != 600 ]]; then fi TAPM_FLEET_VERSION='2026.7.26-7' +TAPM_FLEET_HOSTNAME='pve01' TAPM_FLEET_GIT_COMMIT='0123456789abcdef0123456789abcdef01234567' TAPM_FLEET_PVE_VERSION='pve-manager/9.0.3/abc~1' TAPM_FLEET_OS_VERSION='Debian GNU/Linux 13 (trixie)' @@ -40,6 +41,7 @@ REGISTRATION_JSON="$registration_json" python3 -c ' import json, os payload = json.loads(os.environ["REGISTRATION_JSON"]) assert payload["installation_id"] +assert payload["hostname"] == "pve01" assert len(payload["credential"]) == 64 assert payload["proxmenu_version"] == "2026.7.26-7" assert payload["clustered"] is True @@ -73,6 +75,7 @@ python3 -c ' import json, os payload = json.loads(os.environ["EVENT_BODY"]) assert payload["event"] == "run_completed" +assert payload["hostname"] == "pve01" assert payload["result"] == "success" assert payload["duration_seconds"] == 12 assert "credential" not in payload From 8418cbed5652716de2e9e833782874747da9b4b7 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Tue, 28 Jul 2026 20:14:57 -0500 Subject: [PATCH 67/76] update --- defaults.inc | 2 +- inc/rmm.inc | 24 ++++++++++++++++++++++++ proxmenu-scripts.sh | 24 +++++++++++++++++------- tests/test-rmm.sh | 28 ++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 inc/rmm.inc create mode 100644 tests/test-rmm.sh diff --git a/defaults.inc b/defaults.inc index 4fe4dfc..e0089a0 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.28-3' +VERS='2026.7.28-4' noupdate=' ' diff --git a/inc/rmm.inc b/inc/rmm.inc new file mode 100644 index 0000000..57134ef --- /dev/null +++ b/inc/rmm.inc @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +TAPM_RMM_TOKEN_FROM_URL() { + local url="${1:-}" + local token_pattern + + token_pattern='TKN([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12})/RUN(/|$)' + if [[ "$url" =~ $token_pattern ]]; then + printf '%s' "${BASH_REMATCH[1],,}" + return 0 + fi + return 1 +} + +TAPM_RMM_ENSURE_SUDO() { + if command -v sudo >/dev/null 2>&1; then + return 0 + fi + command -v apt-get >/dev/null 2>&1 || return 1 + + printf 'The RMM installer requires sudo; installing it now...\n' + apt-get update && + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends sudo +} diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 129daa3..352d662 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -7,6 +7,7 @@ source /opt/idssys/ta-proxmenu/defaults.inc source /opt/idssys/ta-proxmenu/inc/git-update.inc source /opt/idssys/ta-proxmenu/inc/ha-status.inc source /opt/idssys/ta-proxmenu/inc/post-install.inc +source /opt/idssys/ta-proxmenu/inc/rmm.inc source /opt/idssys/ta-proxmenu/inc/deploy-iso-nfs-lxc.sh source /opt/idssys/ta-proxmenu/inc/deploy-pulse-lxc.sh source /opt/idssys/ta-proxmenu/inc/virtio-helpers.inc @@ -356,6 +357,7 @@ INSTALL_RMM() { local RMMURL='' local TOKEN='' local installer + local installer_dir local temp_dir echo @@ -369,17 +371,16 @@ INSTALL_RMM() { read -r -s RMMURL echo [[ -n "$RMMURL" ]] || { echo "No URL supplied."; FINISH_FAILED_ACTION; return; } - if [[ "$RMMURL" != *TKN* || "$RMMURL" != */RUN* ]]; then + if ! TOKEN="$(TAPM_RMM_TOKEN_FROM_URL "$RMMURL")"; then echo "Unable to extract the RMM token from the URL." - unset RMMURL + unset RMMURL TOKEN FINISH_FAILED_ACTION return fi - TOKEN="${RMMURL#*TKN}" - TOKEN="${TOKEN%%/RUN*}" - if [[ -z "$TOKEN" ]]; then + + if ! TAPM_RMM_ENSURE_SUDO; then unset RMMURL TOKEN - echo "The RMM token is empty." + echo -e "${idsCL[LightRed]}Unable to install the sudo dependency required by the RMM installer.${idsCL[Default]}" FINISH_FAILED_ACTION return fi @@ -390,7 +391,16 @@ INSTALL_RMM() { return fi temp_dir="$TAPM_TEMP_DIR" - installer="${temp_dir}/rmminstall" + # Preserve the token-bearing portion of the original URL because the + # vendor installer also attempts to derive TOKEN from its own pathname. + installer_dir="${temp_dir}/ITSPlatform_TKN${TOKEN}/RUN" + if ! mkdir -p "$installer_dir" || ! chmod 0700 "$installer_dir"; then + unset RMMURL TOKEN + TAPM_CLEAN_TEMP_DIR "$temp_dir" + FINISH_FAILED_ACTION + return + fi + installer="${installer_dir}/setup" if ! TAPM_DOWNLOAD_HTTPS "$RMMURL" "$installer" 'RMM installer'; then unset RMMURL TOKEN TAPM_CLEAN_TEMP_DIR "$temp_dir" diff --git a/tests/test-rmm.sh b/tests/test-rmm.sh new file mode 100644 index 0000000..020616a --- /dev/null +++ b/tests/test-rmm.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +TEST_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" +# shellcheck source=../inc/rmm.inc +source "${TEST_ROOT}/inc/rmm.inc" + +example_token='a81581f5-2e8c-465a-9eff-a094a75d5bbf' +example_url="https://prod.setup.itsupport247.net/linux/BareboneAgent/64/TA_Green_Bay-Technology_Arch_Corporate_Linux_Server_ITSPlatform_TKN${example_token}/RUN/setup" + +actual_token="$(TAPM_RMM_TOKEN_FROM_URL "$example_url")" +if [[ "$actual_token" != "$example_token" ]]; then + printf 'FAIL: extracted %q, want %q\n' "$actual_token" "$example_token" >&2 + exit 1 +fi + +if TAPM_RMM_TOKEN_FROM_URL 'https://prod.setup.itsupport247.net/linux/setup' >/dev/null; then + printf 'FAIL: accepted an RMM URL without a token\n' >&2 + exit 1 +fi + +if TAPM_RMM_TOKEN_FROM_URL \ + 'https://prod.setup.itsupport247.net/TKNnot-a-token/RUN/setup' >/dev/null; then + printf 'FAIL: accepted a malformed RMM token\n' >&2 + exit 1 +fi + +printf 'PASS: RMM token extraction\n' From 741e2ad9e8f71db40868f2f32aac1de2aed3bb0d Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Tue, 28 Jul 2026 20:41:57 -0500 Subject: [PATCH 68/76] update --- defaults.inc | 2 +- inc/secure-input.inc | 27 +++++++++++++++++++++++++++ proxmenu-scripts.sh | 14 ++++++-------- tests/test-secure-input.sh | 25 +++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 inc/secure-input.inc create mode 100644 tests/test-secure-input.sh diff --git a/defaults.inc b/defaults.inc index e0089a0..dd5eda1 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.28-4' +VERS='2026.7.28-5' noupdate=' ' diff --git a/inc/secure-input.inc b/inc/secure-input.inc new file mode 100644 index 0000000..ea14dff --- /dev/null +++ b/inc/secure-input.inc @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +TAPM_READ_MASKED() { + local destination="$1" + local character='' + local value='' + + while IFS= read -r -s -n 1 character; do + if [[ -z "$character" ]]; then + break + fi + case "$character" in + $'\177' | $'\b') + if [[ -n "$value" ]]; then + value="${value%?}" + printf '\b \b' + fi + ;; + *) + value+="$character" + printf '*' + ;; + esac + done + printf '\n' + printf -v "$destination" '%s' "$value" +} diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 352d662..338307a 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -8,6 +8,7 @@ source /opt/idssys/ta-proxmenu/inc/git-update.inc source /opt/idssys/ta-proxmenu/inc/ha-status.inc source /opt/idssys/ta-proxmenu/inc/post-install.inc source /opt/idssys/ta-proxmenu/inc/rmm.inc +source /opt/idssys/ta-proxmenu/inc/secure-input.inc source /opt/idssys/ta-proxmenu/inc/deploy-iso-nfs-lxc.sh source /opt/idssys/ta-proxmenu/inc/deploy-pulse-lxc.sh source /opt/idssys/ta-proxmenu/inc/virtio-helpers.inc @@ -172,8 +173,7 @@ TAPM_AUTHORIZE() { fi echo echo -en "${idsCL[LightYellow]}Paste the TAPM deployment code: ${idsCL[Default]}" - read -r -s deploycode - echo + TAPM_READ_MASKED deploycode deploycode="${deploycode^^}" if [[ ! "$deploycode" =~ ^TAPM-[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$ ]]; then unset deploycode @@ -311,8 +311,7 @@ INSTALL_SCREENCONNECT() { fi TAPM_CLEAR_AUTHORIZATION echo -en "\n${idsCL[LightYellow]}Paste the URL provided from the Build Installer: ${idsCL[Default]}" - read -r -s SCURL - echo + TAPM_READ_MASKED SCURL [[ -n "$SCURL" ]] || { echo "No URL supplied."; FINISH_FAILED_ACTION; return; } if ! TAPM_CREATE_TEMP_DIR screenconnect; then unset SCURL @@ -368,8 +367,7 @@ INSTALL_RMM() { TAPM_CLEAR_AUTHORIZATION echo -en "\n${idsCL[LightYellow]}Paste the Linux Server URL provided from the Download Agent screen: ${idsCL[Default]}" - read -r -s RMMURL - echo + TAPM_READ_MASKED RMMURL [[ -n "$RMMURL" ]] || { echo "No URL supplied."; FINISH_FAILED_ACTION; return; } if ! TOKEN="$(TAPM_RMM_TOKEN_FROM_URL "$RMMURL")"; then echo "Unable to extract the RMM token from the URL." @@ -439,6 +437,7 @@ INSTALL_S1() { FINISH_FAILED_ACTION return fi + echo if ! TAPM_CREATE_TEMP_DIR sentinelone; then TAPM_CLEAR_AUTHORIZATION @@ -477,8 +476,7 @@ INSTALL_S1() { TAPM_CLEAR_AUTHORIZATION echo -en "${idsCL[LightYellow]}Paste the customers SentinelOne Site Token: ${idsCL[Default]}" - read -r -s s1token - echo + TAPM_READ_MASKED s1token [[ -n "$s1token" ]] || { TAPM_CLEAN_TEMP_DIR "$temp_dir" echo "No SentinelOne site token supplied." diff --git a/tests/test-secure-input.sh b/tests/test-secure-input.sh new file mode 100644 index 0000000..a4b1b6d --- /dev/null +++ b/tests/test-secure-input.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +TEST_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" +# shellcheck source=../inc/secure-input.inc +source "${TEST_ROOT}/inc/secure-input.inc" + +masked_value='' +output_file="$(mktemp)" +trap 'rm -f "$output_file"' EXIT + +TAPM_READ_MASKED masked_value >"$output_file" <<'EOF' +TAPM-secret +EOF + +if [[ "$masked_value" != 'TAPM-secret' ]]; then + printf 'FAIL: masked input did not preserve the entered value\n' >&2 + exit 1 +fi +if [[ "$(cat "$output_file")" != '***********' ]]; then + printf 'FAIL: masked input did not display one marker per character\n' >&2 + exit 1 +fi + +printf 'PASS: masked secret input\n' From 72f6be5b43aae5163130e4c8616e58487f93a47a Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Tue, 28 Jul 2026 21:12:05 -0500 Subject: [PATCH 69/76] update --- README.md | 2 +- defaults.inc | 2 +- inc/runtime-config.inc | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 07c1b89..d8560c4 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ iso-nfs Create an LXC NFS server and cluster-wide shared ISO storage Large installer artifacts used by this project are stored in the private `TAI/files` package registry. SentinelOne, RMM, Acronis, and ScreenConnect installation require a temporary deployment code from TAPM Deployment Access. -The private Gitea credentials are never stored on or returned to a Proxmox +The private Git credentials are never stored on or returned to a Proxmox host. SentinelOne package versions can be updated through the deployment portal without changing ProxMenu. diff --git a/defaults.inc b/defaults.inc index dd5eda1..3316433 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.28-5' +VERS='2026.7.28-6' noupdate=' ' diff --git a/inc/runtime-config.inc b/inc/runtime-config.inc index 98b8e1c..b6bc307 100644 --- a/inc/runtime-config.inc +++ b/inc/runtime-config.inc @@ -82,7 +82,7 @@ TAPM_ENSURE_RUNTIME_CONFIG() { printf 'Enter an HTTPS origin without a path.\n' >&3 done while true; do - printf 'Gitea hostname (example: git.example.com): ' >&3 + printf 'Git hostname (example: git.example.com): ' >&3 IFS= read -r gitea_domain <"$input_device" || return 1 TAPM_VALID_GITEA_DOMAIN "$gitea_domain" && break printf 'Enter a hostname without https:// or a path.\n' >&3 From c48e89af6cdbfa9380eb1bbf613950cc9beeb110 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Tue, 28 Jul 2026 21:15:50 -0500 Subject: [PATCH 70/76] update gui --- defaults.inc | 2 +- proxmenu-scripts.sh | 45 +++++++++++++++++++++++++++++++++++---------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/defaults.inc b/defaults.inc index 3316433..b5484af 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.28-6' +VERS='2026.7.28-7' noupdate=' ' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 338307a..9423bc0 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -1653,7 +1653,13 @@ FORCE_UPDATE_CHECK() { MENU_HEADER() { - local version_display + local header_title='TA-ProxMenu - Proxmox Setup Scripts' + local header_width=75 + local status_color='' + local status_display='' + local version_color="${idsCL[White]}" + local right_width + local spacer_width # Reuse a settled update result instead of running Git checks on every # arrow-key redraw. Continue refreshing only while the worker is pending. @@ -1662,28 +1668,47 @@ MENU_HEADER() { fi case "$UPDATE_STATUS" in behind) - version_display="${idsCL[LightYellow]}${VERS} ** UPDATE AVAILABLE **${idsCL[Default]}" + status_display='** UPDATE AVAILABLE **' + status_color="${idsCL[LightYellow]}" + version_color="${idsCL[LightYellow]}" ;; ahead) - version_display="${idsCL[LightYellow]}${VERS} ** LOCAL COMMITS **${idsCL[Default]}" + status_display='** LOCAL COMMITS **' + status_color="${idsCL[LightYellow]}" + version_color="${idsCL[LightYellow]}" ;; diverged) - version_display="${idsCL[LightRed]}${VERS} ** BRANCH DIVERGED **${idsCL[Default]}" + status_display='** BRANCH DIVERGED **' + status_color="${idsCL[LightRed]}" + version_color="${idsCL[LightRed]}" ;; dirty) - version_display="${idsCL[LightYellow]}${VERS} ** LOCAL CHANGES **${idsCL[Default]}" + status_display='** LOCAL CHANGES **' + status_color="${idsCL[LightYellow]}" + version_color="${idsCL[LightYellow]}" ;; checking) - version_display="${idsCL[LightCyan]}${VERS} (checking for updates)${idsCL[Default]}" - ;; - *) - version_display="${idsCL[White]}${VERS}${idsCL[Default]}" + status_display='(checking for updates)' + status_color="${idsCL[LightCyan]}" + version_color="${idsCL[LightCyan]}" ;; esac + right_width="${#VERS}" + if [[ -n "$status_display" ]]; then + right_width=$((right_width + ${#status_display} + 2)) + fi + spacer_width=$((header_width - 1 - ${#header_title} - right_width)) + ((spacer_width < 2)) && spacer_width=2 + clear echo - echo -e " ${idsCL[Green]}TA-ProxMenu - Proxmox Setup Scripts${idsCL[Default]} ${version_display}" + printf ' %b%s%b%*s' \ + "${idsCL[Green]}" "$header_title" "${idsCL[Default]}" "$spacer_width" '' + if [[ -n "$status_display" ]]; then + printf '%b%s%b ' "$status_color" "$status_display" "${idsCL[Default]}" + fi + printf '%b%s%b\n' "$version_color" "$VERS" "${idsCL[Default]}" echo -e "${idsCL[Green]}---------------------------------------------------------------------------${idsCL[Default]}" echo -e " Hostname: ${idsCL[Cyan]}$(hostname -s)${idsCL[Default]}" echo -e " IP Address: ${idsCL[Cyan]}${RNIP:-Unavailable}${idsCL[Default]}" From 247c4ddace1c5b5364df48818e846df84c661651 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Tue, 28 Jul 2026 21:17:41 -0500 Subject: [PATCH 71/76] updateupdate --- defaults.inc | 2 +- proxmenu-scripts.sh | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/defaults.inc b/defaults.inc index b5484af..bbd56cc 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.28-7' +VERS='2026.7.28-8' noupdate=' ' diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 9423bc0..2aa09d5 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -175,11 +175,6 @@ TAPM_AUTHORIZE() { echo -en "${idsCL[LightYellow]}Paste the TAPM deployment code: ${idsCL[Default]}" TAPM_READ_MASKED deploycode deploycode="${deploycode^^}" - if [[ ! "$deploycode" =~ ^TAPM-[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$ ]]; then - unset deploycode - echo -e "${idsCL[LightRed]}The TAPM deployment code is not valid.${idsCL[Default]}" - return 1 - fi host_fingerprint="$(sha256sum /etc/machine-id | cut -d' ' -f1)" exchange_response="$( From 3bea3baa8891c97ab3f0c2e3d63f47bac70919a0 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Tue, 28 Jul 2026 22:09:13 -0500 Subject: [PATCH 72/76] update --- README.md | 18 ++++++++---- defaults.inc | 2 +- inc/deploy-iso-nfs-lxc.sh | 35 ++++++++++++++--------- inc/deploy-pulse-lxc.sh | 44 +++++++++++++++++++++++----- inc/fleet.inc | 49 ++++++++++++++++++++------------ inc/virtio-helpers.inc | 14 +++++++++ proxmenu-scripts.sh | 35 +++++++++++++++++++++-- tests/test-fleet.sh | 60 +++++++++++++++++++++++++++++++++++++-- tests/test-git-update.sh | 3 ++ tests/test-iso-nfs.sh | 12 ++++++++ tests/test-pulse.sh | 4 +++ tests/test-virtio.sh | 9 ++++++ 12 files changed, 236 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index d8560c4..ba816d4 100644 --- a/README.md +++ b/README.md @@ -177,12 +177,18 @@ a default company URL. V2 creates a random installation UUID and 256-bit credential in `/var/lib/ta-proxmenu/identity.env`. The directory is mode `0700` and the file -is mode `0600`. On each interactive launch, TA-ProxMenu makes best-effort HTTPS -calls to the configured broker to record the hostname, installation/upgrade -state, start and completion status, duration, TA-ProxMenu and Git versions, -PVE/OS/kernel versions, architecture, and whether the host is clustered. No -background service is installed, and broker availability never prevents the -menu from running. +is mode `0600`. The first successful launch enrolls the host with the broker. +Later launches make one best-effort HTTPS call on completion or failure to +record the hostname, duration, result, TA-ProxMenu and Git versions, +PVE/OS/kernel versions, architecture, and whether the host is clustered. A +failed event schedules re-enrollment on the next invocation instead of adding +another retry to the current run. No background service is installed, and +broker availability never prevents the menu from running. + +Automatic Git update checks and the stable VirtIO release lookup are cached for +24 hours. Their explicit **Check again** and **Refresh** actions bypass the +cache. Installer, package-repository, and vendor download traffic occurs only +after a technician selects the corresponding action. The hostname is treated as a limited operational identifier. The registry does not send machine IDs, MAC addresses, usernames, VM/container inventory, diff --git a/defaults.inc b/defaults.inc index bbd56cc..c32651b 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.28-8' +VERS='2026.7.28-9' noupdate=' ' diff --git a/inc/deploy-iso-nfs-lxc.sh b/inc/deploy-iso-nfs-lxc.sh index 189ee3a..7634d3e 100644 --- a/inc/deploy-iso-nfs-lxc.sh +++ b/inc/deploy-iso-nfs-lxc.sh @@ -49,6 +49,15 @@ TAPM_ISO_NFS_FAIL() { return 1 } +TAPM_ISO_NFS_LOCAL_TEMPLATE() { + local storage="$1" + + pveam list "$storage" 2>/dev/null | + awk 'NR > 1 && $1 ~ /:vztmpl\/debian-(13|12)-standard_/ { print $1 }' | + sort -V | + tail -1 +} + TAPM_ISO_NFS_SELECT_STORAGE() { local variable="$1" local label="$2" @@ -198,19 +207,19 @@ TAPM_DEPLOY_ISO_NFS_LXC() { } echo -e "\n${idsCL[LightCyan]}Locating a Debian container template...${idsCL[Default]}" - pveam update || { TAPM_ISO_NFS_FAIL "Could not refresh the template catalog."; return 1; } - template_name="$( - pveam available --section system | - awk '$2 ~ /^debian-(13|12)-standard_/ { print $2 }' | - sort -V | - tail -1 - )" - [[ -n "$template_name" ]] || - { TAPM_ISO_NFS_FAIL "No supported Debian 12/13 standard template was found."; return 1; } - template_path="${template_storage}:vztmpl/${template_name}" - if ! pveam list "$template_storage" 2>/dev/null | - awk 'NR > 1 { print $1 }' | - grep -Fxq -- "$template_path"; then + template_path="$(TAPM_ISO_NFS_LOCAL_TEMPLATE "$template_storage")" + if [[ -z "$template_path" ]]; then + pveam update || + { TAPM_ISO_NFS_FAIL "Could not refresh the template catalog."; return 1; } + template_name="$( + pveam available --section system | + awk '$2 ~ /^debian-(13|12)-standard_/ { print $2 }' | + sort -V | + tail -1 + )" + [[ -n "$template_name" ]] || + { TAPM_ISO_NFS_FAIL "No supported Debian 12/13 standard template was found."; return 1; } + template_path="${template_storage}:vztmpl/${template_name}" pveam download "$template_storage" "$template_name" || { TAPM_ISO_NFS_FAIL "The Debian template download failed."; return 1; } fi diff --git a/inc/deploy-pulse-lxc.sh b/inc/deploy-pulse-lxc.sh index 842c227..b1547d5 100644 --- a/inc/deploy-pulse-lxc.sh +++ b/inc/deploy-pulse-lxc.sh @@ -60,6 +60,28 @@ TAPM_PULSE_VALID_NONNEGATIVE_INTEGER() { [[ "${1:-}" =~ ^[0-9]+$ ]] } +TAPM_PULSE_BACKOFF_NEXT() { + local current="${1:-1}" + local maximum="${2:-8}" + local next + + [[ "$current" =~ ^[1-9][0-9]*$ && + "$maximum" =~ ^[1-9][0-9]*$ ]] || return 1 + next=$((current * 2)) + (( next > maximum )) && next="$maximum" + printf '%s\n' "$next" +} + +TAPM_PULSE_BACKOFF_SLEEP() { + local deadline="$1" + local delay="$2" + local remaining=$((deadline - SECONDS)) + + (( remaining > 0 )) || return 0 + (( delay > remaining )) && delay="$remaining" + sleep "$delay" +} + TAPM_PULSE_VALID_PORT() { [[ "${1:-}" =~ ^[0-9]+$ ]] && (( 10#$1 >= 1 && 10#$1 <= 65535 )) } @@ -498,7 +520,8 @@ TAPM_PULSE_CONFIGURE_SECURITY() { local token_variable="$6" local requested_password="${7:-}" local bootstrap_output bootstrap_token generated_username generated_password generated_api_token - local request_file curl_config response security_ready='no' attempt + local request_file curl_config response security_ready='no' + local deadline delay generated_username='admin' if [[ -n "$requested_password" ]]; then @@ -577,7 +600,9 @@ raise SystemExit(0 if success is True else 1) { printf 'header = "X-API-Token: %s"\n' "$generated_api_token" } >"$curl_config" || return 1 - for (( attempt = 1; attempt <= 15; attempt++ )); do + deadline=$((SECONDS + 30)) + delay=1 + while (( SECONDS < deadline )); do if curl --fail --silent --show-error \ --connect-timeout 3 --max-time 5 \ --config "$curl_config" \ @@ -585,7 +610,8 @@ raise SystemExit(0 if success is True else 1) security_ready='yes' break fi - sleep 2 + TAPM_PULSE_BACKOFF_SLEEP "$deadline" "$delay" + delay="$(TAPM_PULSE_BACKOFF_NEXT "$delay" 4)" done if [[ "$security_ready" != 'yes' ]]; then unset generated_password generated_api_token @@ -796,7 +822,8 @@ TAPM_PULSE_WAIT_AGENT_REGISTERED() { local node="$3" local temp_dir="$4" local curl_config="${temp_dir}/agent-${node}-verify.curl" - local encoded_node response attempt + local encoded_node response + local deadline delay printf 'header = "X-API-Token: %s"\n' "$token" >"$curl_config" || return 1 chmod 0600 "$curl_config" || return 1 @@ -809,8 +836,10 @@ print(urllib.parse.quote(os.environ["TAPM_PULSE_NODE_NAME"], safe="")) # Pulse v6.1.1 completes its Proxmox setup before sending the first host # report. Its built-in registration retries can span at least 135 seconds, - # so allow up to four minutes before treating the active agent as unconfirmed. - for (( attempt = 1; attempt <= 80; attempt++ )); do + # so allow up to four minutes while backing off repeated local lookups. + deadline=$((SECONDS + 240)) + delay=1 + while (( SECONDS < deadline )); do response="$( curl --fail --silent --show-error \ --connect-timeout 3 --max-time 5 \ @@ -821,7 +850,8 @@ print(urllib.parse.quote(os.environ["TAPM_PULSE_NODE_NAME"], safe="")) if TAPM_PULSE_AGENT_REGISTERED_FROM_RESPONSE "$response"; then return 0 fi - sleep 3 + TAPM_PULSE_BACKOFF_SLEEP "$deadline" "$delay" + delay="$(TAPM_PULSE_BACKOFF_NEXT "$delay" 8)" done return 1 } diff --git a/inc/fleet.inc b/inc/fleet.inc index 3d1ed02..9ed1876 100644 --- a/inc/fleet.inc +++ b/inc/fleet.inc @@ -5,6 +5,7 @@ TAPM_FLEET_IDENTITY_FILE="${TAPM_FLEET_IDENTITY_FILE:-${TAPM_FLEET_STATE_DIR}/id TAPM_FLEET_INSTALLATION_ID='' TAPM_FLEET_CREDENTIAL='' TAPM_FLEET_LAST_VERSION='' +TAPM_FLEET_ENROLLED=0 TAPM_FLEET_REGISTERED=0 TAPM_FLEET_STARTED_AT=0 TAPM_FLEET_VERSION='' @@ -26,6 +27,8 @@ TAPM_FLEET_LOAD_IDENTITY() { TAPM_FLEET_INSTALLATION_ID="$(TAPM_FLEET_READ_VALUE INSTALLATION_ID)" TAPM_FLEET_CREDENTIAL="$(TAPM_FLEET_READ_VALUE CREDENTIAL)" TAPM_FLEET_LAST_VERSION="$(TAPM_FLEET_READ_VALUE LAST_VERSION)" + TAPM_FLEET_ENROLLED="$(TAPM_FLEET_READ_VALUE ENROLLED)" + [[ "$TAPM_FLEET_ENROLLED" == 1 ]] || TAPM_FLEET_ENROLLED=0 } TAPM_FLEET_VALID_IDENTITY() { @@ -43,6 +46,7 @@ TAPM_FLEET_WRITE_IDENTITY() { printf 'INSTALLATION_ID=%s\n' "$TAPM_FLEET_INSTALLATION_ID" printf 'CREDENTIAL=%s\n' "$TAPM_FLEET_CREDENTIAL" printf 'LAST_VERSION=%s\n' "$TAPM_FLEET_LAST_VERSION" + printf 'ENROLLED=%s\n' "$TAPM_FLEET_ENROLLED" } >"$temporary_file" || ! chmod 0600 "$temporary_file" || ! mv -f "$temporary_file" "$TAPM_FLEET_IDENTITY_FILE"; then @@ -64,6 +68,7 @@ TAPM_FLEET_ENSURE_IDENTITY() { fi TAPM_FLEET_CREDENTIAL="$(openssl rand -hex 32)" || return 1 TAPM_FLEET_LAST_VERSION='' + TAPM_FLEET_ENROLLED=0 TAPM_FLEET_WRITE_IDENTITY } @@ -155,23 +160,27 @@ TAPM_FLEET_EVENT_SEND() { local duration="${4:-0}" local event_payload='' - (( TAPM_FLEET_REGISTERED == 1 )) || return 0 - event_payload="$(mktemp "${TMPDIR:-/tmp}/tapm-fleet-event.XXXXXX")" || return 0 + (( TAPM_FLEET_REGISTERED == 1 )) || return 1 + event_payload="$(mktemp "${TMPDIR:-/tmp}/tapm-fleet-event.XXXXXX")" || return 1 chmod 0600 "$event_payload" 2>/dev/null || { rm -f "$event_payload" - return 0 + return 1 } if ! TAPM_FLEET_JSON "$event" "$result" "$error_code" "$duration" >"$event_payload"; then rm -f "$event_payload" - return 0 + return 1 fi - printf 'header = "Content-Type: application/json"\nheader = "Authorization: Bearer %s"\nurl = "%s/api/v1/hosts/events"\n' \ + if printf 'header = "Content-Type: application/json"\nheader = "Authorization: Bearer %s"\nurl = "%s/api/v1/hosts/events"\n' \ "$TAPM_FLEET_CREDENTIAL" "$TAPM_BROKER_URL" | curl --fail --silent --show-error \ --connect-timeout 3 --max-time 10 \ --config - --data-binary "@${event_payload}" \ - >/dev/null 2>&1 || true + >/dev/null 2>&1; then + rm -f "$event_payload" + return 0 + fi rm -f "$event_payload" + return 1 } TAPM_FLEET_START() { @@ -181,34 +190,38 @@ TAPM_FLEET_START() { command -v python3 >/dev/null 2>&1 || return 0 command -v curl >/dev/null 2>&1 || return 0 TAPM_FLEET_COLLECT_METADATA - if TAPM_FLEET_REGISTER; then + if (( TAPM_FLEET_ENROLLED == 1 )); then TAPM_FLEET_REGISTERED=1 - else - return 0 + elif TAPM_FLEET_REGISTER; then + TAPM_FLEET_REGISTERED=1 + TAPM_FLEET_ENROLLED=1 + TAPM_FLEET_WRITE_IDENTITY || true fi - if [[ -n "$TAPM_FLEET_LAST_VERSION" && - "$TAPM_FLEET_LAST_VERSION" != "$TAPM_FLEET_VERSION" ]]; then - TAPM_FLEET_EVENT_SEND upgraded success - fi - TAPM_FLEET_EVENT_SEND run_started started } TAPM_FLEET_FINISH() { local exit_status="${1:-0}" local duration=0 + local event_sent=0 if [[ "$TAPM_FLEET_STARTED_AT" =~ ^[0-9]+$ ]] && (( TAPM_FLEET_STARTED_AT > 0 )); then duration="$(( $(date +%s) - TAPM_FLEET_STARTED_AT ))" fi if (( exit_status == 0 )); then - TAPM_FLEET_EVENT_SEND run_completed success '' "$duration" + TAPM_FLEET_EVENT_SEND run_completed success '' "$duration" && + event_sent=1 else - TAPM_FLEET_EVENT_SEND run_failed failure exit_nonzero "$duration" + TAPM_FLEET_EVENT_SEND run_failed failure exit_nonzero "$duration" && + event_sent=1 fi - if (( TAPM_FLEET_REGISTERED == 1 )) && TAPM_FLEET_VALID_IDENTITY; then + if (( event_sent == 1 )) && TAPM_FLEET_VALID_IDENTITY; then TAPM_FLEET_LAST_VERSION="$TAPM_FLEET_VERSION" - TAPM_FLEET_WRITE_IDENTITY || true + else + # A failed event may mean the broker no longer recognizes this local + # credential. Re-enroll on the next invocation, not during this one. + TAPM_FLEET_ENROLLED=0 fi + TAPM_FLEET_VALID_IDENTITY && TAPM_FLEET_WRITE_IDENTITY || true return 0 } diff --git a/inc/virtio-helpers.inc b/inc/virtio-helpers.inc index ce04a1b..30a8606 100644 --- a/inc/virtio-helpers.inc +++ b/inc/virtio-helpers.inc @@ -23,3 +23,17 @@ TAPM_VIRTIO_LABELED_FILENAME() { printf 'virtio-win-%s%s.iso\n' "$label" "${version:+-${version}}" } + +TAPM_VIRTIO_CACHE_VALID() { + local checked_at="${1:-}" + local now="${2:-}" + local max_age="${3:-}" + local filename="${4:-}" + + [[ "$checked_at" =~ ^[0-9]+$ && + "$now" =~ ^[0-9]+$ && + "$max_age" =~ ^[1-9][0-9]*$ && + "$filename" =~ ^virtio-win(-[0-9]+\.[0-9]+\.[0-9]+)?\.iso$ ]] || + return 1 + (( now >= checked_at && now - checked_at < max_age )) +} diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 2aa09d5..45f372e 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -687,6 +687,8 @@ INSTALL_OMSA() { VIRTIO_STABLE_CHECKED=0 VIRTIO_STABLE_STATUS='unknown' VIRTIO_STABLE_FILE='' +VIRTIO_STABLE_CACHE_FILE="${VIRTIO_STABLE_CACHE_FILE:-/var/cache/ta-proxmenu/virtio-stable}" +VIRTIO_STABLE_CACHE_SECONDS=86400 VIRTIO_LAST_FILE='' DLDIR='' VIRTIO_SELECTED_STORAGE='' @@ -784,8 +786,12 @@ TAPM_REFRESH_VIRTIO_LOCAL_STATUS() { TAPM_CHECK_VIRTIO_STABLE() { local force="${1:-0}" + local cached_at='' + local cached_filename='' local effective_url + local now local source_filename + local cache_temp if (( VIRTIO_STABLE_CHECKED == 1 && force == 0 )); then return @@ -794,6 +800,19 @@ TAPM_CHECK_VIRTIO_STABLE() { VIRTIO_STABLE_CHECKED=1 VIRTIO_STABLE_STATUS='unavailable' VIRTIO_STABLE_FILE='' + now="$(date +%s)" + + if (( force == 0 )) && [[ -r "$VIRTIO_STABLE_CACHE_FILE" ]]; then + IFS='|' read -r cached_at cached_filename <"$VIRTIO_STABLE_CACHE_FILE" + if TAPM_VIRTIO_CACHE_VALID \ + "$cached_at" "$now" "$VIRTIO_STABLE_CACHE_SECONDS" "$cached_filename"; then + VIRTIO_STABLE_FILE="$( + TAPM_VIRTIO_LABELED_FILENAME "$cached_filename" latest + )" || return 1 + TAPM_REFRESH_VIRTIO_LOCAL_STATUS + return 0 + fi + fi effective_url="$( curl --fail --location --silent --show-error --head \ @@ -806,6 +825,18 @@ TAPM_CHECK_VIRTIO_STABLE() { return 1 VIRTIO_STABLE_FILE="$(TAPM_VIRTIO_LABELED_FILENAME "$source_filename" latest)" || return 1 + if mkdir -p "$(dirname "$VIRTIO_STABLE_CACHE_FILE")" 2>/dev/null; then + cache_temp="$(mktemp "${VIRTIO_STABLE_CACHE_FILE}.tmp.XXXXXX")" || cache_temp='' + if [[ -n "$cache_temp" ]]; then + if printf '%s|%s\n' "$now" "$source_filename" >"$cache_temp" && + chmod 0644 "$cache_temp"; then + mv -f "$cache_temp" "$VIRTIO_STABLE_CACHE_FILE" || + rm -f "$cache_temp" + else + rm -f "$cache_temp" + fi + fi + fi TAPM_REFRESH_VIRTIO_LOCAL_STATUS } @@ -1000,7 +1031,7 @@ VIRTIO_MENU() { echo -en "\n${idsCL[LightCyan]}Checking the current stable VirtIO release...${idsCL[Default]} " TAPM_REFRESH_ISO_STORAGES || true - TAPM_CHECK_VIRTIO_STABLE 1 || true + TAPM_CHECK_VIRTIO_STABLE 0 || true echo while true; do @@ -1492,7 +1523,7 @@ INSTALL_KEEPALIVE() { UPDATE_CACHE_DIR='/var/cache/ta-proxmenu' UPDATE_CACHE_FILE="${UPDATE_CACHE_DIR}/update-status" -UPDATE_CACHE_SECONDS=14400 +UPDATE_CACHE_SECONDS=86400 UPDATE_CHECK_PID='' UPDATE_STATUS='unknown' UPDATE_REMOTE_COMMIT='' diff --git a/tests/test-fleet.sh b/tests/test-fleet.sh index c700419..2fd65a7 100644 --- a/tests/test-fleet.sh +++ b/tests/test-fleet.sh @@ -48,6 +48,7 @@ assert payload["clustered"] is True ' printf '0\n' >"${test_dir}/curl-count" +curl_should_fail=0 curl() { local count data_path='' previous='' argument='' count="$(cat "${test_dir}/curl-count")" @@ -59,11 +60,11 @@ curl() { fi previous="$argument" done - if [[ -n "$data_path" ]]; then + if [[ -n "$data_path" && "$data_path" != '-' ]]; then cp "$data_path" "${test_dir}/curl-body-${count}.json" fi cat >"${test_dir}/curl-config-${count}" - return 0 + (( curl_should_fail == 0 )) } TAPM_FLEET_REGISTERED=1 @@ -89,4 +90,59 @@ if compgen -G "${TMPDIR:-/tmp}/tapm-fleet-event.*" >/dev/null; then exit 1 fi +printf '0\n' >"${test_dir}/curl-count" +TAPM_FLEET_ENROLLED=0 +TAPM_FLEET_REGISTERED=0 +TAPM_FLEET_LAST_VERSION='' +TAPM_FLEET_COLLECT_METADATA() { + TAPM_FLEET_HOSTNAME='pve01' + TAPM_FLEET_GIT_COMMIT='0123456789abcdef0123456789abcdef01234567' + TAPM_FLEET_PVE_VERSION='pve-manager/9.2.4' + TAPM_FLEET_OS_VERSION='Debian GNU/Linux 13 (trixie)' + TAPM_FLEET_KERNEL_VERSION='6.14.11-5-pve' + TAPM_FLEET_ARCHITECTURE='amd64' + TAPM_FLEET_CLUSTERED=true +} + +TAPM_FLEET_START '2026.7.28-9' +TAPM_FLEET_FINISH 0 +if [[ "$(cat "${test_dir}/curl-count")" != 2 ]]; then + printf 'FAIL: initial run did not make exactly registration + completion calls\n' >&2 + exit 1 +fi +TAPM_FLEET_LOAD_IDENTITY +if [[ "$TAPM_FLEET_ENROLLED" != 1 || + "$TAPM_FLEET_LAST_VERSION" != '2026.7.28-9' ]]; then + printf 'FAIL: successful fleet enrollment was not persisted\n' >&2 + exit 1 +fi + +TAPM_FLEET_REGISTERED=0 +TAPM_FLEET_START '2026.7.28-9' +TAPM_FLEET_FINISH 0 +if [[ "$(cat "${test_dir}/curl-count")" != 3 ]]; then + printf 'FAIL: normal run made more than one broker call\n' >&2 + exit 1 +fi + +curl_should_fail=1 +TAPM_FLEET_REGISTERED=0 +TAPM_FLEET_START '2026.7.28-9' +TAPM_FLEET_FINISH 0 +TAPM_FLEET_LOAD_IDENTITY +if [[ "$TAPM_FLEET_ENROLLED" != 0 ]]; then + printf 'FAIL: failed fleet event did not schedule next-run re-enrollment\n' >&2 + exit 1 +fi + +curl_should_fail=0 +TAPM_FLEET_REGISTERED=0 +TAPM_FLEET_START '2026.7.28-9' +TAPM_FLEET_FINISH 0 +TAPM_FLEET_LOAD_IDENTITY +if [[ "$TAPM_FLEET_ENROLLED" != 1 ]]; then + printf 'FAIL: fleet client did not recover enrollment after a failed event\n' >&2 + exit 1 +fi + printf 'PASS: fleet identity and event client\n' diff --git a/tests/test-git-update.sh b/tests/test-git-update.sh index 19437c3..3439e9d 100755 --- a/tests/test-git-update.sh +++ b/tests/test-git-update.sh @@ -5,6 +5,9 @@ TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "${TEST_ROOT}/tests/testlib.sh" source "${TEST_ROOT}/inc/git-update.inc" +assert_success "automatic Git check cache is 24 hours" \ + grep -q '^UPDATE_CACHE_SECONDS=86400$' "${TEST_ROOT}/proxmenu-scripts.sh" + TEST_TEMP_DIR="$(mktemp -d /tmp/tapm-git-tests.XXXXXX)" TEST_REPOSITORY="${TEST_TEMP_DIR}/repository" diff --git a/tests/test-iso-nfs.sh b/tests/test-iso-nfs.sh index 5c9b099..924761c 100755 --- a/tests/test-iso-nfs.sh +++ b/tests/test-iso-nfs.sh @@ -60,4 +60,16 @@ test_default_storage_selection() { assert_success "default storage menu selection" test_default_storage_selection +pveam() { + [[ "${1:-}" == list && "${2:-}" == local ]] || return 1 + printf '%s\n' \ + 'NAME VOLID FORMAT TYPE SIZE VMID' \ + 'local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst 0 0 0 0 0' \ + 'local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst 0 0 0 0 0' +} +assert_equal \ + 'local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst' \ + "$(TAPM_ISO_NFS_LOCAL_TEMPLATE local)" \ + "newest local Debian template avoids catalog refresh" + finish_tests diff --git a/tests/test-pulse.sh b/tests/test-pulse.sh index 9be6d75..df18b83 100644 --- a/tests/test-pulse.sh +++ b/tests/test-pulse.sh @@ -15,6 +15,10 @@ assert_equal amd64 "$(TAPM_PULSE_ARCH x86_64)" "x86 architecture mapping" assert_equal arm64 "$(TAPM_PULSE_ARCH aarch64)" "ARM architecture mapping" assert_failure "unsupported Pulse architecture" TAPM_PULSE_ARCH riscv64 +assert_equal 2 "$(TAPM_PULSE_BACKOFF_NEXT 1 8)" "Pulse backoff doubles" +assert_equal 8 "$(TAPM_PULSE_BACKOFF_NEXT 4 8)" "Pulse backoff reaches cap" +assert_equal 8 "$(TAPM_PULSE_BACKOFF_NEXT 8 8)" "Pulse backoff remains capped" + test_bridge_selection_assignment() { local bridge='' diff --git a/tests/test-virtio.sh b/tests/test-virtio.sh index b753d89..d991261 100755 --- a/tests/test-virtio.sh +++ b/tests/test-virtio.sh @@ -31,4 +31,13 @@ assert_equal virtio-win-server-2008r2-0.1.172.iso \ assert_failure "unsafe local label rejected" \ TAPM_VIRTIO_LABELED_FILENAME virtio-win-0.1.285.iso '../latest' +assert_success "fresh VirtIO cache accepted" \ + TAPM_VIRTIO_CACHE_VALID 1000 1500 86400 virtio-win-0.1.285.iso +assert_failure "expired VirtIO cache rejected" \ + TAPM_VIRTIO_CACHE_VALID 1000 87400 86400 virtio-win-0.1.285.iso +assert_failure "future VirtIO cache rejected" \ + TAPM_VIRTIO_CACHE_VALID 2000 1000 86400 virtio-win-0.1.285.iso +assert_failure "unsafe VirtIO cache filename rejected" \ + TAPM_VIRTIO_CACHE_VALID 1000 1500 86400 ../../installer + finish_tests From dd7a66e1e4df141efa7135b6982ad822ace85d6e Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Tue, 28 Jul 2026 22:26:29 -0500 Subject: [PATCH 73/76] update --- defaults.inc | 2 +- inc/header-info.inc | 37 ++++++++++++++++++++++++++++++++ proxmenu-scripts.sh | 4 ++++ tests/test-header-info.sh | 44 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 inc/header-info.inc create mode 100644 tests/test-header-info.sh diff --git a/defaults.inc b/defaults.inc index c32651b..4390656 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.28-9' +VERS='2026.7.28-10' noupdate=' ' diff --git a/inc/header-info.inc b/inc/header-info.inc new file mode 100644 index 0000000..0138629 --- /dev/null +++ b/inc/header-info.inc @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Cached local host details displayed by the interactive menu header. + +TAPM_HEADER_INFO_LOADED=0 +TAPM_HEADER_PVE_VERSION='Unavailable' +TAPM_HEADER_CLUSTER='Standalone' + +TAPM_LOAD_HEADER_INFO() { + local pve_output='' + local first_line='' + local cluster_config="${1:-/etc/pve/corosync.conf}" + local cluster_name='' + local line='' + + (( TAPM_HEADER_INFO_LOADED == 0 )) || return 0 + TAPM_HEADER_INFO_LOADED=1 + + if command -v pveversion >/dev/null 2>&1; then + pve_output="$(pveversion 2>/dev/null || true)" + first_line="${pve_output%%$'\n'*}" + if [[ "$first_line" =~ ^pve-manager/([^/[:space:]]+) ]]; then + TAPM_HEADER_PVE_VERSION="${BASH_REMATCH[1]}" + elif [[ -n "$first_line" ]]; then + TAPM_HEADER_PVE_VERSION="${first_line%%[[:space:]]*}" + fi + fi + + if [[ -r "$cluster_config" ]]; then + while IFS= read -r line; do + if [[ "$line" =~ ^[[:space:]]*cluster_name:[[:space:]]*([^[:space:]#]+) ]]; then + cluster_name="${BASH_REMATCH[1]}" + break + fi + done <"$cluster_config" + TAPM_HEADER_CLUSTER="${cluster_name:-Clustered}" + fi +} diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 45f372e..9124b28 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -6,6 +6,7 @@ source /opt/idssys/ta-proxmenu/defaults.inc source /opt/idssys/ta-proxmenu/inc/git-update.inc source /opt/idssys/ta-proxmenu/inc/ha-status.inc +source /opt/idssys/ta-proxmenu/inc/header-info.inc source /opt/idssys/ta-proxmenu/inc/post-install.inc source /opt/idssys/ta-proxmenu/inc/rmm.inc source /opt/idssys/ta-proxmenu/inc/secure-input.inc @@ -1687,6 +1688,8 @@ MENU_HEADER() { local right_width local spacer_width + TAPM_LOAD_HEADER_INFO + # Reuse a settled update result instead of running Git checks on every # arrow-key redraw. Continue refreshing only while the worker is pending. if [[ "$UPDATE_STATUS" == 'unknown' || "$UPDATE_STATUS" == 'checking' ]]; then @@ -1738,6 +1741,7 @@ MENU_HEADER() { echo -e "${idsCL[Green]}---------------------------------------------------------------------------${idsCL[Default]}" echo -e " Hostname: ${idsCL[Cyan]}$(hostname -s)${idsCL[Default]}" echo -e " IP Address: ${idsCL[Cyan]}${RNIP:-Unavailable}${idsCL[Default]}" + echo -e " Proxmox VE: ${idsCL[Cyan]}${TAPM_HEADER_PVE_VERSION}${idsCL[Default]} Cluster: ${idsCL[Cyan]}${TAPM_HEADER_CLUSTER}${idsCL[Default]}" echo -e "${idsCL[Green]}---------------------------------------------------------------------------${idsCL[Default]}" } diff --git a/tests/test-header-info.sh b/tests/test-header-info.sh new file mode 100644 index 0000000..585b853 --- /dev/null +++ b/tests/test-header-info.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -u -o pipefail + +TEST_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +test_dir="$(mktemp -d)" +trap 'rm -rf "$test_dir"' EXIT + +source "${TEST_ROOT}/tests/testlib.sh" +source "${TEST_ROOT}/inc/header-info.inc" + +pveversion() { + printf '%s\n' 'pve-manager/9.2.4/5e5ae681198514d4 (running kernel: 7.0.14-5-pve)' +} + +cat >"${test_dir}/corosync.conf" <<'EOF' +totem { + version: 2 + cluster_name: production-cluster +} +EOF + +TAPM_LOAD_HEADER_INFO "${test_dir}/corosync.conf" +assert_equal '9.2.4' "$TAPM_HEADER_PVE_VERSION" \ + "header extracts the concise Proxmox VE version" +assert_equal 'production-cluster' "$TAPM_HEADER_CLUSTER" \ + "header reads the local cluster name" + +pveversion() { + printf '%s\n' 'pve-manager/10.0.0/changed' +} +TAPM_LOAD_HEADER_INFO "${test_dir}/corosync.conf" +assert_equal '9.2.4' "$TAPM_HEADER_PVE_VERSION" \ + "header information is cached between menu redraws" + +TAPM_HEADER_INFO_LOADED=0 +TAPM_HEADER_PVE_VERSION='Unavailable' +TAPM_HEADER_CLUSTER='Standalone' +TAPM_LOAD_HEADER_INFO "${test_dir}/missing.conf" +assert_equal '10.0.0' "$TAPM_HEADER_PVE_VERSION" \ + "header refresh reads the current Proxmox VE version" +assert_equal 'Standalone' "$TAPM_HEADER_CLUSTER" \ + "host without corosync configuration is shown as standalone" + +finish_tests From 8bd60e2f5240f15907077985c7722056c00f7701 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Wed, 29 Jul 2026 17:17:56 -0500 Subject: [PATCH 74/76] update --- README.md | 7 +-- defaults.inc | 2 +- inc/cpu-compat.inc | 96 ++++++++++++++++++++++++++++++++++++++++ proxmenu-scripts.sh | 79 +++++++++++++++++++++++++++++++-- tests/test-cpu-compat.sh | 47 ++++++++++++++++++++ 5 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 inc/cpu-compat.inc create mode 100644 tests/test-cpu-compat.sh diff --git a/README.md b/README.md index ba816d4..8fd7429 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,10 @@ ISO repository. The legacy Dell OMSA installer is limited to supported PowerEdge x30/x40 systems running Proxmox VE 9 on Debian 13 (Trixie), amd64. -CPU compatibility detection previews cluster-wide QEMU VM and template -changes before applying the ProxCLMC recommendation through the Proxmox CLI. -Running VMs are not restarted automatically. +CPU compatibility analysis compares every cluster host's physical processor, +processor generation/family, and supported generic VM CPU baseline. It then previews +QEMU VM and template changes before applying the highest baseline shared by +all nodes through the Proxmox CLI. Running VMs are not restarted automatically. The native TAPM host configuration workflow replaces the former ProxMenux post-install dependency. It can audit a host, apply the recommended PVE 9 diff --git a/defaults.inc b/defaults.inc index 4390656..a4158af 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.28-10' +VERS='2026.7.28-11' noupdate=' ' diff --git a/inc/cpu-compat.inc b/inc/cpu-compat.inc new file mode 100644 index 0000000..2f60edb --- /dev/null +++ b/inc/cpu-compat.inc @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +# Parse the per-node rows printed by ProxCLMC. Fields are separated with an +# ASCII unit separator so processor descriptions can safely contain spaces. +TAPM_PROXCLMC_HOST_ROWS() { + awk -F '|' ' +function trim(value) { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + return value +} + +{ + node = trim($1) + address = trim($2) + level = trim($3) + + if (node ~ /^[[:alnum:]_.-]+$/ && level ~ /^x86-64-v(1|2-AES|3|4)$/) { + printf "%s\034%s\034%s\n", node, address, level + } +} +' +} + +# Produce a conservative generation label from the processor name exposed by +# Proxmox. Ambiguous products are described as a family instead of being given +# a potentially incorrect codename. +TAPM_CPU_GENERATION_FROM_NAME() { + local cpu_name="${1:-}" + local normalized + local generation="" + local model_number="" + + normalized="$(printf '%s' "$cpu_name" | tr '[:upper:]' '[:lower:]')" + + if [[ "$normalized" =~ xeon.*e5-[[:digit:]]+[[:space:]]+v([1-4]) ]]; then + generation="${BASH_REMATCH[1]}" + case "$generation" in + 1) printf '%s\n' "Sandy Bridge-EP" ;; + 2) printf '%s\n' "Ivy Bridge-EP" ;; + 3) printf '%s\n' "Haswell-EP" ;; + 4) printf '%s\n' "Broadwell-EP" ;; + esac + return 0 + fi + + if [[ "$normalized" =~ xeon.*(bronze|silver|gold|platinum)[[:space:]]+([[:digit:]]{4}) ]]; then + model_number="${BASH_REMATCH[2]}" + generation="${model_number:1:1}" + case "$generation" in + 1) printf '%s\n' "1st Gen Xeon Scalable (Skylake-SP)" ;; + 2) printf '%s\n' "2nd Gen Xeon Scalable (Cascade Lake)" ;; + 3) printf '%s\n' "3rd Gen Xeon Scalable (Ice Lake)" ;; + 4) printf '%s\n' "4th Gen Xeon Scalable (Sapphire Rapids)" ;; + 5) printf '%s\n' "5th Gen Xeon Scalable (Emerald Rapids)" ;; + *) printf '%s\n' "Intel Xeon Scalable family" ;; + esac + return 0 + fi + + if [[ "$normalized" =~ epyc[[:space:]]+([[:digit:]]{4}) ]]; then + model_number="${BASH_REMATCH[1]}" + generation="${model_number:3:1}" + case "$generation" in + 1) printf '%s\n' "1st Gen EPYC (Naples / Zen)" ;; + 2) printf '%s\n' "2nd Gen EPYC (Rome / Zen 2)" ;; + 3) printf '%s\n' "3rd Gen EPYC (Milan / Zen 3)" ;; + 4) printf '%s\n' "4th Gen EPYC (Zen 4 family)" ;; + 5) printf '%s\n' "5th Gen EPYC (Zen 5 family)" ;; + *) printf '%s\n' "AMD EPYC family" ;; + esac + return 0 + fi + + if [[ "$normalized" =~ core.*i[3579]-([[:digit:]]{4,5}) ]]; then + model_number="${BASH_REMATCH[1]}" + if (( ${#model_number} == 4 )); then + generation="${model_number:0:1}" + else + generation="${model_number:0:2}" + fi + printf '%s\n' "Intel Core Gen ${generation}" + return 0 + fi + + if [[ "$normalized" =~ ryzen.*[[:space:]]([[:digit:]]{4,5}) ]]; then + model_number="${BASH_REMATCH[1]}" + printf '%s\n' "AMD Ryzen ${model_number:0:1}000 family" + return 0 + fi + + case "$normalized" in + *intel*) printf '%s\n' "Intel generation unknown" ;; + *amd*) printf '%s\n' "AMD generation unknown" ;; + *) printf '%s\n' "Generation unknown" ;; + esac +} diff --git a/proxmenu-scripts.sh b/proxmenu-scripts.sh index 9124b28..8771d1d 100755 --- a/proxmenu-scripts.sh +++ b/proxmenu-scripts.sh @@ -10,6 +10,7 @@ source /opt/idssys/ta-proxmenu/inc/header-info.inc source /opt/idssys/ta-proxmenu/inc/post-install.inc source /opt/idssys/ta-proxmenu/inc/rmm.inc source /opt/idssys/ta-proxmenu/inc/secure-input.inc +source /opt/idssys/ta-proxmenu/inc/cpu-compat.inc source /opt/idssys/ta-proxmenu/inc/deploy-iso-nfs-lxc.sh source /opt/idssys/ta-proxmenu/inc/deploy-pulse-lxc.sh source /opt/idssys/ta-proxmenu/inc/virtio-helpers.inc @@ -1225,6 +1226,73 @@ TAPM_SET_QEMU_CPU_MODEL() { --cpu "$cpu_model" } +TAPM_NODE_CPU_NAME() { + local node="${1:?node is required}" + local status_json + + status_json="$( + pvesh get "/nodes/${node}/status" --output-format json 2>/dev/null + )" || return 1 + + TAPM_NODE_STATUS_JSON="$status_json" python3 -c ' +import json +import os + +try: + status = json.loads(os.environ["TAPM_NODE_STATUS_JSON"]) +except (KeyError, TypeError, ValueError): + raise SystemExit(1) + +cpu_info = status.get("cpuinfo") or {} +model = str(cpu_info.get("model") or "").strip() +if not model: + raise SystemExit(1) + +print(" ".join(model.split())) +' 2>/dev/null +} + +TAPM_SHOW_CLUSTER_CPU_TABLE() { + local proxclmc_output + local host_rows + local node + local address + local host_max + local cpu_name + local generation + + proxclmc_output="$(proxclmc 2>/dev/null)" || proxclmc_output="" + host_rows="$(printf '%s\n' "$proxclmc_output" | TAPM_PROXCLMC_HOST_ROWS)" + + printf '\nCluster host CPU capabilities:\n\n' + + if [[ -z "$host_rows" ]]; then + echo -e " ${idsCL[Yellow]}Per-host CPU details were unavailable from ProxCLMC.${idsCL[Default]}" + return 0 + fi + + printf ' %-16s %-42s %-34s %-14s\n' \ + 'HOST' 'PHYSICAL CPU' 'GENERATION / FAMILY' 'BEST VM MODEL' + printf ' %-16s %-42s %-34s %-14s\n' \ + '----------------' '------------------------------------------' \ + '----------------------------------' '--------------' + + while IFS=$'\034' read -r node address host_max; do + [[ -n "$node" ]] || continue + + cpu_name="$(TAPM_NODE_CPU_NAME "$node" 2>/dev/null || true)" + if [[ -n "$cpu_name" ]]; then + generation="$(TAPM_CPU_GENERATION_FROM_NAME "$cpu_name")" + else + cpu_name='Unavailable' + generation='Unavailable' + fi + + printf ' %-16.16s %-42.42s %-34.34s %-14s\n' \ + "$node" "$cpu_name" "$generation" "$host_max" + done <<< "$host_rows" +} + DETECT_CPU() { local answer local cpu_model @@ -1266,6 +1334,12 @@ DETECT_CPU() { return fi + TAPM_SHOW_CLUSTER_CPU_TABLE + printf '\nRecommended cluster-wide VM CPU baseline: %s\n' "$cpu_model" + printf '%s\n' \ + 'This is the highest generic model shared by every node for HA placement,' \ + 'load balancing, and moving workloads between cluster hosts.' + guest_output="$(TAPM_CLUSTER_QEMU_GUESTS)" || { echo -e "${idsCL[LightRed]}Could not read cluster QEMU resources.${idsCL[Default]}" FINISH_FAILED_ACTION @@ -1296,7 +1370,6 @@ DETECT_CPU() { return fi - printf '\nRecommended cluster CPU model: %s\n' "$cpu_model" printf '\n%-7s %-28s %-20s %-10s %-24s %s\n' \ 'VMID' 'NAME' 'NODE' 'TEMPLATE' 'CURRENT CPU' 'PROPOSED CPU' printf '%-7s %-28s %-20s %-10s %-24s %s\n' \ @@ -1308,7 +1381,7 @@ DETECT_CPU() { "$vmid" "${name:-unnamed}" "$node" "$template" "$current_cpu" "$cpu_model" done - echo -en "\n${idsCL[LightCyan]}Apply this CPU model to ${#changes[@]} VM(s) and template(s) (y/N)?${idsCL[Default]} " + echo -en "\n${idsCL[LightCyan]}Apply cluster-wide baseline ${cpu_model} to ${#changes[@]} VM(s) and template(s) (y/N)?${idsCL[Default]} " read -r -n 1 answer echo [[ "$answer" =~ ^[Yy]$ ]] || return @@ -2269,7 +2342,7 @@ HOST_SETUP_MENU() { values=("post_install") TAPM_POST_PROXMENUX_DETECTED && labels[0]="TAPM Host Configuration — ProxMenux migration recommended" - labels+=("Detect CPU model for live migrations") + labels+=("Analyze VM CPU compatibility for HA and workload mobility") values+=("cpu") # Discover ISO storage only after the VirtIO submenu is selected. diff --git a/tests/test-cpu-compat.sh b/tests/test-cpu-compat.sh new file mode 100644 index 0000000..0712bc4 --- /dev/null +++ b/tests/test-cpu-compat.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${TEST_DIR}/.." && pwd)" + +source "${REPO_DIR}/inc/cpu-compat.inc" + +assert_equal() { + local expected="${1:?expected value is required}" + local actual="${2:?actual value is required}" + local description="${3:?description is required}" + + if [[ "$actual" != "$expected" ]]; then + printf 'FAIL: %s\nExpected: %s\nActual: %s\n' \ + "$description" "$expected" "$actual" >&2 + exit 1 + fi +} + +assert_equal \ + "Broadwell-EP" \ + "$(TAPM_CPU_GENERATION_FROM_NAME "Intel(R) Xeon(R) CPU E5-2690 v4 @ 2.60GHz")" \ + "identifies Xeon E5 v4" + +assert_equal \ + "3rd Gen Xeon Scalable (Ice Lake)" \ + "$(TAPM_CPU_GENERATION_FROM_NAME "Intel(R) Xeon(R) Gold 6338 CPU @ 2.00GHz")" \ + "identifies third-generation Xeon Scalable" + +assert_equal \ + "3rd Gen EPYC (Milan / Zen 3)" \ + "$(TAPM_CPU_GENERATION_FROM_NAME "AMD EPYC 7543 32-Core Processor")" \ + "identifies EPYC Milan" + +proxclmc_fixture='node-a | 10.0.0.1 | x86-64-v4 +node-b | 10.0.0.2 | x86-64-v3 + +Cluster CPU type: x86-64-v3' + +expected_rows=$'node-a\03410.0.0.1\034x86-64-v4\nnode-b\03410.0.0.2\034x86-64-v3' +actual_rows="$(printf '%s\n' "$proxclmc_fixture" | TAPM_PROXCLMC_HOST_ROWS)" + +assert_equal "$expected_rows" "$actual_rows" "parses ProxCLMC host rows" + +printf 'CPU compatibility tests passed.\n' From c0bbfb3447026c32f8aa0227a83e6a0325ba9b95 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Wed, 29 Jul 2026 17:22:18 -0500 Subject: [PATCH 75/76] update --- defaults.inc | 2 +- inc/cluster-update.inc | 12 +++++++++++- run.sh | 2 +- tests/test-cluster-update.sh | 14 ++++++++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/defaults.inc b/defaults.inc index a4158af..fe593dd 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.28-11' +VERS='2026.7.29-1' noupdate=' ' diff --git a/inc/cluster-update.inc b/inc/cluster-update.inc index f4133d0..b94a5c1 100644 --- a/inc/cluster-update.inc +++ b/inc/cluster-update.inc @@ -104,10 +104,12 @@ INSTALL_CLUSTER_UPDATES() { continue fi if [[ "$status" != "online" ]]; then + printf '\n' echo -e "${idsCL[LightYellow]}Skipping ${node}: node status is ${status}.${idsCL[Default]}" cluster_failed=1 continue fi + printf '\n' echo -e "${idsCL[LightCyan]}Updating remote node ${node}...${idsCL[Default]}" if TAPM_UPDATE_REMOTE_NODE "$node"; then echo -e "${idsCL[Green]}Remote node ${node} is updated.${idsCL[Default]}" @@ -117,8 +119,16 @@ INSTALL_CLUSTER_UPDATES() { fi done + printf '\n' echo -e "${idsCL[LightCyan]}Updating local node ${local_node}...${idsCL[Default]}" - INSTALL_LOCAL_UPDATES || cluster_failed=1 + if INSTALL_LOCAL_UPDATES; then + echo -e "${idsCL[Green]}Local node ${local_node} is updated.${idsCL[Default]}" + else + echo -e "${idsCL[Red]}Local node ${local_node} failed to update.${idsCL[Default]}" + cluster_failed=1 + fi + + printf '\n' if (( cluster_failed == 0 )); then echo -e "${idsCL[Green]}TA-ProxMenu is updated on all ${#cluster_nodes[@]} cluster node(s).${idsCL[Default]}" return 0 diff --git a/run.sh b/run.sh index c8e3ffa..f453d14 100755 --- a/run.sh +++ b/run.sh @@ -165,7 +165,7 @@ INSTALL_LOCAL_UPDATES() { if (( update_failed == 0 )); then source /opt/idssys/ta-proxmenu/defaults.inc - echo -e "\n${idsCL[Green]}Update check complete. Installed version: ${VERS}${idsCL[Default]}" + echo -e "${idsCL[Green]}Update check complete. Installed version: ${VERS}${idsCL[Default]}" return 0 fi diff --git a/tests/test-cluster-update.sh b/tests/test-cluster-update.sh index b742f66..9d0f288 100644 --- a/tests/test-cluster-update.sh +++ b/tests/test-cluster-update.sh @@ -62,4 +62,18 @@ assert_equal $'remote:pve2\nlocal:pve1' \ "$(cat "$update_log")" \ "online remotes and initiating node update once" +TAPM_CLUSTER_NODE_ROWS() { + printf 'pve1\tonline\npve2\tonline\n' +} +TAPM_UPDATE_REMOTE_NODE() { + printf 'remote update output\n' +} +INSTALL_LOCAL_UPDATES() { + printf 'local update output\n' +} +expected_output=$'Updating TA-ProxMenu across 2 cluster node(s)...\n\nUpdating remote node pve2...\nremote update output\nRemote node pve2 is updated.\n\nUpdating local node pve1...\nlocal update output\nLocal node pve1 is updated.\n\nTA-ProxMenu is updated on all 2 cluster node(s).' +assert_equal "$expected_output" \ + "$(INSTALL_CLUSTER_UPDATES)" \ + "each node update is grouped with blank lines only between node blocks" + finish_tests From ecb981c8a7b63bd1a05bbd6e668ccd8fec3ed936 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Wed, 12 Aug 2026 20:51:32 -0500 Subject: [PATCH 76/76] update --- README.md | 4 +- install-pulse.sh | 2 +- install-ta_proxmenu.sh | 122 +++++++++++++++++++++++++++++++++ tests/test-pulse-standalone.sh | 4 +- 4 files changed, 127 insertions(+), 5 deletions(-) create mode 100755 install-ta_proxmenu.sh diff --git a/README.md b/README.md index 8fd7429..46358d7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ VE environments. Run as `root` on a Proxmox VE host: ```bash -bash <(curl -fsSL https://go.scity.us/install-tapm) +bash <(curl -fsSL https://tagit.technologyarch.com/TAI/TA-ProxMenu/raw/branch/V2/install-ta_proxmenu.sh) ``` The installed launcher loads TA-ProxMenu's bundled defaults and colors, then @@ -104,7 +104,7 @@ Pulse can also be deployed without installing TA-ProxMenu. Run the standalone bootstrap as root on a Proxmox VE host: ```bash -bash <(curl -fsSL https://tagit.technologyarch.com/taiadmin/TA-ProxMenu/raw/branch/V2/install-pulse.sh) +bash <(curl -fsSL https://tagit.technologyarch.com/TAI/TA-ProxMenu/raw/branch/V2/install-pulse.sh) ``` The bootstrap downloads the Pulse deployment module and its LXC storage helper diff --git a/install-pulse.sh b/install-pulse.sh index bbd2849..27c7820 100755 --- a/install-pulse.sh +++ b/install-pulse.sh @@ -4,7 +4,7 @@ set -u -o pipefail TAPM_PULSE_SOURCE_BRANCH="${TAPM_PULSE_SOURCE_BRANCH:-V2}" -TAPM_PULSE_SOURCE_BASE="${TAPM_PULSE_SOURCE_BASE:-https://tagit.technologyarch.com/taiadmin/TA-ProxMenu/raw/branch/${TAPM_PULSE_SOURCE_BRANCH}}" +TAPM_PULSE_SOURCE_BASE="${TAPM_PULSE_SOURCE_BASE:-https://tagit.technologyarch.com/TAI/TA-ProxMenu/raw/branch/${TAPM_PULSE_SOURCE_BRANCH}}" TAPM_PULSE_BOOTSTRAP_DIR='' TAPM_TEMP_DIR='' declare -a TAPM_TEMP_DIRS=() diff --git a/install-ta_proxmenu.sh b/install-ta_proxmenu.sh new file mode 100755 index 0000000..dfc5d81 --- /dev/null +++ b/install-ta_proxmenu.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Install TA-ProxMenu on a Proxmox VE host. + +set -Eeuo pipefail + +readonly INSTALL_ROOT='/opt/idssys' +readonly TAPM_DIR="${INSTALL_ROOT}/ta-proxmenu" +readonly DEFAULTS_DIR="${INSTALL_ROOT}/defaults" +readonly TAPM_REPOSITORY='https://tagit.technologyarch.com/TAI/TA-ProxMenu.git' +readonly DEFAULTS_REPOSITORY='https://git.scity.us/voltron/iDS-Defaults.git' +readonly TAPM_LAUNCHER='/usr/local/bin/tapm' +readonly REQUESTED_BRANCH="${TAPM_BRANCH:-}" + +declare -a TEMP_PATHS=() + +cleanup() { + local path + + for path in "${TEMP_PATHS[@]}"; do + [[ -e "$path" ]] && rm -rf -- "$path" + done +} + +fail() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +clone_repository() { + local repository="$1" + local destination="$2" + local label="$3" + local branch="${4:-}" + local temporary + + if [[ -d "${destination}/.git" ]]; then + printf '%s is already installed at %s; leaving it unchanged.\n' \ + "$label" "$destination" + return + fi + + [[ ! -e "$destination" ]] || + fail "${destination} already exists but is not a Git repository." + + temporary="${destination}.install.$$" + TEMP_PATHS+=("$temporary") + + printf 'Cloning %s...\n' "$label" + if [[ -n "$branch" ]]; then + git clone --branch "$branch" --single-branch \ + "$repository" "$temporary" + else + git clone "$repository" "$temporary" + fi + + mv "$temporary" "$destination" +} + +install_launcher() { + local current_target='' + + if [[ -L "$TAPM_LAUNCHER" ]]; then + current_target="$(readlink "$TAPM_LAUNCHER")" + if [[ "$current_target" == "${TAPM_DIR}/run.sh" ]]; then + return + fi + + fail "${TAPM_LAUNCHER} points to ${current_target}; it was not replaced." + fi + + [[ ! -e "$TAPM_LAUNCHER" ]] || + fail "${TAPM_LAUNCHER} already exists and was not replaced." + + ln -s "${TAPM_DIR}/run.sh" "$TAPM_LAUNCHER" +} + +trap cleanup EXIT + +(( EUID == 0 )) || fail 'Run this installer as root.' +command -v pveversion >/dev/null 2>&1 || + fail 'This installer must be run on a Proxmox VE host.' + +if [[ -n "$REQUESTED_BRANCH" ]] && + [[ ! "$REQUESTED_BRANCH" =~ ^[A-Za-z0-9._/-]+$ ]]; then + fail "Invalid TAPM_BRANCH value: ${REQUESTED_BRANCH}" +fi + +printf '\nTA-ProxMenu Installation Script\n\n' + +apt-get update +DEBIAN_FRONTEND=noninteractive apt-get install -y \ + ca-certificates curl git jq python3 wget + +mkdir -p "$INSTALL_ROOT" + +clone_repository \ + "$DEFAULTS_REPOSITORY" "$DEFAULTS_DIR" 'iDSSYS Defaults' +clone_repository \ + "$TAPM_REPOSITORY" "$TAPM_DIR" 'TA-ProxMenu' "$REQUESTED_BRANCH" + +[[ -x "${TAPM_DIR}/run.sh" ]] || + fail "${TAPM_DIR}/run.sh is missing or is not executable." +[[ -r "${DEFAULTS_DIR}/colors.inc" ]] || + fail "${DEFAULTS_DIR}/colors.inc is missing." +[[ -r "${DEFAULTS_DIR}/default.inc" ]] || + fail "${DEFAULTS_DIR}/default.inc is missing." + +install_launcher + +# Load the standard colors only after the trusted defaults repository exists. +# shellcheck disable=SC1091 +source "${DEFAULTS_DIR}/colors.inc" + +printf '\n%bTA-ProxMenu has been installed.%b\n\n' \ + "${idsCL[Yellow]}" "${idsCL[Default]}" +printf 'Run it with: %btapm%b\n\n' \ + "${idsCL[Green]}" "${idsCL[Default]}" + +if [[ -n "$REQUESTED_BRANCH" ]]; then + printf 'Installed branch: %b%s%b\n\n' \ + "${idsCL[Green]}" "$REQUESTED_BRANCH" "${idsCL[Default]}" +fi diff --git a/tests/test-pulse-standalone.sh b/tests/test-pulse-standalone.sh index 3fac251..d699255 100755 --- a/tests/test-pulse-standalone.sh +++ b/tests/test-pulse-standalone.sh @@ -16,9 +16,9 @@ assert_failure "source branch traversal rejected" \ TAPM_PULSE_VALID_SOURCE_BRANCH '../main' assert_success "HTTPS source base accepted" \ TAPM_PULSE_VALID_SOURCE_BASE \ - https://tagit.technologyarch.com/taiadmin/TA-ProxMenu/raw/branch/V2 + https://tagit.technologyarch.com/TAI/TA-ProxMenu/raw/branch/V2 assert_failure "HTTP source base rejected" \ TAPM_PULSE_VALID_SOURCE_BASE \ - http://tagit.technologyarch.com/taiadmin/TA-ProxMenu/raw/branch/V2 + http://tagit.technologyarch.com/TAI/TA-ProxMenu/raw/branch/V2 finish_tests