Files
TA-ProxMenu/inc/deploy-pulse-lxc.sh
T
David Schroeder 6b5cbe928d update
2026-07-25 22:45:52 -05:00

1385 lines
45 KiB
Bash

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