From 76af8116da99e1b5f998b4157dda9b4b1bd6979b Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 19:46:11 -0500 Subject: [PATCH] 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