diff --git a/defaults.inc b/defaults.inc index 8f05176..91c628c 100755 --- a/defaults.inc +++ b/defaults.inc @@ -3,7 +3,7 @@ action="${1:-}" FOLDER='/opt/idssys/ta-proxmenu' -VERS='2026.7.25-53' +VERS='2026.7.25-54' noupdate=' ' diff --git a/inc/deploy-pulse-lxc.sh b/inc/deploy-pulse-lxc.sh index 00d5019..c7dc9d3 100644 --- a/inc/deploy-pulse-lxc.sh +++ b/inc/deploy-pulse-lxc.sh @@ -491,6 +491,76 @@ raise SystemExit(0 if success is True else 1) 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_REGISTER_CLUSTER() { local ctid="$1" local container_ip="$2" @@ -523,10 +593,15 @@ TAPM_PULSE_REGISTER_CLUSTER() { # 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 - command curl --config "$curl_config" "$@" + 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 diff --git a/tests/test-pulse.sh b/tests/test-pulse.sh index a4b75c0..dce502c 100644 --- a/tests/test-pulse.sh +++ b/tests/test-pulse.sh @@ -53,6 +53,45 @@ assert_equal '{"type":"pve","enableCommands":false}' \ "$(TAPM_PULSE_AGENT_TOKEN_REQUEST_JSON)" \ "Proxmox-specific agent enrollment requested with commands disabled" +setup_token='0123456789abcdef0123456789abcdef' +setup_host='https://pve1.example.test:8006' +setup_old_url='https://pulse.example.test/api/setup-script?backup_perms=true' +setup_response="$( + SETUP_TOKEN="$setup_token" SETUP_HOST="$setup_host" SETUP_OLD_URL="$setup_old_url" \ + python3 -c ' +import json, os, sys, time +old_url = os.environ["SETUP_OLD_URL"] +token = os.environ["SETUP_TOKEN"] +json.dump({ + "type": "pve", + "host": os.environ["SETUP_HOST"], + "url": old_url, + "downloadURL": old_url + "&setup_token=" + token, + "scriptFileName": "pulse-setup-pve.sh", + "command": old_url + " PULSE_SETUP_TOKEN=" + token + " if", + "commandWithEnv": old_url + " PULSE_SETUP_TOKEN=" + token + " if", + "commandWithoutEnv": old_url + " if", + "expires": int(time.time()) + 300, + "setupToken": token, + "tokenHint": token[:3] + "..." + token[-3:], +}, sys.stdout) +' +)" +normalized_setup="$( + TAPM_PULSE_NORMALIZE_V611_SETUP_ARTIFACT \ + "$setup_response" 'http://10.10.2.30:7655' +)" +assert_equal "$setup_token" \ + "$(SETUP_RESPONSE="$normalized_setup" python3 -c 'import json, os; print(json.loads(os.environ["SETUP_RESPONSE"])["setupToken"])')" \ + "Pulse v6.1.1 compatibility preserves server-issued setup token" +assert_equal \ + 'http://10.10.2.30:7655/api/setup-script?host=https%3A%2F%2Fpve1.example.test%3A8006&pulse_url=http%3A%2F%2F10.10.2.30%3A7655&type=pve' \ + "$(SETUP_RESPONSE="$normalized_setup" python3 -c 'import json, os; print(json.loads(os.environ["SETUP_RESPONSE"])["url"])')" \ + "Pulse v6.1.1 compatibility normalizes setup artifact URL" +assert_failure "Pulse v6.1.1 compatibility rejects missing setup token" \ + TAPM_PULSE_NORMALIZE_V611_SETUP_ARTIFACT \ + '{"type":"pve","host":"https://pve1:8006"}' 'http://10.10.2.30:7655' + nodes_json='[ {"node":"pve3","status":"offline"}, {"node":"pve2","status":"online"},