#!/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 }