#!/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_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 )) } 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_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';; aarch64|arm64) printf 'arm64\n';; *) return 1;; 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_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":true}' } 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:-[]}" 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() in {"pulse", "pulse-monitor"} ): 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_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_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 } TAPM_PULSE_CONFIRM_CREDENTIALS_SAVED() { local pulse_url="$1" 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 echo echo -e "${idsCL[LightYellow]}============================================================================${idsCL[Default]}" echo -e "${idsCL[LightYellow]} IMPORTANT — SAVE THESE PULSE CREDENTIALS NOW${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]}" 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 [[ "$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:-}" 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_name default_bridge="${2:-vmbr0}" local default_found=0 local -a bridges=() local -a labels=() local -a values=() 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 [[ -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 )) || { 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_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:*) TAPM_PULSE_SET_BRIDGE_FROM_SELECTION "$variable" "$MENU_SELECTION";; 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_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" 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]}" 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" local temp_dir="$3" 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' 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" 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_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_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" 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'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." # 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. curl() { local curl_argument local setup_response for curl_argument in "$@"; do if [[ "$curl_argument" == "${pulse_url}/api/setup-script-url" ]]; then 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 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" 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_TOKEN_REQUEST_JSON >"$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_WAIT_AGENT_REGISTERED() { local pulse_url="$1" local token="$2" 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 # 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 \ --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 /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 } 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..." # 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 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 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-host \ --enable-proxmox \ --proxmox-type pve \ --enable-commands \ --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 } 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" "$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)" 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..." # 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 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 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-host \ --enable-proxmox \ --proxmox-type pve \ --enable-commands \ --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 "$node" "$pulse_url" "$agent_token" else TAPM_PULSE_INSTALL_AGENT_REMOTE "$node" "$pulse_url" "$agent_token" fi if (( $? == 0 )); then 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)) 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' 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 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=() 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]}" 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 curl openssl ssh; 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; } 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)" 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_UNTIL_VALID hostname "Container hostname" "Pulse-Monitor" \ TAPM_PULSE_VALID_HOSTNAME \ "The hostname must contain only letters, numbers, periods, and hyphens." 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_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 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_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_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_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' 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 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 }' )" [[ -n "$default_root_storage" ]] || { TAPM_PULSE_FAIL "No active storage supports container templates."; return 1; } template_storage="$default_root_storage" 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 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}" [[ -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, order ${startup}" 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]$ ]] || { 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]}" 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_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." return 1 fi fi if [[ -n "$address_cidr" ]]; then 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=${firewall},type=veth" fi [[ -n "$vlan_id" ]] && network_config+=",tag=${vlan_id}" echo -e "\n${idsCL[LightCyan]}Creating and starting LXC ${ctid}...${idsCL[Default]}" 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}" ) [[ "$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 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}" \ "$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" 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 [[ -n "$container_ip" ]]; then pulse_url="http://${container_ip}:${pulse_port}" 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]}" 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." TAPM_PULSE_OFFER_FAILED_LXC_REMOVAL "$ctid" || true 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 \ "$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" 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 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" container_created=0 echo 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" \ "$cluster_status" "$agent_status" }