#!/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_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_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_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() == "pulse" ): 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_FAIL() { echo -e "\n${idsCL[LightRed]}$1${idsCL[Default]}" return 1 } TAPM_PULSE_SELECT_BRIDGE() { local variable="$1" local bridge default_bridge="${2:-vmbr0}" local default_found=0 local -a bridges=() local -a labels=() local -a values=() while IFS= read -r bridge; do [[ -n "$bridge" ]] || continue bridges+=("$bridge") [[ "$bridge" == "$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 in "${bridges[@]}"; do [[ $default_found == 1 && "$bridge" == "$default_bridge" ]] && continue labels+=("$bridge") values+=("bridge:${bridge}") done SELECT_MENU "Pulse network bridge" labels values case "$MENU_SELECTION" in bridge:*) printf -v "$variable" '%s' "${MENU_SELECTION#bridge:}";; 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]}" pct stop "$ctid" --skiplock 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() { 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 primary_api_token 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)" TAPM_PULSE_PROMPT ctid "Container ID" "$default_ctid" TAPM_PULSE_VALID_CTID "$ctid" || { TAPM_PULSE_FAIL "The container ID is invalid."; return 1; } if pct status "$ctid" >/dev/null 2>&1; then TAPM_PULSE_FAIL "Container ${ctid} already exists; no changes were made." return 1 fi TAPM_PULSE_PROMPT hostname "Container hostname" "Pulse-Monitor" TAPM_PULSE_VALID_HOSTNAME "$hostname" || { TAPM_PULSE_FAIL "The hostname is invalid."; return 1; } read -r -p " Customize CPU, memory, disk, or swap? [y/N] " choice if [[ "$choice" =~ ^[Yy]$ ]]; then TAPM_PULSE_PROMPT memory "Memory in MiB" "$memory" TAPM_PULSE_VALID_POSITIVE_INTEGER "$memory" || { TAPM_PULSE_FAIL "Memory must be a positive whole number."; return 1; } TAPM_PULSE_PROMPT disk "Root disk size in GiB" "$disk" TAPM_PULSE_VALID_POSITIVE_INTEGER "$disk" || { TAPM_PULSE_FAIL "Disk size must be a positive whole number."; return 1; } TAPM_PULSE_PROMPT cores "CPU cores" "$cores" TAPM_PULSE_VALID_POSITIVE_INTEGER "$cores" || { TAPM_PULSE_FAIL "CPU cores must be a positive whole number."; return 1; } TAPM_PULSE_PROMPT cpulimit "CPU limit (0 for unlimited)" "$cpulimit" [[ "$cpulimit" =~ ^[0-9]+$ ]] || { TAPM_PULSE_FAIL "CPU limit must be zero or a positive whole number."; return 1; } TAPM_PULSE_PROMPT swap "Swap in MiB" "$swap" [[ "$swap" =~ ^[0-9]+$ ]] || { TAPM_PULSE_FAIL "Swap must be zero or a positive whole number."; return 1; } 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 address_cidr \ "Static IPv4 address with prefix (leave blank for DHCP)" if [[ -n "$address_cidr" ]]; then TAPM_PULSE_VALID_IPV4_CIDR "$address_cidr" || { TAPM_PULSE_FAIL "The static IPv4 address is invalid."; return 1; } TAPM_PULSE_PROMPT gateway "IPv4 gateway" TAPM_PULSE_VALID_IPV4_CIDR "${gateway}/32" || { TAPM_PULSE_FAIL "The IPv4 gateway is invalid."; return 1; } fi TAPM_PULSE_PROMPT nameserver \ "DNS servers, space-separated (leave blank to inherit host settings)" TAPM_PULSE_PROMPT vlan_id "VLAN ID (leave blank for untagged)" if [[ -n "$vlan_id" ]] && { [[ ! "$vlan_id" =~ ^[0-9]+$ ]] || (( vlan_id < 1 || vlan_id > 4094 )); }; then TAPM_PULSE_FAIL "The VLAN ID must be between 1 and 4094." return 1 fi 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 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 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}" --tags 'tapm;pulse' ) [[ "$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}" 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 # Reuse the verified release's registration implementation so its # API contract and least-privilege role handling stay in sync. # shellcheck disable=SC1090 source "$installer" IN_CONTAINER=false 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 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. 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" container_created=0 echo echo -e "${idsCL[Green]}Pulse ${release} was installed and its service is active.${idsCL[Default]}" 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]}" }