diff --git a/defaults.inc b/defaults.inc index f15bf34..48d55b3 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-47' +VERS='2026.7.25-48' noupdate=' ' diff --git a/inc/deploy-pulse-lxc.sh b/inc/deploy-pulse-lxc.sh index 133ec33..995c132 100644 --- a/inc/deploy-pulse-lxc.sh +++ b/inc/deploy-pulse-lxc.sh @@ -54,6 +54,65 @@ TAPM_PULSE_ARCH() { 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() { local resources_json="${1:-[]}" @@ -202,6 +261,323 @@ TAPM_PULSE_REMOVE_PARTIAL_LXC() { 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() { local release="${PULSE_RELEASE:-v6.1.1}" 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 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 + local default_bridge pulse_url admin_username admin_password primary_api_token local container_created=0 local -a create_args=() @@ -235,7 +611,7 @@ TAPM_DEPLOY_PULSE_LXC() { [[ $EUID -eq 0 ]] || { TAPM_PULSE_FAIL "Run this action as root on a Proxmox VE host."; return 1; } - for command in pct pvesm pveam pvesh ssh-keygen python3; do + 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 @@ -490,6 +866,7 @@ TAPM_DEPLOY_PULSE_LXC() { )" 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]}" ( 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 source "$installer" 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" ) || echo -e "${idsCL[LightYellow]}Pulse is installed, but automatic Proxmox registration did not complete.${idsCL[Default]}" 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]}" 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 \ /tmp/install.sh "/tmp/${archive_name}" "/tmp/${archive_name}.sshsig" || true TAPM_CLEAN_TEMP_DIR "$temp_dir" @@ -515,9 +919,11 @@ TAPM_DEPLOY_PULSE_LXC() { echo echo -e "${idsCL[Green]}Pulse ${release} was installed and its service is active.${idsCL[Default]}" - if [[ -n "$container_ip" ]]; then - echo -e " Open ${idsCL[LightCyan]}http://${container_ip}:${pulse_port}${idsCL[Default]} to finish setup." - else - echo " Open the Pulse LXC address on port ${pulse_port} to finish setup." - fi + echo -e " Open: ${idsCL[LightCyan]}${pulse_url}${idsCL[Default]}" + echo " Username: ${admin_username}" + echo " Password: ${admin_password}" + echo " API token: ${primary_api_token}" + 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]}" } diff --git a/tests/test-pulse.sh b/tests/test-pulse.sh index 6f44214..cb61d80 100644 --- a/tests/test-pulse.sh +++ b/tests/test-pulse.sh @@ -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_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_failure "invalid Pulse CTID" TAPM_PULSE_VALID_CTID 99 assert_success "valid Pulse hostname" TAPM_PULSE_VALID_HOSTNAME pulse-monitor