1378 lines
45 KiB
Bash
1378 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":"host","enableCommands":false}'
|
|
}
|
|
|
|
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
|
|
|
|
for (( attempt = 1; attempt <= 20; attempt++ )); do
|
|
response="$(
|
|
curl --fail --silent --show-error \
|
|
--connect-timeout 3 --max-time 5 \
|
|
--config "$curl_config" \
|
|
"${pulse_url}/api/agents/agent/lookup?hostname=${encoded_node}" \
|
|
2>/dev/null
|
|
)" || response=''
|
|
if TAPM_PULSE_AGENT_REGISTERED_FROM_RESPONSE "$response"; then
|
|
return 0
|
|
fi
|
|
sleep 3
|
|
done
|
|
return 1
|
|
}
|
|
|
|
TAPM_PULSE_INSTALL_AGENT_LOCAL() {
|
|
local node="$1"
|
|
local pulse_url="$2"
|
|
local token="$3"
|
|
local token_file installer_file cleanup_state_dir artifact
|
|
local status=0
|
|
local -a old_artifacts=(
|
|
/usr/local/bin/pulse-agent
|
|
/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 \
|
|
--disable-proxmox \
|
|
--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 \
|
|
--disable-proxmox \
|
|
--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"
|
|
}
|