This commit is contained in:
2026-07-25 20:19:28 -05:00
parent 747e984da6
commit f1ed0bb12d
3 changed files with 446 additions and 9 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
action="${1:-}" action="${1:-}"
FOLDER='/opt/idssys/ta-proxmenu' FOLDER='/opt/idssys/ta-proxmenu'
VERS='2026.7.25-47' VERS='2026.7.25-48'
noupdate=' ' noupdate=' '
+414 -8
View File
@@ -54,6 +54,65 @@ TAPM_PULSE_ARCH() {
esac 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_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() { TAPM_PULSE_RESOURCE_INSTALLED() {
local resources_json="${1:-[]}" local resources_json="${1:-[]}"
@@ -202,6 +261,323 @@ TAPM_PULSE_REMOVE_PARTIAL_LXC() {
pct destroy "$ctid" --purge 1 >/dev/null 2>&1 || true pct destroy "$ctid" --purge 1 >/dev/null 2>&1 || true
} }
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 bootstrap_output bootstrap_token generated_username generated_password generated_api_token
local request_file curl_config response security_ready='no' attempt
generated_username='admin'
generated_password="Ta!9-$(openssl rand -hex 18)" || return 1
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_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_NAME="tapm-pve-${node}" python3 -c '
import json, os, sys
json.dump({
"type": "host",
"name": os.environ["TAPM_PULSE_AGENT_NAME"],
"enableCommands": False,
}, sys.stdout)
' >"$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_INSTALL_AGENT_LOCAL() {
local pulse_url="$1"
local token="$2"
local token_file installer_file 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
}
chmod 0600 "$token_file" "$installer_file" || {
rm -f -- "$token_file" "$installer_file"
return 1
}
printf '%s' "$token" >"$token_file" || {
rm -f -- "$token_file" "$installer_file"
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..."
bash "$installer_file" --uninstall --non-interactive || 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
bash "$installer_file" \
--url "$pulse_url" \
--token-file "$token_file" \
--enable-proxmox \
--proxmox-type pve \
--non-interactive \
--insecure || status=$?
fi
rm -f -- "$token_file" "$installer_file"
(( 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" <<'TAPM_PULSE_REMOTE_AGENT'
set -eu -o pipefail
pulse_url="$1"
token_file="$2"
installer_file="$(mktemp /tmp/tapm-pulse-agent-installer.XXXXXX)"
trap 'rm -f -- "$token_file" "$installer_file"' 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..."
bash "$installer_file" --uninstall --non-interactive
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
bash "$installer_file" \
--url "$pulse_url" \
--token-file "$token_file" \
--enable-proxmox \
--proxmox-type pve \
--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 "$pulse_url" "$agent_token"
else
TAPM_PULSE_INSTALL_AGENT_REMOTE "$node" "$pulse_url" "$agent_token"
fi
if (( $? == 0 )); then
echo -e "${idsCL[Green]}Pulse Unified Agent is active on ${node}.${idsCL[Default]}"
((installed += 1))
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() { TAPM_DEPLOY_PULSE_LXC() {
local release="${PULSE_RELEASE:-v6.1.1}" local release="${PULSE_RELEASE:-v6.1.1}"
local pulse_port="${PULSE_PORT:-7655}" auto_update_flag='--disable-auto-updates' local pulse_port="${PULSE_PORT:-7655}" auto_update_flag='--disable-auto-updates'
@@ -210,7 +586,7 @@ TAPM_DEPLOY_PULSE_LXC() {
local arch archive_name base_url installer archive signature installer_signature local arch archive_name base_url installer archive signature installer_signature
local memory disk cores cpulimit swap onboot firewall unprivileged nameserver startup 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 network_config choice add_ha='no' auto_updates='yes' container_ip timezone temp_dir
local default_bridge local default_bridge pulse_url admin_username admin_password primary_api_token
local container_created=0 local container_created=0
local -a create_args=() local -a create_args=()
@@ -235,7 +611,7 @@ TAPM_DEPLOY_PULSE_LXC() {
[[ $EUID -eq 0 ]] || [[ $EUID -eq 0 ]] ||
{ TAPM_PULSE_FAIL "Run this action as root on a Proxmox VE host."; return 1; } { TAPM_PULSE_FAIL "Run this action as root on a Proxmox VE host."; return 1; }
for command in pct pvesm pveam pvesh ssh-keygen python3; do for command in pct pvesm pveam pvesh ssh-keygen python3 curl openssl ssh; do
command -v "$command" >/dev/null 2>&1 || command -v "$command" >/dev/null 2>&1 ||
{ TAPM_PULSE_FAIL "Required command '${command}' was not found."; return 1; } { TAPM_PULSE_FAIL "Required command '${command}' was not found."; return 1; }
done done
@@ -490,6 +866,7 @@ TAPM_DEPLOY_PULSE_LXC() {
)" )"
if [[ -n "$container_ip" ]]; then if [[ -n "$container_ip" ]]; then
pulse_url="http://${container_ip}:${pulse_port}"
echo -e "\n${idsCL[LightCyan]}Registering the Proxmox cluster with Pulse...${idsCL[Default]}" echo -e "\n${idsCL[LightCyan]}Registering the Proxmox cluster with Pulse...${idsCL[Default]}"
( (
trap 'rm -f /tmp/pulse-auto-register-request.json /tmp/pulse-auto-register-response.json' EXIT trap 'rm -f /tmp/pulse-auto-register-request.json /tmp/pulse-auto-register-response.json' EXIT
@@ -498,7 +875,7 @@ TAPM_DEPLOY_PULSE_LXC() {
# shellcheck disable=SC1090 # shellcheck disable=SC1090
source "$installer" source "$installer"
IN_CONTAINER=false IN_CONTAINER=false
wait_for_pulse_ready "http://${container_ip}:${pulse_port}" 120 1 || true wait_for_pulse_ready "$pulse_url" 120 1 || true
auto_register_pve_node "$ctid" "$container_ip" "$pulse_port" auto_register_pve_node "$ctid" "$container_ip" "$pulse_port"
) || echo -e "${idsCL[LightYellow]}Pulse is installed, but automatic Proxmox registration did not complete.${idsCL[Default]}" ) || echo -e "${idsCL[LightYellow]}Pulse is installed, but automatic Proxmox registration did not complete.${idsCL[Default]}"
fi fi
@@ -508,6 +885,33 @@ TAPM_DEPLOY_PULSE_LXC() {
echo -e "${idsCL[LightYellow]}Pulse is running, but it could not be added to HA.${idsCL[Default]}" echo -e "${idsCL[LightYellow]}Pulse is running, but it could not be added to HA.${idsCL[Default]}"
fi 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. The LXC was kept."
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; then
pct exec "$ctid" -- rm -f \
/tmp/install.sh "/tmp/${archive_name}" "/tmp/${archive_name}.sshsig" || true
TAPM_CLEAN_TEMP_DIR "$temp_dir"
echo " The Pulse LXC was kept so setup can be recovered without reinstalling it."
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"
return 1
fi
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
pct exec "$ctid" -- rm -f \ pct exec "$ctid" -- rm -f \
/tmp/install.sh "/tmp/${archive_name}" "/tmp/${archive_name}.sshsig" || true /tmp/install.sh "/tmp/${archive_name}" "/tmp/${archive_name}.sshsig" || true
TAPM_CLEAN_TEMP_DIR "$temp_dir" TAPM_CLEAN_TEMP_DIR "$temp_dir"
@@ -515,9 +919,11 @@ TAPM_DEPLOY_PULSE_LXC() {
echo echo
echo -e "${idsCL[Green]}Pulse ${release} was installed and its service is active.${idsCL[Default]}" echo -e "${idsCL[Green]}Pulse ${release} was installed and its service is active.${idsCL[Default]}"
if [[ -n "$container_ip" ]]; then echo -e " Open: ${idsCL[LightCyan]}${pulse_url}${idsCL[Default]}"
echo -e " Open ${idsCL[LightCyan]}http://${container_ip}:${pulse_port}${idsCL[Default]} to finish setup." echo " Username: ${admin_username}"
else echo " Password: ${admin_password}"
echo " Open the Pulse LXC address on port ${pulse_port} to finish setup." echo " API token: ${primary_api_token}"
fi echo " Agents: ${TAPM_PULSE_AGENTS_INSTALLED} installed, ${TAPM_PULSE_AGENTS_FAILED} failed or offline"
echo
echo -e "${idsCL[LightYellow]}Save the generated password and API token now; they are only shown once.${idsCL[Default]}"
} }
+31
View File
@@ -15,6 +15,37 @@ assert_equal amd64 "$(TAPM_PULSE_ARCH x86_64)" "x86 architecture mapping"
assert_equal arm64 "$(TAPM_PULSE_ARCH aarch64)" "ARM architecture mapping" assert_equal arm64 "$(TAPM_PULSE_ARCH aarch64)" "ARM architecture mapping"
assert_failure "unsupported Pulse architecture" TAPM_PULSE_ARCH riscv64 assert_failure "unsupported Pulse architecture" TAPM_PULSE_ARCH riscv64
bootstrap_token='0123456789abcdef0123456789abcdef0123456789abcdef'
bootstrap_output="║ Token: ${bootstrap_token}"
assert_equal "$bootstrap_token" \
"$(TAPM_PULSE_BOOTSTRAP_TOKEN_FROM_OUTPUT "$bootstrap_output")" \
"Pulse bootstrap token parsed"
assert_failure "malformed Pulse bootstrap output rejected" \
TAPM_PULSE_BOOTSTRAP_TOKEN_FROM_OUTPUT 'Token: not-a-token'
agent_token='0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
assert_equal "$agent_token" \
"$(TAPM_PULSE_TOKEN_FROM_RESPONSE "{\"token\":\"${agent_token}\"}")" \
"Pulse agent token parsed"
assert_failure "missing Pulse agent token rejected" \
TAPM_PULSE_TOKEN_FROM_RESPONSE '{"command":"install"}'
assert_failure "Pulse agent token containing whitespace rejected" \
TAPM_PULSE_TOKEN_FROM_RESPONSE '{"token":"invalid token"}'
assert_failure "non-hex Pulse agent token rejected" \
TAPM_PULSE_TOKEN_FROM_RESPONSE '{"token":"not-a-token"}'
nodes_json='[
{"node":"pve3","status":"offline"},
{"node":"pve2","status":"online"},
{"node":"pve1","status":"online"}
]'
assert_equal $'pve1\npve2' \
"$(TAPM_PULSE_ONLINE_NODES_FROM_JSON "$nodes_json")" \
"online Pulse nodes selected and sorted"
assert_equal 'pve3' \
"$(TAPM_PULSE_OFFLINE_NODES_FROM_JSON "$nodes_json")" \
"offline Pulse nodes reported"
assert_success "valid Pulse CTID" TAPM_PULSE_VALID_CTID 210 assert_success "valid Pulse CTID" TAPM_PULSE_VALID_CTID 210
assert_failure "invalid Pulse CTID" TAPM_PULSE_VALID_CTID 99 assert_failure "invalid Pulse CTID" TAPM_PULSE_VALID_CTID 99
assert_success "valid Pulse hostname" TAPM_PULSE_VALID_HOSTNAME pulse-monitor assert_success "valid Pulse hostname" TAPM_PULSE_VALID_HOSTNAME pulse-monitor