Pulse/install.sh
rcourtman 6dc43d298c Stage server update archives on the install filesystem, not /tmp
The server installer downloaded the release archive and extracted it
under /tmp. Debian 13 and many LXC templates mount /tmp as tmpfs, whose
pages are charged to the container's memory cgroup. A v6.5.0 archive is
~280 MB and extracts to ~750 MB, so an unattended update on a 1 GiB LXC
filled the cgroup with ~820 MB of shmem; the kernel OOM-killed the
container's systemd and Proxmox stopped the container mid-update.

Download and extract into owner-only hidden directories under the
install directory instead. It is always writable from the hardened
pulse-update.service sandbox, it is where the staged binary already
lives, and the disk headroom preflight now measures the filesystem the
update actually stages on. The Go updater already prefers its data
directory over /tmp for the same reason.

Unlike /tmp, the install filesystem keeps an interrupted run's staging
across reboots. Staging setup now removes stale staging directories that
match only the exact owner-only names, never symlinks or files, and only
once their ctime is over six hours old: install.sh has no update lock,
so age is what proves no live run still owns them. Every recursive
pulse chown of the install directory now goes through one helper that
skips staging, so the payload root still reads is never handed to the
service account and the ~750 MB tree is not rewritten for nothing.

The "Failed to print table: Broken pipe" line in the report was
systemctl list-unit-files | grep -q in v6.5.0's service discovery; main
already reads that inventory in full, and it was not the failure.

Contract-Neutral: Defect fix inside the existing private server-installer inputs and update-space boundaries; staging moves from tmpfs-prone /tmp to the install filesystem with no public contract change
2026-10-16 23:49:36 +01:00

6117 lines
235 KiB
Bash

#!/usr/bin/env bash
# Pulse Installer Script
# Supports: Ubuntu 20.04+, Debian 11+, Proxmox VE 7+
set -euo pipefail
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration helpers
default_install_dir_for_service() {
local service_name="${1:-pulse}"
if [[ "$service_name" == "pulse" ]]; then
printf '/opt/pulse\n'
else
printf '/opt/%s\n' "$service_name"
fi
}
default_config_dir_for_service() {
local service_name="${1:-pulse}"
if [[ "$service_name" == "pulse" ]]; then
printf '/etc/pulse\n'
else
printf '/etc/%s\n' "$service_name"
fi
}
default_binary_link_path_for_service() {
local service_name="${1:-pulse}"
if [[ "$service_name" == "pulse" ]]; then
printf '/usr/local/bin/pulse\n'
else
printf '/usr/local/bin/%s\n' "$service_name"
fi
}
default_update_helper_path_for_service() {
local service_name="${1:-pulse}"
if [[ "$service_name" == "pulse" ]]; then
printf '/bin/update\n'
else
printf '/usr/local/bin/update-%s\n' "$service_name"
fi
}
default_auto_update_dest_for_service() {
local service_name="${1:-pulse}"
if [[ "$service_name" == "pulse" ]]; then
printf '/usr/local/bin/pulse-auto-update.sh\n'
else
printf '/usr/local/bin/%s-auto-update.sh\n' "$service_name"
fi
}
default_update_service_path_for_service() {
local service_name="${1:-pulse}"
printf '/etc/systemd/system/%s-update.service\n' "$service_name"
}
default_update_timer_path_for_service() {
local service_name="${1:-pulse}"
printf '/etc/systemd/system/%s-update.timer\n' "$service_name"
}
# Configuration
DEFAULT_SERVICE_NAME="pulse"
SERVICE_NAME_EXPLICIT="false"
if [[ -n "${PULSE_SERVICE_NAME:-}" ]]; then
SERVICE_NAME_EXPLICIT="true"
fi
SERVICE_NAME="${PULSE_SERVICE_NAME:-$DEFAULT_SERVICE_NAME}"
INSTALL_DIR="${PULSE_INSTALL_DIR:-$(default_install_dir_for_service "$SERVICE_NAME")}"
CONFIG_DIR="${PULSE_CONFIG_DIR:-$(default_config_dir_for_service "$SERVICE_NAME")}" # All config and data goes here for manual installs
BINARY_LINK_PATH="${PULSE_BINARY_LINK_PATH:-$(default_binary_link_path_for_service "$SERVICE_NAME")}"
UPDATE_HELPER_PATH="${PULSE_UPDATE_HELPER_PATH:-$(default_update_helper_path_for_service "$SERVICE_NAME")}"
AUTO_UPDATE_DEST="${PULSE_AUTO_UPDATE_DEST:-$(default_auto_update_dest_for_service "$SERVICE_NAME")}"
UPDATE_SERVICE_PATH="${PULSE_UPDATE_SERVICE_PATH:-$(default_update_service_path_for_service "$SERVICE_NAME")}"
UPDATE_TIMER_PATH="${PULSE_UPDATE_TIMER_PATH:-$(default_update_timer_path_for_service "$SERVICE_NAME")}"
UPDATE_SERVICE_UNIT="$(basename "$UPDATE_SERVICE_PATH")"
UPDATE_TIMER_UNIT="$(basename "$UPDATE_TIMER_PATH")"
GITHUB_REPO="rcourtman/Pulse"
DOCKER_IMAGE_REPO="${DOCKER_IMAGE_REPO:-rcourtman/pulse}"
INSTALL_SIGNATURE_IDENTITY="pulse-installer"
INSTALL_SIGNATURE_NAMESPACE="pulse-install"
PINNED_RELEASE_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer"
BUILD_FROM_SOURCE=false
SKIP_DOWNLOAD=false
IN_CONTAINER=false
IN_DOCKER=false
ENABLE_AUTO_UPDATES=false
AUTO_UPDATE_CHOICE_EXPLICIT=false
FORCE_VERSION=""
FORCE_CHANNEL=""
SKIP_UPGRADE_PREFLIGHT="${PULSE_SKIP_UPGRADE_PREFLIGHT:-false}"
UPGRADE_PREFLIGHT_RAN=false
ARCHIVE_OVERRIDE="${PULSE_ARCHIVE_PATH:-}"
SOURCE_BRANCH="main"
CURRENT_INSTALL_CTID=""
CONTAINER_CREATED_FOR_CLEANUP=false
BUILD_FROM_SOURCE_MARKER="$INSTALL_DIR/BUILD_FROM_SOURCE"
DETECTED_CTID=""
UPDATE_MIN_TEMP_FREE_BYTES=$((900 * 1024 * 1024))
UPDATE_MIN_INSTALL_FREE_BYTES=$((256 * 1024 * 1024))
SERVER_STAGING_STALE_MINUTES=360
CONFIG_BACKUP_MIN_EXTRA_BYTES=$((64 * 1024 * 1024))
release_signature_key_available() {
[[ -n "${PINNED_RELEASE_SSH_PUBLIC_KEY:-}" ]]
}
require_release_signature_verifier() {
if ! release_signature_key_available; then
echo "Pinned release signature key is not configured." >&2
return 1
fi
if ! command -v ssh-keygen >/dev/null 2>&1; then
echo "ssh-keygen is required to verify signed Pulse release assets." >&2
return 1
fi
}
verify_release_signature() {
local target_path="$1"
local signature_path="$2"
local context="${3:-downloaded file}"
local allowed_signers=""
if ! require_release_signature_verifier; then
return 1
fi
allowed_signers=$(mktemp /tmp/pulse-release-signers.XXXXXX)
printf '%s %s\n' "$INSTALL_SIGNATURE_IDENTITY" "$PINNED_RELEASE_SSH_PUBLIC_KEY" > "$allowed_signers"
if ! ssh-keygen -Y verify \
-f "$allowed_signers" \
-I "$INSTALL_SIGNATURE_IDENTITY" \
-n "$INSTALL_SIGNATURE_NAMESPACE" \
-s "$signature_path" < "$target_path" >/dev/null 2>&1; then
rm -f "$allowed_signers"
echo "Cryptographic signature verification failed for ${context}." >&2
return 1
fi
rm -f "$allowed_signers"
return 0
}
# Installer version - the major version this script is bundled with
INSTALLER_MAJOR_VERSION=5
AUTO_NODE_REGISTERED=false
AUTO_NODE_REGISTERED_NAME=""
AUTO_NODE_REGISTER_ERROR=""
DEBIAN_TEMPLATE_FALLBACK="debian-12-standard_12.12-1_amd64.tar.zst"
DEBIAN_TEMPLATE=""
bytes_to_human() {
local bytes="${1:-0}"
if [[ ! "$bytes" =~ ^[0-9]+$ ]]; then
printf '%s\n' "$bytes"
return 0
fi
local units=("B" "KB" "MB" "GB" "TB")
local value="$bytes"
local unit_index=0
while (( value >= 1024 && unit_index < ${#units[@]} - 1 )); do
value=$((value / 1024))
((unit_index += 1))
done
printf '%s%s\n' "$value" "${units[$unit_index]}"
}
get_available_bytes_for_path() {
local path="$1"
local available_kb=""
available_kb=$(df -Pk "$path" 2>/dev/null | awk 'NR==2 {print $4}')
if [[ ! "$available_kb" =~ ^[0-9]+$ ]]; then
return 1
fi
printf '%s\n' $((available_kb * 1024))
}
get_filesystem_device_for_path() {
local path="$1"
local filesystem=""
filesystem=$(df -Pk "$path" 2>/dev/null | awk 'NR==2 {print $1}')
if [[ -z "$filesystem" ]]; then
return 1
fi
printf '%s\n' "$filesystem"
}
get_directory_size_bytes() {
local path="$1"
local used_kb=""
used_kb=$(du -sk "$path" 2>/dev/null | awk 'NR==1 {print $1}')
if [[ ! "$used_kb" =~ ^[0-9]+$ ]]; then
return 1
fi
printf '%s\n' $((used_kb * 1024))
}
ensure_update_disk_headroom() {
local temp_path="${1:-/tmp}"
local install_path="${2:-$INSTALL_DIR}"
local temp_fs=""
local install_fs=""
local temp_free_bytes=""
local install_free_bytes=""
local combined_required_bytes=$((UPDATE_MIN_TEMP_FREE_BYTES + UPDATE_MIN_INSTALL_FREE_BYTES))
temp_fs=$(get_filesystem_device_for_path "$temp_path" 2>/dev/null || true)
install_fs=$(get_filesystem_device_for_path "$install_path" 2>/dev/null || true)
temp_free_bytes=$(get_available_bytes_for_path "$temp_path" 2>/dev/null || true)
install_free_bytes=$(get_available_bytes_for_path "$install_path" 2>/dev/null || true)
if [[ -z "$temp_free_bytes" || -z "$install_free_bytes" ]]; then
print_warn "Could not determine available disk space for the update preflight; continuing anyway"
return 0
fi
if [[ -n "$temp_fs" && "$temp_fs" == "$install_fs" ]]; then
if (( temp_free_bytes < combined_required_bytes )); then
print_error "Not enough free disk space to stage the Pulse update"
print_info "The same filesystem backs $temp_path and $install_path"
print_info "Available: $(bytes_to_human "$temp_free_bytes"), required: $(bytes_to_human "$combined_required_bytes")"
print_info "Free disk space and retry the update"
return 1
fi
return 0
fi
if (( temp_free_bytes < UPDATE_MIN_TEMP_FREE_BYTES )); then
print_error "Not enough free disk space in $temp_path to stage the Pulse update"
print_info "Available: $(bytes_to_human "$temp_free_bytes"), required: $(bytes_to_human "$UPDATE_MIN_TEMP_FREE_BYTES")"
print_info "Free disk space under $temp_path and retry the update"
return 1
fi
if (( install_free_bytes < UPDATE_MIN_INSTALL_FREE_BYTES )); then
print_error "Not enough free disk space in $install_path to apply the Pulse update"
print_info "Available: $(bytes_to_human "$install_free_bytes"), required: $(bytes_to_human "$UPDATE_MIN_INSTALL_FREE_BYTES")"
print_info "Free disk space under $install_path and retry the update"
return 1
fi
return 0
}
ensure_config_backup_headroom() {
local config_path="${1:-$CONFIG_DIR}"
local backup_parent="${2:-}"
local free_bytes=""
local config_bytes=""
local required_bytes=""
if [[ ! -d "$config_path" ]]; then
return 0
fi
if [[ -z "$backup_parent" ]]; then
backup_parent="$(dirname "$config_path")"
fi
free_bytes=$(get_available_bytes_for_path "$backup_parent" 2>/dev/null || true)
config_bytes=$(get_directory_size_bytes "$config_path" 2>/dev/null || true)
if [[ -z "$free_bytes" || -z "$config_bytes" ]]; then
print_warn "Could not determine disk headroom for configuration backup; continuing anyway"
return 0
fi
required_bytes=$((config_bytes + CONFIG_BACKUP_MIN_EXTRA_BYTES))
if (( free_bytes < required_bytes )); then
print_error "Not enough free disk space to back up the existing Pulse configuration"
print_info "Configuration size: $(bytes_to_human "$config_bytes")"
print_info "Available in $backup_parent: $(bytes_to_human "$free_bytes"), required: $(bytes_to_human "$required_bytes")"
print_info "Free disk space or remove old configuration backups under $backup_parent, then retry the update"
return 1
fi
return 0
}
get_latest_release_from_redirect() {
# Follow the GitHub "latest" redirect and extract the tag in a way that
# tolerates intermediate redirects that omit /tag/ (issue #698).
local target_url="${1:-https://github.com/$GITHUB_REPO/releases/latest}"
local effective_url=""
local curl_cmd=(curl -fsSL --connect-timeout 5 --max-time 10 -o /dev/null -w '%{url_effective}' "$target_url")
if command -v timeout >/dev/null 2>&1; then
effective_url=$(timeout 10 "${curl_cmd[@]}" 2>/dev/null || true)
else
effective_url=$("${curl_cmd[@]}" 2>/dev/null || true)
fi
# Strip stray carriage returns so string comparisons behave under set -u
effective_url="${effective_url//$'\r'/}"
if [[ -z "$effective_url" ]]; then
return 1
fi
local tag=""
if [[ "$effective_url" =~ /tag/([^/?#]+) ]]; then
tag="${BASH_REMATCH[1]}"
elif [[ "$effective_url" =~ /download/([^/?#]+)/ ]]; then
tag="${BASH_REMATCH[1]}"
fi
if [[ -z "$tag" ]]; then
return 1
fi
printf '%s\n' "$tag"
return 0
}
is_stable_release_tag() {
local tag="${1:-}"
tag="${tag#v}"
[[ "$tag" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]]
}
is_pulse_release_tag() {
local tag="${1:-}"
tag="${tag#v}"
[[ "$tag" =~ ^[0-9]+[.][0-9]+[.][0-9]+(-(beta|rc)[.][0-9]+)?$ ]]
}
latest_pulse_release_tag_from_json() {
local releases_json="${1:-}"
local channel="${2:-stable}"
local latest_release=""
if [[ -z "$releases_json" ]] || [[ "$channel" != stable && "$channel" != rc ]]; then
return 1
fi
# Tag-shaped text is not release metadata: regex extraction loses draft
# and prerelease ownership. Without a parser, use only the caller's checked
# public redirect (or an explicitly pinned version), never a guessed list.
command -v jq >/dev/null 2>&1 || return 1
latest_release=$(printf '%s' "$releases_json" | jq -er -s --arg channel "$channel" '
if length != 1 or (.[0] | type) != "array" then
error("Expected one release array")
else .[0] end
| [.[] | select(type == "object")
| select(.draft == false and (.prerelease | type) == "boolean")
| select(.tag_name | type == "string")
| select(.tag_name | test("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-(beta|rc)\\.[0-9]+)?\\z"))
| select($channel == "rc" or (.prerelease == false
and (.tag_name | test("^v?[0-9]+\\.[0-9]+\\.[0-9]+\\z"))))]
| .[0].tag_name // empty
' 2>/dev/null) || return 1
if is_pulse_release_tag "$latest_release"; then
printf '%s\n' "$latest_release"
return 0
fi
return 1
}
latest_stable_release_tag_from_json() {
latest_pulse_release_tag_from_json "${1:-}" stable
}
read_configured_update_channel() {
if [[ "${IGNORE_CONFIGURED_UPDATE_CHANNEL:-false}" == "true" ]]; then
return 1
fi
if [[ ! -f "$CONFIG_DIR/system.json" ]]; then
return 1
fi
local configured_channel=""
configured_channel=$(grep -o '"updateChannel"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_DIR/system.json" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/' || true)
if [[ -z "$configured_channel" ]]; then
return 1
fi
printf '%s\n' "$configured_channel"
}
resolve_install_script_download_url() {
if [[ -n "${FORCE_VERSION:-}" ]]; then
printf 'https://github.com/%s/releases/download/%s/install.sh\n' "$GITHUB_REPO" "$FORCE_VERSION"
return 0
fi
local channel="${FORCE_CHANNEL:-${UPDATE_CHANNEL:-stable}}"
if [[ "$channel" != "rc" ]]; then
local stable_release=""
stable_release=$(resolve_latest_release_tag_for_channel stable 2>/dev/null || true)
if [[ -z "$stable_release" || "$stable_release" == "null" ]]; then
return 1
fi
printf 'https://github.com/%s/releases/download/%s/install.sh\n' "$GITHUB_REPO" "$stable_release"
return 0
fi
local rc_release=""
rc_release=$(resolve_latest_release_tag_for_channel rc 2>/dev/null || true)
if [[ -z "$rc_release" || "$rc_release" == "null" ]]; then
return 1
fi
printf 'https://github.com/%s/releases/download/%s/install.sh\n' "$GITHUB_REPO" "$rc_release"
}
resolve_release_asset_base_url() {
if [[ -n "${LATEST_RELEASE:-}" ]]; then
printf 'https://github.com/%s/releases/download/%s\n' "$GITHUB_REPO" "$LATEST_RELEASE"
return 0
fi
printf 'https://github.com/%s/releases/latest/download\n' "$GITHUB_REPO"
}
selected_update_channel() {
if [[ -n "${FORCE_CHANNEL:-}" ]]; then
printf '%s\n' "$FORCE_CHANNEL"
return 0
fi
if [[ -n "${FORCE_VERSION:-}" ]]; then
if [[ "$FORCE_VERSION" == *-* ]]; then
printf 'rc\n'
else
printf 'stable\n'
fi
return 0
fi
if [[ -n "${UPDATE_CHANNEL:-}" ]]; then
printf '%s\n' "$UPDATE_CHANNEL"
return 0
fi
local configured_channel=""
configured_channel=$(read_configured_update_channel 2>/dev/null || true)
if [[ -n "$configured_channel" ]]; then
printf '%s\n' "$configured_channel"
return 0
fi
printf 'stable\n'
}
build_container_install_command() {
local install_cmd="bash /tmp/install.sh --in-container"
local quoted_archive=""
if [[ -n "${FORCE_VERSION:-}" ]]; then
install_cmd="$install_cmd --version '$FORCE_VERSION'"
elif [[ "$(selected_update_channel)" == "rc" ]]; then
install_cmd="$install_cmd --rc"
fi
if [[ -n "${container_archive_dest:-}" ]]; then
printf -v quoted_archive '%q' "$container_archive_dest"
install_cmd="$install_cmd --archive $quoted_archive"
fi
if [[ -n "${auto_updates_flag:-}" ]]; then
install_cmd="$install_cmd $auto_updates_flag"
fi
if [[ "${BUILD_FROM_SOURCE:-false}" == "true" ]]; then
install_cmd="$install_cmd --source '$SOURCE_BRANCH'"
fi
if [[ "${frontend_port:-7655}" != "7655" ]]; then
install_cmd="FRONTEND_PORT=$frontend_port $install_cmd"
fi
printf '%s\n' "$install_cmd"
}
print_container_recovery_command() {
local install_cmd
install_cmd=$(build_container_install_command)
print_info " $install_cmd"
}
detect_lxc_ctid() {
local ctid=""
if [[ -r /proc/1/cgroup ]]; then
ctid=$(sed 's/\\x2d/-/g' /proc/1/cgroup 2>/dev/null | grep -Eo '(lxc|machine-lxc)-[0-9]+' | tail -n1 | grep -Eo '[0-9]+' | tail -n1)
if [[ -n "$ctid" ]]; then
echo "$ctid"
return
fi
fi
if command -v hostname >/dev/null 2>&1; then
ctid=$(hostname 2>/dev/null || true)
if [[ "$ctid" =~ ^[0-9]+$ ]]; then
echo "$ctid"
return
fi
fi
if command -v hostnamectl >/dev/null 2>&1; then
ctid=$(hostnamectl hostname 2>/dev/null || true)
if [[ "$ctid" =~ ^[0-9]+$ ]]; then
echo "$ctid"
return
fi
fi
if command -v findmnt >/dev/null 2>&1; then
local mount_src
mount_src=$(findmnt -no SOURCE / 2>/dev/null || true)
if [[ "$mount_src" =~ -([0-9]+)-disk ]]; then
echo "${BASH_REMATCH[1]}"
return
fi
fi
if command -v df >/dev/null 2>&1; then
local root_src
root_src=$(df -P / 2>/dev/null | awk 'NR==2 {print $1}')
if [[ "$root_src" =~ -([0-9]+)-disk ]]; then
echo "${BASH_REMATCH[1]}"
return
fi
fi
}
auto_detect_container_environment() {
if [[ "$IN_CONTAINER" == "true" ]]; then
if [[ -z "$DETECTED_CTID" ]]; then
DETECTED_CTID=$(detect_lxc_ctid 2>/dev/null || true)
fi
return
fi
local virt_type=""
if command -v systemd-detect-virt >/dev/null 2>&1; then
virt_type=$(systemd-detect-virt --container 2>/dev/null || true)
if [[ -n "$virt_type" && "$virt_type" != "none" ]]; then
IN_CONTAINER=true
if [[ "$virt_type" == "docker" ]]; then
IN_DOCKER=true
fi
fi
fi
if [[ "$IN_CONTAINER" != "true" ]]; then
if [[ -f /.dockerenv ]] || grep -qa docker /proc/1/cgroup 2>/dev/null || grep -qa docker /proc/self/cgroup 2>/dev/null; then
IN_CONTAINER=true
IN_DOCKER=true
elif grep -qaE '(lxc|machine-lxc)' /proc/1/cgroup 2>/dev/null; then
IN_CONTAINER=true
elif [[ -r /proc/1/environ ]] && grep -qa 'container=lxc' /proc/1/environ 2>/dev/null; then
IN_CONTAINER=true
fi
fi
if [[ "$IN_CONTAINER" == "true" ]]; then
if [[ -z "$DETECTED_CTID" ]]; then
DETECTED_CTID=$(detect_lxc_ctid 2>/dev/null || true)
fi
fi
}
handle_install_interrupt() {
echo ""
print_error "Installation cancelled"
if [[ -n "$CURRENT_INSTALL_CTID" ]] && [[ "$CONTAINER_CREATED_FOR_CLEANUP" == "true" ]]; then
print_info "Cleaning up container $CURRENT_INSTALL_CTID..."
pct stop "$CURRENT_INSTALL_CTID" 2>/dev/null || true
sleep 2
pct destroy "$CURRENT_INSTALL_CTID" 2>/dev/null || true
fi
exit 1
}
# Wrapper for systemctl commands that might hang in unprivileged containers
safe_systemctl() {
local action="$1"
shift
timeout 5 systemctl "$action" "$@" 2>/dev/null || {
if [[ "$action" == "daemon-reload" ]]; then
# daemon-reload hanging is common in unprivileged containers, silent fail is OK
return 0
elif [[ "$action" == "start" || "$action" == "enable" ]]; then
print_info "Note: systemctl $action failed (may be in unprivileged container)"
return 1
else
return 1
fi
}
}
# A failed/timed-out is-active query is not evidence that replacement is safe.
# Require an explicit stopped state, and read it back after a successful stop.
# Do not restart after an uncertain stop: the operator must reconcile that state.
stop_pulse_for_replacement() {
local service_name="$1"
local state=""
if ! state=$(timeout 5 systemctl show "$service_name" --property=ActiveState --value 2>/dev/null); then
print_error "Could not read Pulse service state ($service_name); refusing binary replacement"
return 1
fi
case "$state" in
inactive) return 0 ;;
active) PULSE_WAS_ACTIVE="true" ;;
failed) ;; # A failed unit can retain processes; stop it without claiming prior liveness.
*)
print_error "Pulse service state is not settled ($service_name); refusing binary replacement"
return 1
;;
esac
print_info "Stopping existing Pulse service ($service_name)..."
if ! safe_systemctl stop "$service_name"; then
print_error "Could not stop Pulse ($service_name); refusing binary replacement. Check service state before retrying."
return 1
fi
if ! state=$(timeout 5 systemctl show "$service_name" --property=ActiveState --value 2>/dev/null); then
print_error "Could not confirm Pulse stopped ($service_name); refusing binary replacement"
return 1
fi
case "$state" in
inactive) return 0 ;;
*)
print_error "Pulse has not reached a stopped state ($service_name); refusing binary replacement"
return 1
;;
esac
}
# Removal is destructive: neither an is-active error nor a successful stop
# proves the process is gone. Timers and in-flight updaters must be quiescent
# too, before deleting their executable or the server's persistent files.
confirm_pulse_unit_removable() {
local unit="$1"
local load_state="" active_state="" unit_file_state=""
if ! load_state=$(timeout -k 1 5 systemctl show "$unit" --property=LoadState --value 2>/dev/null) ||
! active_state=$(timeout -k 1 5 systemctl show "$unit" --property=ActiveState --value 2>/dev/null); then
print_error "Cannot confirm service state ($unit); refusing removal. Files are preserved; reconcile service state before retrying."
return 1
fi
if [[ "$active_state" != "inactive" ]]; then
print_error "Unit is not confirmed inactive ($unit); refusing removal. Files are preserved; reconcile service state before retrying."
return 1
fi
[[ "$load_state" == "not-found" ]] && return 0
case "$load_state" in
loaded|masked) ;;
*) print_error "Unknown unit load state ($unit); refusing removal"; return 1 ;;
esac
if ! unit_file_state=$(timeout -k 1 5 systemctl show "$unit" --property=UnitFileState --value 2>/dev/null); then
print_error "Cannot confirm unit disabled ($unit); refusing removal"
return 1
fi
case "$unit_file_state" in
disabled|static|indirect|masked|masked-runtime) return 0 ;;
*) print_error "Unit is not confirmed disabled ($unit); refusing removal"; return 1 ;;
esac
}
stop_pulse_unit_for_removal() {
local unit="$1"
local load_state="" active_state=""
if ! load_state=$(timeout -k 1 5 systemctl show "$unit" --property=LoadState --value 2>/dev/null) ||
! active_state=$(timeout -k 1 5 systemctl show "$unit" --property=ActiveState --value 2>/dev/null); then
print_error "Cannot inspect unit ($unit); refusing removal"
return 1
fi
if [[ "$load_state" == "not-found" && "$active_state" == "inactive" ]]; then
return 0
fi
case "$load_state" in
loaded|masked) ;;
*) print_error "Unknown unit load state ($unit); refusing removal"; return 1 ;;
esac
case "$active_state" in
inactive) ;;
active|failed)
if ! timeout -k 1 5 systemctl stop "$unit"; then
print_error "Cannot stop unit ($unit); refusing removal. Reconcile service state before retrying."
return 1
fi
;;
*) print_error "Unsettled unit state ($unit); refusing removal"; return 1 ;;
esac
if ! timeout -k 1 5 systemctl disable "$unit"; then
print_error "Cannot disable unit ($unit); refusing removal"
return 1
fi
confirm_pulse_unit_removable "$unit"
}
quiesce_pulse_for_removal() {
local service_name="$1" unit=""
local units=("$UPDATE_TIMER_UNIT" "$UPDATE_SERVICE_UNIT" "${service_name%.service}.service")
if [[ "$SERVICE_NAME_EXPLICIT" != "true" ]]; then
units+=(pulse.service pulse-backend.service)
fi
# A full uninstall also removes the local legacy footprint. Refuse before
# any server deletion if that footprint cannot be safely quiesced.
if local_sensor_proxy_present; then
units+=(pulse-sensor-proxy-selfheal.timer pulse-sensor-cleanup.path
pulse-sensor-proxy-selfheal.service pulse-sensor-cleanup.service pulse-sensor-proxy.service)
fi
for unit in "${units[@]}"; do
stop_pulse_unit_for_removal "$unit" || return 1
done
# Reconcile all targets again, not just the last one stopped. An earlier
# unit that reactivated/re-enabled during this sequence blocks deletion.
for unit in "${units[@]}"; do
confirm_pulse_unit_removable "$unit" || return 1
done
}
# Called only after a confirmed stop and a failed atomic rename. The previous
# executable is still in place. An uncertain stop never reaches this recovery,
# and an intentionally inactive/failed service must not be started for the user.
recover_pulse_after_failed_replacement() {
local service_name="$1"
[[ "$PULSE_WAS_ACTIVE" == "true" ]] || return 0
if safe_systemctl start "$service_name" && wait_for_service_active "$service_name" 20; then
PULSE_WAS_ACTIVE="false"
print_info "Previous Pulse binary is running again; the update failed"
else
print_error "The update failed and the previous Pulse service could not be confirmed running. Check systemctl status $service_name before retrying."
return 1
fi
}
# Read the unit-file inventory for exact unit names. systemd exits 1 and prints
# nothing at all, on stdout or stderr, when none of the named unit files exist,
# which is every fresh host. That is an empty inventory. A timeout, a signal,
# another failed status, or exit 1 with output or a message remains a failure.
# Capture both streams in the same read:
# a later silent sample must never discard an earlier diagnostic.
unit_file_inventory() {
local out="" rc=0
# Keep trailing newlines: an emitted blank line is not a silent exit 1.
out=$(LC_ALL=C timeout -k 1 5 systemctl list-unit-files --no-legend --no-pager --full -- "$@" 2>&1; rc=$?; printf '.'; exit "$rc") || rc=$?
out="${out%.}"
if (( rc == 1 )) && [[ -z "$out" ]]; then
rc=0
fi
printf '%s' "$out"
return "$rc"
}
# Detect existing service name (pulse or pulse-backend)
detect_service_name() {
if [[ "$SERVICE_NAME_EXPLICIT" == "true" ]]; then
echo "$SERVICE_NAME"
return
fi
if ! command -v systemctl >/dev/null 2>&1; then
echo "$DEFAULT_SERVICE_NAME"
return
fi
local unit_files="" unit="" state="" preset="" extra=""
local seen_backend=false seen_pulse=false
# grep -q closes a full inventory early: systemctl can report Broken pipe,
# and pipefail then hides a real pulse-backend unit behind the default.
# Consume one bounded inventory of the two exact historical names instead.
if ! unit_files=$(unit_file_inventory pulse-backend.service pulse.service); then
print_error "Cannot identify the Pulse systemd service: unit inventory failed. Reconcile service state before retrying." >&2
return 1
fi
while read -r unit state preset extra; do
[[ -n "$unit" ]] || continue
if [[ "$unit" == pulse-backend.service && "$seen_backend" == false ]]; then
seen_backend=true
elif [[ "$unit" == pulse.service && "$seen_pulse" == false ]]; then
seen_pulse=true
else
print_error "Cannot identify the Pulse systemd service: unexpected unit inventory. No default service was selected." >&2
return 1
fi
case "$state" in
enabled|enabled-runtime|disabled|static|indirect|linked|linked-runtime|alias|generated|transient|masked|masked-runtime) ;;
*)
print_error "Cannot identify the Pulse systemd service: unknown unit state. No default service was selected." >&2
return 1 ;;
esac
if [[ -n "$extra" ]]; then
print_error "Cannot identify the Pulse systemd service: malformed unit inventory. No default service was selected." >&2
return 1
fi
done <<< "$unit_files"
if [[ "$seen_backend" == true ]]; then
echo "pulse-backend"
else
echo "$DEFAULT_SERVICE_NAME" # A successful empty read permits a new installation.
fi
}
update_timer_exists() {
command -v systemctl >/dev/null 2>&1 || return 1
local unit_files=""
# Rows also contain enablement/preset columns. Match the exact first field,
# not the whole row or a regex, and keep long custom unit names untruncated.
# A failed inventory must not admit even plausible partial stdout.
if ! unit_files=$(LC_ALL=C timeout -k 1 5 systemctl list-unit-files --no-legend --no-pager --full -- "$UPDATE_TIMER_UNIT" 2>/dev/null); then
return 1
fi
awk -v unit="$UPDATE_TIMER_UNIT" '$1 == unit { found = 1 } END { exit !found }' <<< "$unit_files"
}
update_timer_enabled() {
command -v systemctl >/dev/null 2>&1 && systemctl is-enabled --quiet "$UPDATE_TIMER_UNIT" 2>/dev/null
}
current_frontend_port() {
local port="${FRONTEND_PORT:-7655}"
if [[ -z "${FRONTEND_PORT:-}" ]] && [[ -f "/etc/systemd/system/$SERVICE_NAME.service" ]]; then
port=$(grep -oP 'FRONTEND_PORT=\K\d+' "/etc/systemd/system/$SERVICE_NAME.service" 2>/dev/null || echo "7655")
fi
printf '%s\n' "${port:-7655}"
}
# Functions
print_header() {
echo -e "${BLUE}=================================================${NC}"
echo -e "${BLUE} Pulse Installation Script${NC}"
echo -e "${BLUE}=================================================${NC}"
echo
}
# Safe read function that works with or without TTY
safe_read() {
local prompt="$1"
local var_name="$2"
shift 2
local read_args="$@" # Allow passing additional args like -n 1
# When script is piped (curl | bash), stdin is the pipe, not the terminal
# We need to read from /dev/tty for user input
if [[ -t 0 ]]; then
# stdin is a terminal, read normally
echo -n "$prompt"
IFS= read -r $read_args "$var_name"
return 0
else
# stdin is not a terminal (piped), try /dev/tty if available
if { exec 3< /dev/tty; } 2>/dev/null; then
# /dev/tty is available and usable
echo -n "$prompt"
IFS= read -r $read_args "$var_name" <&3
exec 3<&-
return 0
else
# No TTY available at all - truly non-interactive
# Don't try to read from piped stdin as it will hang
return 1
fi
fi
}
# Wrapper that handles safe_read with automatic error handling
safe_read_with_default() {
local prompt="$1"
local var_name="$2"
local default_value="$3"
shift 3
local read_args="$@"
# Temporarily disable errexit
set +e
safe_read "$prompt" "$var_name" $read_args
local read_result=$?
set -e
if [[ $read_result -ne 0 ]]; then
# Failed to read - use default
eval "$var_name='$default_value'"
# Only print default message in truly non-interactive mode
if [[ ! -t 0 ]] && [[ -n "$default_value" ]]; then
print_info "Using default: $default_value"
fi
return 0 # Return success since we handled it with a default
fi
# Check if empty and use default
local current_value
eval "current_value=\$$var_name"
if [[ -z "$current_value" ]]; then
eval "$var_name='$default_value'"
fi
return 0
}
# A version change is not consent to unattended updates. Keep the existing
# choice (including a disabled timer) and require an affirmative answer when
# offering updates on an installation without an enabled configuration.
offer_existing_auto_updates() {
if [[ "$AUTO_UPDATE_CHOICE_EXPLICIT" == "true" ]] || [[ "$ENABLE_AUTO_UPDATES" == "true" ]] || [[ "$IN_DOCKER" == "true" ]]; then
return 0
fi
local prompt_reason=""
if ! update_timer_exists; then
prompt_reason="missing"
elif [[ -f "$CONFIG_DIR/system.json" ]] && grep -Eq '"autoUpdateEnabled"[[:space:]]*:[[:space:]]*false' "$CONFIG_DIR/system.json"; then
prompt_reason="disabled"
fi
[[ -n "$prompt_reason" ]] || return 0
echo
if [[ "$prompt_reason" == "disabled" ]]; then
echo -e "${YELLOW}Auto-updates are currently disabled.${NC}"
else
echo "Automatic updates are not configured."
fi
echo "Pulse can automatically install stable updates daily (between 2-6 AM)"
echo "Leave this disabled to keep control of version changes and rollbacks."
local enable_updates=""
safe_read_with_default "Enable auto-updates? [y/N]: " enable_updates "n"
if [[ "$enable_updates" =~ ^([Yy]|[Yy][Ee][Ss])$ ]]; then
ENABLE_AUTO_UPDATES=true
fi
return 0
}
wait_for_pulse_ready() {
local pulse_url="$1"
local retries="${2:-60}"
local delay="${3:-1}"
if [[ -z "$pulse_url" ]] || ! command -v curl >/dev/null 2>&1; then
return 0
fi
local api_endpoint="${pulse_url%/}/api/health"
print_info "Waiting for Pulse API at ${api_endpoint}..."
for attempt in $(seq 1 "$retries"); do
if curl -fsS --max-time 2 "$api_endpoint" >/dev/null 2>&1; then
print_info "Pulse API is reachable"
return 0
fi
sleep "$delay"
done
print_warn "Pulse API did not respond after ${retries}s; continuing anyway"
return 1
}
print_error() {
echo -e "${RED}[ERROR] $1${NC}" >&2
}
print_success() {
echo -e "${GREEN}[SUCCESS] $1${NC}"
}
print_info() {
echo -e "${YELLOW}[INFO] $1${NC}"
}
print_warn() {
echo -e "${YELLOW}[WARN] $1${NC}"
}
ensure_debian_template() {
if [[ -n "$DEBIAN_TEMPLATE" ]]; then
return
fi
local candidate=""
if command -v pveam >/dev/null 2>&1; then
local available_output=""
available_output=$(pveam available --section system 2>/dev/null || true)
candidate=$(echo "$available_output" | awk '$2 ~ /^debian-12-standard_[0-9]+\.[0-9]+-[0-9]+_amd64\.tar\.zst$/ {print $2}' | sort -V | tail -1)
if [[ -z "$candidate" ]]; then
available_output=$(pveam available 2>/dev/null || true)
candidate=$(echo "$available_output" | awk '$2 ~ /^debian-12-standard_[0-9]+\.[0-9]+-[0-9]+_amd64\.tar\.zst$/ {print $2}' | sort -V | tail -1)
fi
fi
if [[ -z "$candidate" ]]; then
candidate="$DEBIAN_TEMPLATE_FALLBACK"
print_info "Using fallback Debian template version: $candidate"
else
print_info "Detected latest Debian template version: $candidate"
fi
DEBIAN_TEMPLATE="$candidate"
}
check_root() {
if [[ $EUID -ne 0 ]]; then
print_error "This script must be run as root"
exit 1
fi
}
# V3 is deprecated - no longer checking for it
detect_os() {
if [[ -f /etc/os-release ]]; then
. /etc/os-release
OS=$ID
VER=$VERSION_ID
else
print_error "Cannot detect OS"
exit 1
fi
}
# Restore SELinux contexts for installed binaries
# On SELinux-enforcing systems (Fedora, RHEL, CentOS), binaries in non-standard
# locations need proper security contexts for systemd to execute them.
restore_selinux_contexts() {
# Check if SELinux is available and enforcing
if ! command -v getenforce >/dev/null 2>&1; then
return 0 # SELinux not installed
fi
if [[ "$(getenforce 2>/dev/null)" != "Enforcing" ]]; then
return 0 # SELinux not enforcing
fi
# restorecon is the proper way to fix SELinux contexts
if command -v restorecon >/dev/null 2>&1; then
print_info "Restoring SELinux contexts for installed binaries..."
restorecon -Rv "$INSTALL_DIR/bin/" >/dev/null 2>&1 || true
restorecon -v "$BINARY_LINK_PATH" >/dev/null 2>&1 || true
print_success "SELinux contexts restored"
else
# Fallback to chcon if restorecon isn't available
if command -v chcon >/dev/null 2>&1; then
print_info "Setting SELinux contexts for installed binaries..."
find "$INSTALL_DIR/bin/" -type f -executable -exec chcon -t bin_t {} \; 2>/dev/null || true
chcon -h -t bin_t "$BINARY_LINK_PATH" 2>/dev/null || true
fi
fi
}
check_proxmox_host() {
# Check if this is a Proxmox VE host
if command -v pvesh &> /dev/null && [[ -d /etc/pve ]]; then
return 0
fi
return 1
}
check_docker_environment() {
# Detect if we're running inside Docker (multiple detection methods)
if [[ -f /.dockerenv ]] || \
grep -q docker /proc/1/cgroup 2>/dev/null || \
grep -q docker /proc/self/cgroup 2>/dev/null || \
[[ -f /run/.containerenv ]] || \
[[ "${container:-}" == "docker" ]]; then
# The install.sh smoke gate exercises the documented systemd install
# path inside a privileged systemd-in-Docker test container. That is
# the one legitimate case for bypassing this guard.
if [[ "${PULSE_INSTALL_ALLOW_DOCKER:-}" == "1" ]]; then
print_warn "Docker environment detected but PULSE_INSTALL_ALLOW_DOCKER=1 set; continuing."
return 0
fi
print_error "Docker environment detected"
echo "Please use the Docker image directly: docker run -d -p 7655:7655 $(repo_docker_image_ref latest)"
echo "See: $(repo_docker_docs_url)"
exit 1
fi
}
repo_web_url() {
printf 'https://github.com/%s\n' "$GITHUB_REPO"
}
resolve_latest_release_tag_for_channel() {
local channel="${1:-stable}"
local releases_json=""
local release=""
[[ "$channel" == stable || "$channel" == rc ]] || return 1
if command -v timeout >/dev/null 2>&1; then
if ! releases_json=$(timeout 15 curl -fsSL --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases" 2>/dev/null); then
releases_json=""
fi
else
if ! releases_json=$(curl -fsSL --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases" 2>/dev/null); then
releases_json=""
fi
fi
release=$(latest_pulse_release_tag_from_json "$releases_json" "$channel" 2>/dev/null || true)
if [[ -n "$release" ]]; then
printf '%s\n' "$release"
return 0
fi
release=$(get_latest_release_from_redirect 2>/dev/null || true)
if [[ "$channel" == rc ]] && is_pulse_release_tag "$release"; then
printf '%s\n' "$release"
return 0
elif [[ "$channel" == stable ]] && is_stable_release_tag "$release"; then
printf '%s\n' "$release"
return 0
fi
return 1
}
repo_release_docs_ref() {
if [[ -n "${FORCE_VERSION:-}" ]]; then
printf '%s\n' "$FORCE_VERSION"
return 0
fi
if [[ -n "${LATEST_RELEASE:-}" ]]; then
printf '%s\n' "$LATEST_RELEASE"
return 0
fi
local channel="${FORCE_CHANNEL:-${UPDATE_CHANNEL:-stable}}"
resolve_latest_release_tag_for_channel "$channel"
}
repo_docs_url_for_path() {
local docs_path="$1"
local ref=""
ref=$(repo_release_docs_ref 2>/dev/null || true)
if [[ -n "$ref" ]]; then
printf '%s/blob/%s/%s\n' "$(repo_web_url)" "$ref" "$docs_path"
return 0
fi
printf '%s/releases/latest\n' "$(repo_web_url)"
}
repo_docker_docs_url() {
repo_docs_url_for_path "docs/DOCKER.md"
}
repo_docker_image_ref() {
local tag="${1:-latest}"
printf '%s:%s\n' "$DOCKER_IMAGE_REPO" "$tag"
}
# Discover host bridge interfaces, including Open vSwitch bridges.
detect_network_bridges() {
local preferred=()
local fallback=()
local ip_output=""
ip_output=$(ip -o link show type bridge 2>/dev/null || true)
if [[ -n "$ip_output" ]]; then
while IFS= read -r line; do
[[ -z "$line" ]] && continue
local iface=${line#*: }
iface=${iface%%:*}
iface=${iface%%@*}
case "$iface" in
docker*|br-*|virbr*|cni*|cilium*|flannel*|kube-*|veth*|tap*|ovs-system)
continue
;;
vmbr*|vnet*|ovs*)
preferred+=("$iface")
;;
*)
fallback+=("$iface")
;;
esac
done <<< "$ip_output"
fi
if command -v ovs-vsctl >/dev/null 2>&1; then
local ovs_output=""
ovs_output=$(ovs-vsctl list-br 2>/dev/null || true)
if [[ -n "$ovs_output" ]]; then
while IFS= read -r iface; do
[[ -z "$iface" ]] && continue
case "$iface" in
docker*|br-*|virbr*|cni*|cilium*|flannel*|kube-*|veth*|tap*|ovs-system)
continue
;;
vmbr*|vnet*|ovs*)
preferred+=("$iface")
;;
*)
fallback+=("$iface")
;;
esac
done <<< "$ovs_output"
fi
fi
local combined=()
if [[ ${#preferred[@]} -gt 0 ]]; then
combined=("${preferred[@]}")
fi
if [[ ${#fallback[@]} -gt 0 ]]; then
combined+=("${fallback[@]}")
fi
if [[ ${#combined[@]} -gt 0 ]]; then
printf '%s\n' "${combined[@]}" | awk '!seen[$0]++' | paste -sd' ' -
fi
}
is_bridge_interface() {
local iface="$1"
[[ -z "$iface" ]] && return 1
local bridge_info=""
bridge_info=$(ip -o link show "$iface" type bridge 2>/dev/null || true)
if [[ -n "$bridge_info" ]]; then
return 0
fi
if command -v ovs-vsctl >/dev/null 2>&1 && ovs-vsctl br-exists "$iface" &>/dev/null; then
return 0
fi
return 1
}
cleanup_stale_sensor_proxy_mounts() {
# The v4 installer added mount entries for /run/pulse-sensor-proxy to LXC
# container configs. In v5+, the sensor proxy was removed but these entries
# persist. After a host reboot, /run (tmpfs) is wiped and the mount source
# disappears, causing Proxmox to refuse to start the container.
# This function detects and removes those stale entries automatically.
command -v pct >/dev/null 2>&1 || return 0
local ctids
ctids=$(pct list 2>/dev/null | tail -n +2 | awk '{print $1}')
[ -z "$ctids" ] && return 0
for ctid in $ctids; do
local conf="/etc/pve/lxc/${ctid}.conf"
[ -f "$conf" ] || continue
# Check if this container has any pulse-sensor-proxy references
grep -q "pulse-sensor-proxy" "$conf" 2>/dev/null || continue
echo " Cleaning stale sensor-proxy mount from container $ctid..."
# Find the first [snapshot] line to avoid modifying snapshot sections
local snap_line
snap_line=$(grep -n '^\[' "$conf" 2>/dev/null | head -1 | cut -d: -f1 || true)
# Determine which lines to examine (main config only, before snapshots)
local main_section
if [ -n "$snap_line" ]; then
main_section=$(head -n "$((snap_line - 1))" "$conf")
else
main_section=$(cat "$conf")
fi
# Check if container is running (we may need to stop it for pct set)
local was_running=false
local stopped_by_cleanup=false
if pct status "$ctid" 2>/dev/null | grep -q "running"; then
was_running=true
fi
# Remove mp<N> entries referencing pulse-sensor-proxy
local mp_keys
mp_keys=$(echo "$main_section" | grep -E '^mp[0-9]+:.*pulse-sensor-proxy' | cut -d: -f1 || true)
for mp_key in $mp_keys; do
if $was_running && ! $stopped_by_cleanup; then
if timeout 15 pct stop "$ctid" 2>/dev/null; then
stopped_by_cleanup=true
sleep 2
fi
fi
if ! timeout 10 pct set "$ctid" -delete "$mp_key" 2>/dev/null; then
# Fallback: direct sed deletion
if [ -n "$snap_line" ]; then
sed -i "1,$((snap_line - 1))s/^${mp_key}:.*pulse-sensor-proxy.*$//" "$conf"
else
sed -i "/^${mp_key}:.*pulse-sensor-proxy/d" "$conf"
fi
fi
done
# Remove lxc.mount.entry lines referencing pulse-sensor-proxy
if echo "$main_section" | grep -q "lxc.mount.entry.*pulse-sensor-proxy"; then
if [ -n "$snap_line" ]; then
sed -i "1,$((snap_line - 1)){/lxc\.mount\.entry.*pulse-sensor-proxy/d}" "$conf"
else
sed -i "/lxc\.mount\.entry.*pulse-sensor-proxy/d" "$conf"
fi
fi
# Remove any blank lines left behind
sed -i '/^$/d' "$conf"
# Restart container only if we stopped it during cleanup
if $stopped_by_cleanup; then
timeout 30 pct start "$ctid" 2>/dev/null || true
fi
echo " Cleaned sensor-proxy mount from container $ctid"
done
}
create_lxc_container() {
# Only files created by this invocation belong to its EXIT cleanup. Local
# --archive inputs and a caller-supplied installer must remain untouched.
local container_script_temp_dir=""
local container_archive_source=""
local container_archive_dest=""
local container_archive_temp=false
local archive_requested=false
trap 'cleanup_container_install_inputs "${container_script_temp_dir:-}" "${container_archive_source:-}" "${container_archive_temp:-false}"' EXIT
CURRENT_INSTALL_CTID=""
CONTAINER_CREATED_FOR_CLEANUP=false
trap handle_install_interrupt INT TERM
print_header
echo "Proxmox VE detected. Installing Pulse in a container."
echo
# Clean up stale v4 sensor-proxy mount entries that prevent LXC start after reboot
cleanup_stale_sensor_proxy_mounts
# Check if we can interact with the user
# Try to read from /dev/tty to test if we have terminal access
if test -e /dev/tty && (echo -n "" > /dev/tty) 2>/dev/null; then
# We have terminal access, show menu
echo "Installation mode:"
echo " 1) Quick (recommended)"
echo " 2) Advanced"
echo " 3) Cancel"
safe_read_with_default "Select [1-3]: " mode "1"
else
# No terminal access - truly non-interactive
echo "Non-interactive mode detected. Using Quick installation."
mode="1"
fi
case $mode in
3)
print_info "Installation cancelled"
exit 0
;;
2)
ADVANCED_MODE=true
;;
*)
ADVANCED_MODE=false
;;
esac
# A Proxmox-host LXC install creates a brand-new Pulse instance inside the
# container. Host-local Pulse config must not silently switch that fresh
# install onto the prerelease channel unless the operator explicitly asked
# for it via --rc or --version.
IGNORE_CONFIGURED_UPDATE_CHANNEL=true
# Get next available container ID from Proxmox
local CTID=$(pvesh get /cluster/nextid 2>/dev/null || echo "100")
# If pvesh failed, fallback to manual search
if [[ "$CTID" == "100" ]]; then
while pct status $CTID &>/dev/null 2>&1 || qm status $CTID &>/dev/null 2>&1; do
((CTID++))
done
fi
if [[ "$ADVANCED_MODE" == "true" ]]; then
echo
# Ask for port configuration
safe_read_with_default "Frontend port [7655]: " frontend_port "7655"
if [[ ! "$frontend_port" =~ ^[0-9]+$ ]] || [[ "$frontend_port" -lt 1 ]] || [[ "$frontend_port" -gt 65535 ]]; then
print_error "Invalid port number. Using default port 7655."
frontend_port=7655
fi
auto_updates_flag=""
if [[ "$AUTO_UPDATE_CHOICE_EXPLICIT" == "true" ]]; then
if [[ "$ENABLE_AUTO_UPDATES" == "true" ]]; then
auto_updates_flag="--enable-auto-updates"
else
auto_updates_flag="--disable-auto-updates"
fi
fi
if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
echo
print_info "Skipping auto-update configuration: source builds don't support automatic release updates."
elif [[ "$AUTO_UPDATE_CHOICE_EXPLICIT" != "true" ]]; then
echo
# Ask about auto-updates
echo "Enable automatic updates?"
echo "Pulse can automatically install stable updates daily (between 2-6 AM)"
safe_read_with_default "Enable auto-updates? [y/N]: " enable_updates "n"
if [[ "$enable_updates" =~ ^[Yy]$ ]]; then
auto_updates_flag="--enable-auto-updates"
ENABLE_AUTO_UPDATES=true # Set the global variable for host installations
fi
fi
echo
# Try to get cluster-wide IDs, fall back to local
# Disable error exit for this section as commands may fail on fresh PVE installs without VMs
set +e
local USED_IDS=""
if command -v pvesh &>/dev/null; then
# Parse JSON output using grep and sed (works without jq)
USED_IDS=$(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | \
grep -o '"vmid":[0-9]*' | \
sed 's/"vmid"://' | \
sort -n | \
paste -sd',' -)
fi
if [[ -z "$USED_IDS" ]]; then
# Fallback: get local containers and VMs
local LOCAL_CTS=$(pct list 2>/dev/null | tail -n +2 | awk '{print $1}' | sort -n)
local LOCAL_VMS=$(qm list 2>/dev/null | tail -n +2 | awk '{print $1}' | sort -n)
USED_IDS=$(echo -e "$LOCAL_CTS\n$LOCAL_VMS" | grep -v '^$' | sort -n | paste -sd',' -)
fi
# Re-enable error exit
set -e
echo "Container/VM IDs in use: ${USED_IDS:-none}"
safe_read_with_default "Container ID [$CTID]: " custom_ctid "$CTID"
if [[ "$custom_ctid" != "$CTID" ]] && [[ "$custom_ctid" =~ ^[0-9]+$ ]]; then
# Check if ID is in use
if pct status $custom_ctid &>/dev/null 2>&1 || qm status $custom_ctid &>/dev/null 2>&1; then
print_error "Container/VM ID $custom_ctid is already in use"
exit 1
fi
# Also check cluster if possible
if command -v pvesh &>/dev/null; then
if pvesh get /cluster/resources --type vm 2>/dev/null | jq -e ".[] | select(.vmid == $custom_ctid)" &>/dev/null; then
print_error "Container/VM ID $custom_ctid is already in use in the cluster"
exit 1
fi
fi
CTID=$custom_ctid
fi
fi
print_info "Using container ID: $CTID"
CURRENT_INSTALL_CTID="$CTID"
if [[ "$ADVANCED_MODE" == "true" ]]; then
echo
echo -e "${BLUE}Advanced Mode - Customize all settings${NC}"
echo -e "${YELLOW}Defaults shown are suitable for monitoring 10-20 nodes${NC}"
echo
# Container settings
safe_read_with_default "Container hostname [pulse]: " hostname "pulse"
safe_read_with_default "Memory (MB) [1024]: " memory "1024"
safe_read_with_default "Disk size (GB) [4]: " disk "4"
safe_read_with_default "CPU cores [2]: " cores "2"
safe_read_with_default "CPU limit (0=unlimited) [2]: " cpulimit "2"
safe_read_with_default "Swap (MB) [256]: " swap "256"
safe_read_with_default "Start on boot? [Y/n]: " onboot "Y" -n 1 -r
echo
if [[ "$onboot" =~ ^[Nn]$ ]]; then
onboot=0
else
onboot=1
fi
safe_read_with_default "Enable firewall? [Y/n]: " firewall "Y" -n 1 -r
echo
if [[ "$firewall" =~ ^[Nn]$ ]]; then
firewall=0
else
firewall=1
fi
safe_read_with_default "Unprivileged container? [Y/n]: " unprivileged "Y" -n 1 -r
echo
if [[ "$unprivileged" =~ ^[Nn]$ ]]; then
unprivileged=0
else
unprivileged=1
fi
else
# Quick mode - just use defaults silently
# Use optimized defaults
hostname="pulse"
memory=1024
disk=4
cores=2
cpulimit=2
swap=256
onboot=1
firewall=1
unprivileged=1
# Ask for port even in quick mode
echo
safe_read_with_default "Port [7655]: " frontend_port "7655"
if [[ ! "$frontend_port" =~ ^[0-9]+$ ]] || [[ "$frontend_port" -lt 1 ]] || [[ "$frontend_port" -gt 65535 ]]; then
print_info "Using default: 7655"
frontend_port=7655
fi
auto_updates_flag=""
if [[ "$AUTO_UPDATE_CHOICE_EXPLICIT" == "true" ]]; then
if [[ "$ENABLE_AUTO_UPDATES" == "true" ]]; then
auto_updates_flag="--enable-auto-updates"
else
auto_updates_flag="--disable-auto-updates"
fi
fi
if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
echo
print_info "Skipping auto-update configuration: source builds don't support automatic release updates."
elif [[ "$AUTO_UPDATE_CHOICE_EXPLICIT" != "true" ]]; then
# Quick mode should ask about auto-updates too
echo
echo "Enable automatic updates?"
echo "Pulse can automatically install stable updates daily (between 2-6 AM)"
safe_read_with_default "Enable auto-updates? [y/N]: " enable_updates "n"
if [[ "$enable_updates" =~ ^[Yy]$ ]]; then
auto_updates_flag="--enable-auto-updates"
ENABLE_AUTO_UPDATES=true # Set the global variable for host installations
fi
fi
# Optional VLAN configuration - defaults to empty (no VLAN) for regular users
echo
safe_read_with_default "VLAN ID (press Enter for no VLAN): " vlan_id ""
if [[ -n "$vlan_id" ]]; then
# Validate VLAN ID (1-4094)
if [[ ! "$vlan_id" =~ ^[0-9]+$ ]] || [[ "$vlan_id" -lt 1 ]] || [[ "$vlan_id" -gt 4094 ]]; then
print_error "Invalid VLAN ID. Must be between 1 and 4094"
print_info "Proceeding without VLAN"
vlan_id=""
fi
fi
fi
# Get available network bridges
echo
print_info "Detecting available resources..."
# Get available bridges
local BRIDGES=$(detect_network_bridges)
# First try to find the default network interface (could be bridge or regular interface)
local DEFAULT_INTERFACE=$(ip route | grep default | head -1 | grep -oP 'dev \K\S+')
# Check if the default interface is a bridge
local DEFAULT_BRIDGE=""
if [[ -n "$DEFAULT_INTERFACE" ]]; then
if is_bridge_interface "$DEFAULT_INTERFACE"; then
# Default interface is a bridge, use it
DEFAULT_BRIDGE="$DEFAULT_INTERFACE"
fi
fi
# If no default bridge found, try to use the first available bridge
if [[ -z "$DEFAULT_BRIDGE" && -n "$BRIDGES" ]]; then
DEFAULT_BRIDGE=$(echo "$BRIDGES" | cut -d' ' -f1)
fi
# If still no bridge found, we'll need to ask the user
if [[ -z "$DEFAULT_BRIDGE" ]]; then
if [[ -n "$DEFAULT_INTERFACE" ]]; then
# We have a default interface but it's not a bridge
print_info "Default network interface is $DEFAULT_INTERFACE (not a bridge)"
fi
DEFAULT_BRIDGE="vmbr0" # Fallback suggestion only
fi
# Get available storage with usage info
local STORAGE_INFO=$(pvesm status -content rootdir 2>/dev/null | tail -n +2)
local DEFAULT_STORAGE=$(echo "$STORAGE_INFO" | awk '{print $1}' | head -1)
DEFAULT_STORAGE=${DEFAULT_STORAGE:-local-lvm}
if [[ "$ADVANCED_MODE" == "true" ]]; then
# Show available bridges
echo
if [[ -n "$BRIDGES" ]]; then
echo "Available network bridges:"
local bridge_array=($BRIDGES)
local default_idx=0
for i in "${!bridge_array[@]}"; do
local idx=$((i+1))
echo " $idx) ${bridge_array[$i]}"
if [[ "${bridge_array[$i]}" == "$DEFAULT_BRIDGE" ]]; then
default_idx=$idx
fi
done
set +e
safe_read "Select network bridge [${default_idx}]: " bridge_choice
local read_result=$?
set -e
if [[ $read_result -eq 0 ]]; then
bridge_choice=${bridge_choice:-$default_idx}
if [[ "$bridge_choice" =~ ^[0-9]+$ ]] && [[ "$bridge_choice" -ge 1 ]] && [[ "$bridge_choice" -le ${#bridge_array[@]} ]]; then
bridge="${bridge_array[$((bridge_choice-1))]}"
elif [[ -n "$bridge_choice" ]]; then
# User typed a bridge name directly
bridge="$bridge_choice"
else
bridge="$DEFAULT_BRIDGE"
fi
else
# Non-interactive, use default
bridge="$DEFAULT_BRIDGE"
fi
else
echo "No network bridges detected"
echo "You may need to create a Linux bridge or Open vSwitch bridge first (e.g., vmbr0)"
set +e
safe_read "Enter network bridge name: " bridge
set -e
fi
# Show available storage with usage details
echo
if [[ -n "$STORAGE_INFO" ]]; then
echo "Available storage pools:"
local storage_names=()
local default_idx=0
local idx=1
while IFS= read -r line; do
local storage_name storage_type avail_gb total_gb used_pct parsed_line
parsed_line=$(LC_NUMERIC=C awk '{printf "%s,%s,%.1f,%.1f,%s", $1, $2, $6/1048576, $4/1048576, $7}' <<< "$line")
[[ -z "$parsed_line" ]] && continue
IFS=',' read -r storage_name storage_type avail_gb total_gb used_pct <<< "$parsed_line" || continue
storage_names+=("$storage_name")
LC_ALL=C printf " %d) %-15s %-8s %6.1f GB free of %6.1f GB (%s used)\n" \
"$idx" "$storage_name" "$storage_type" "$avail_gb" "$total_gb" "$used_pct"
if [[ "$storage_name" == "$DEFAULT_STORAGE" ]]; then
default_idx=$idx
fi
((idx++))
done <<< "$STORAGE_INFO"
set +e
safe_read "Select storage pool [${default_idx}]: " storage_choice
local read_result=$?
set -e
if [[ $read_result -eq 0 ]]; then
storage_choice=${storage_choice:-$default_idx}
if [[ "$storage_choice" =~ ^[0-9]+$ ]] && [[ "$storage_choice" -ge 1 ]] && [[ "$storage_choice" -le ${#storage_names[@]} ]]; then
storage="${storage_names[$((storage_choice-1))]}"
elif [[ -n "$storage_choice" ]]; then
# User typed a storage name directly
storage="$storage_choice"
else
storage="$DEFAULT_STORAGE"
fi
else
# Non-interactive, use default
storage="$DEFAULT_STORAGE"
fi
else
echo " No storage pools found"
set +e
safe_read "Enter storage pool name [$DEFAULT_STORAGE]: " storage
set -e
storage=${storage:-$DEFAULT_STORAGE}
fi
safe_read_with_default "Static IP with CIDR (e.g. 198.51.100.100/24, leave empty for DHCP): " static_ip ""
# If static IP is provided, we need gateway
if [[ -n "$static_ip" ]]; then
# Validate IP format
if [[ ! "$static_ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then
print_error "Invalid IP format. Please use CIDR notation (e.g., 198.51.100.100/24)"
print_info "Using DHCP instead"
static_ip=""
else
safe_read_with_default "Gateway IP address: " gateway_ip ""
if [[ -z "$gateway_ip" ]]; then
# Try to guess gateway from the IP (use .1 of the subnet)
local ip_base="${static_ip%.*}"
gateway_ip="${ip_base}.1"
print_info "No gateway specified, using $gateway_ip"
elif [[ ! "$gateway_ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
print_error "Invalid gateway format. Using DHCP instead"
static_ip=""
gateway_ip=""
fi
fi
fi
safe_read_with_default "DNS servers (space-separated, empty for host settings): " nameserver ""
# VLAN configuration
safe_read_with_default "VLAN ID (leave empty for no VLAN): " vlan_id ""
if [[ -n "$vlan_id" ]]; then
# Validate VLAN ID (1-4094)
if [[ ! "$vlan_id" =~ ^[0-9]+$ ]] || [[ "$vlan_id" -lt 1 ]] || [[ "$vlan_id" -gt 4094 ]]; then
print_error "Invalid VLAN ID. Must be between 1 and 4094"
print_info "Proceeding without VLAN"
vlan_id=""
fi
fi
safe_read_with_default "Startup order [99]: " startup "99"
else
# Quick mode - but still need to verify critical settings
# Network bridge selection
echo
if [[ -n "$BRIDGES" ]]; then
echo "Available network bridges:"
local bridge_array=($BRIDGES)
local default_idx=0
for i in "${!bridge_array[@]}"; do
local idx=$((i+1))
echo " $idx) ${bridge_array[$i]}"
if [[ "${bridge_array[$i]}" == "$DEFAULT_BRIDGE" ]]; then
default_idx=$idx
fi
done
if [[ ${#bridge_array[@]} -eq 1 ]]; then
# Only one bridge available, use it
bridge="${bridge_array[0]}"
print_info "Using network bridge: $bridge"
else
set +e
safe_read "Select network bridge [${default_idx}]: " bridge_choice
local read_result=$?
set -e
if [[ $read_result -eq 0 ]]; then
bridge_choice=${bridge_choice:-$default_idx}
if [[ "$bridge_choice" =~ ^[0-9]+$ ]] && [[ "$bridge_choice" -ge 1 ]] && [[ "$bridge_choice" -le ${#bridge_array[@]} ]]; then
bridge="${bridge_array[$((bridge_choice-1))]}"
else
bridge="$DEFAULT_BRIDGE"
print_info "Invalid selection, using default: $bridge"
fi
else
# Non-interactive, use default
bridge="$DEFAULT_BRIDGE"
fi
fi
else
print_error "No network bridges detected on this system"
print_info "You may need to create a Linux bridge or Open vSwitch bridge first (e.g., vmbr0)"
set +e
safe_read "Enter network bridge name to use: " bridge
set -e
fi
# Storage selection
echo
if [[ -n "$STORAGE_INFO" ]]; then
echo "Available storage pools:"
# Create arrays for storage info
local storage_names=()
local storage_info_lines=()
local default_idx=0
local idx=1
while IFS= read -r line; do
local storage_name storage_type avail_gb total_gb used_pct parsed_line
parsed_line=$(LC_NUMERIC=C awk '{printf "%s,%s,%.1f,%.1f,%s", $1, $2, $6/1048576, $4/1048576, $7}' <<< "$line")
[[ -z "$parsed_line" ]] && continue
IFS=',' read -r storage_name storage_type avail_gb total_gb used_pct <<< "$parsed_line" || continue
storage_names+=("$storage_name")
LC_ALL=C printf " %d) %-15s %-8s %6.1f GB free of %6.1f GB (%s used)\n" \
"$idx" "$storage_name" "$storage_type" "$avail_gb" "$total_gb" "$used_pct"
if [[ "$storage_name" == "$DEFAULT_STORAGE" ]]; then
default_idx=$idx
fi
((idx++))
done <<< "$STORAGE_INFO"
if [[ ${#storage_names[@]} -eq 1 ]]; then
# Only one storage available, use it
storage="${storage_names[0]}"
print_info "Using storage pool: $storage"
else
set +e
safe_read "Select storage pool [${default_idx}]: " storage_choice
local read_result=$?
set -e
if [[ $read_result -eq 0 ]]; then
storage_choice=${storage_choice:-$default_idx}
if [[ "$storage_choice" =~ ^[0-9]+$ ]] && [[ "$storage_choice" -ge 1 ]] && [[ "$storage_choice" -le ${#storage_names[@]} ]]; then
storage="${storage_names[$((storage_choice-1))]}"
else
storage="$DEFAULT_STORAGE"
print_info "Invalid selection, using default: $storage"
fi
else
# Non-interactive, use default
storage="$DEFAULT_STORAGE"
fi
fi
else
print_error "No storage pools detected"
set +e
safe_read "Enter storage pool name to use: " storage
set -e
fi
static_ip=""
gateway_ip=""
nameserver=""
startup=99
fi
# Handle OS template selection
echo
if [[ "$ADVANCED_MODE" == "true" ]]; then
# Get ALL storages that can contain templates
local TEMPLATE_STORAGES=$(pvesm status -content vztmpl 2>/dev/null | tail -n +2 | awk '{print $1}' | paste -sd' ' -)
# Collect templates from ALL template-capable storages
local ALL_TEMPLATES=""
for tmpl_storage in $TEMPLATE_STORAGES; do
# pveam list output already includes the storage prefix in the path
local STORAGE_TEMPLATES=$(pveam list "$tmpl_storage" 2>/dev/null | tail -n +2 | awk '{print $1}' || true)
if [[ -n "$STORAGE_TEMPLATES" ]]; then
if [[ -n "$ALL_TEMPLATES" ]]; then
ALL_TEMPLATES="${ALL_TEMPLATES}\n${STORAGE_TEMPLATES}"
else
ALL_TEMPLATES="$STORAGE_TEMPLATES"
fi
fi
done
echo "Available OS templates across all storages:"
# Format templates with numbers
local TEMPLATES=$(echo -e "$ALL_TEMPLATES" | nl -w2 -s') ')
if [[ -n "$TEMPLATES" ]]; then
echo "$TEMPLATES"
echo
echo "Or download a new template:"
echo " d) Download Debian 12 (recommended)"
echo " u) Download Ubuntu 22.04 LTS"
echo
echo "Note: Pulse requires a Debian-based template (apt/systemd)."
echo
safe_read_with_default "Select template number or option [Enter for Debian 12]: " template_choice ""
if [[ -n "$template_choice" ]]; then
case "$template_choice" in
d|D)
# Find best storage for templates (prefer one with most free space)
local BEST_TEMPLATE_STORAGE=$(pvesm status -content vztmpl 2>/dev/null | tail -n +2 | sort -k6 -rn | head -1 | awk '{print $1}')
BEST_TEMPLATE_STORAGE=${BEST_TEMPLATE_STORAGE:-$storage}
ensure_debian_template
print_info "Downloading Debian 12 to storage '$BEST_TEMPLATE_STORAGE'..."
pveam download "$BEST_TEMPLATE_STORAGE" "$DEBIAN_TEMPLATE"
TEMPLATE="${BEST_TEMPLATE_STORAGE}:vztmpl/${DEBIAN_TEMPLATE}"
;;
u|U)
# Find best storage for templates (prefer one with most free space)
local BEST_TEMPLATE_STORAGE=$(pvesm status -content vztmpl 2>/dev/null | tail -n +2 | sort -k6 -rn | head -1 | awk '{print $1}')
BEST_TEMPLATE_STORAGE=${BEST_TEMPLATE_STORAGE:-$storage}
print_info "Downloading Ubuntu 22.04 to storage '$BEST_TEMPLATE_STORAGE'..."
pveam download "$BEST_TEMPLATE_STORAGE" ubuntu-22.04-standard_22.04-1_amd64.tar.zst
TEMPLATE="${BEST_TEMPLATE_STORAGE}:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst"
;;
[0-9]*)
# Extract the full template path from numbered list
TEMPLATE=$(echo -e "$ALL_TEMPLATES" | sed -n "${template_choice}p")
if [[ -n "$TEMPLATE" ]]; then
# Check if selected template is Alpine or other non-Debian based
if [[ "$TEMPLATE" =~ alpine|gentoo|arch|void ]]; then
print_warn "Selected template ($TEMPLATE) is not Debian-based."
print_warn "Pulse LXC installation requires apt and systemd."
print_info "Falling back to Debian 12..."
local BEST_TEMPLATE_STORAGE=$(pvesm status -content vztmpl 2>/dev/null | tail -n +2 | sort -k6 -rn | head -1 | awk '{print $1}')
BEST_TEMPLATE_STORAGE=${BEST_TEMPLATE_STORAGE:-$storage}
ensure_debian_template
TEMPLATE="${BEST_TEMPLATE_STORAGE}:vztmpl/${DEBIAN_TEMPLATE}"
else
print_info "Using template: $TEMPLATE"
fi
else
# Find best storage for templates (prefer one with most free space)
local BEST_TEMPLATE_STORAGE=$(pvesm status -content vztmpl 2>/dev/null | tail -n +2 | sort -k6 -rn | head -1 | awk '{print $1}')
BEST_TEMPLATE_STORAGE=${BEST_TEMPLATE_STORAGE:-$storage}
ensure_debian_template
TEMPLATE="${BEST_TEMPLATE_STORAGE}:vztmpl/${DEBIAN_TEMPLATE}"
print_info "Invalid selection, using Debian 12"
fi
;;
*)
# Find best storage for templates (prefer one with most free space)
local BEST_TEMPLATE_STORAGE=$(pvesm status -content vztmpl 2>/dev/null | tail -n +2 | sort -k6 -rn | head -1 | awk '{print $1}')
BEST_TEMPLATE_STORAGE=${BEST_TEMPLATE_STORAGE:-$storage}
ensure_debian_template
TEMPLATE="${BEST_TEMPLATE_STORAGE}:vztmpl/${DEBIAN_TEMPLATE}"
;;
esac
else
# Find best storage for templates (prefer one with most free space)
local BEST_TEMPLATE_STORAGE=$(pvesm status -content vztmpl 2>/dev/null | tail -n +2 | sort -k6 -rn | head -1 | awk '{print $1}')
BEST_TEMPLATE_STORAGE=${BEST_TEMPLATE_STORAGE:-$storage}
ensure_debian_template
TEMPLATE="${BEST_TEMPLATE_STORAGE}:vztmpl/${DEBIAN_TEMPLATE}"
fi
else
# Find best storage for templates (prefer one with most free space)
local BEST_TEMPLATE_STORAGE=$(pvesm status -content vztmpl 2>/dev/null | tail -n +2 | sort -k6 -rn | head -1 | awk '{print $1}')
BEST_TEMPLATE_STORAGE=${BEST_TEMPLATE_STORAGE:-$storage}
ensure_debian_template
TEMPLATE="${BEST_TEMPLATE_STORAGE}:vztmpl/${DEBIAN_TEMPLATE}"
fi
else
# Quick mode - find best storage for templates
local BEST_TEMPLATE_STORAGE=$(pvesm status -content vztmpl 2>/dev/null | tail -n +2 | sort -k6 -rn | head -1 | awk '{print $1}')
BEST_TEMPLATE_STORAGE=${BEST_TEMPLATE_STORAGE:-$storage}
ensure_debian_template
TEMPLATE="${BEST_TEMPLATE_STORAGE}:vztmpl/${DEBIAN_TEMPLATE}"
fi
# Download template if it doesn't exist
# Check if template exists - pveam list shows full paths like storage:vztmpl/file.tar.zst
local TEMPLATE_EXISTS=false
if [[ "$TEMPLATE" =~ ^([^:]+):vztmpl/(.+)$ ]]; then
local STORAGE_NAME="${BASH_REMATCH[1]}"
local TEMPLATE_FILE="${BASH_REMATCH[2]}"
# Check if this exact template exists in pveam list
if pveam list "$STORAGE_NAME" 2>/dev/null | grep -q "$TEMPLATE_FILE"; then
TEMPLATE_EXISTS=true
fi
fi
if [[ "$TEMPLATE_EXISTS" == "false" ]]; then
# Extract storage name from template path
local TEMPLATE_STORAGE="${TEMPLATE%%:*}"
print_info "Template not found, downloading Debian 12 to storage '$TEMPLATE_STORAGE'..."
ensure_debian_template
if ! pveam download "$TEMPLATE_STORAGE" "$DEBIAN_TEMPLATE"; then
print_error "Failed to download template. Please check your internet connection and try again."
print_info "You can manually download with: pveam download $TEMPLATE_STORAGE $DEBIAN_TEMPLATE"
exit 1
fi
# Verify it was downloaded
if ! pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep -q "$DEBIAN_TEMPLATE"; then
print_error "Template download succeeded but file not found in storage"
exit 1
fi
fi
if [[ -n "$ARCHIVE_OVERRIDE" ]]; then
ARCHIVE_OVERRIDE=$(resolve_archive_override "$ARCHIVE_OVERRIDE") || exit 1
fi
print_info "Creating container..."
# Build network configuration
if [[ -n "$static_ip" ]]; then
# Include gateway in network config for static IP
if [[ -n "$gateway_ip" ]]; then
NET_CONFIG="name=eth0,bridge=${bridge},ip=${static_ip},gw=${gateway_ip},firewall=${firewall}"
else
# This shouldn't happen but handle it gracefully
NET_CONFIG="name=eth0,bridge=${bridge},ip=${static_ip},firewall=${firewall}"
fi
else
NET_CONFIG="name=eth0,bridge=${bridge},ip=dhcp,firewall=${firewall}"
fi
# Add VLAN tag if specified
if [[ -n "$vlan_id" ]]; then
NET_CONFIG="${NET_CONFIG},tag=${vlan_id}"
fi
# Build container create command using array to avoid eval issues
local CREATE_ARGS=(pct create "$CTID" "$TEMPLATE")
CREATE_ARGS+=("--hostname" "$hostname")
CREATE_ARGS+=("--memory" "$memory")
CREATE_ARGS+=("--cores" "$cores")
if [[ "$cpulimit" != "0" ]]; then
CREATE_ARGS+=("--cpulimit" "$cpulimit")
fi
CREATE_ARGS+=("--rootfs" "${storage}:${disk}")
CREATE_ARGS+=("--net0" "$NET_CONFIG")
CREATE_ARGS+=("--unprivileged" "$unprivileged")
CREATE_ARGS+=("--features" "nesting=1")
CREATE_ARGS+=("--onboot" "$onboot")
CREATE_ARGS+=("--startup" "order=$startup")
CREATE_ARGS+=("--protection" "0")
CREATE_ARGS+=("--swap" "$swap")
if [[ -n "$nameserver" ]]; then
CREATE_ARGS+=("--nameserver" "$nameserver")
fi
# Execute container creation (suppress verbose output)
if ! "${CREATE_ARGS[@]}" >/dev/null 2>&1; then
print_error "Failed to create container"
exit 1
fi
CONTAINER_CREATED_FOR_CLEANUP=true
# From this point on, cleanup container if we fail
cleanup_on_error() {
print_error "Installation failed, cleaning up container $CTID..."
CURRENT_INSTALL_CTID="$CTID"
CONTAINER_CREATED_FOR_CLEANUP=true
pct stop $CTID 2>/dev/null || true
sleep 2
pct destroy $CTID 2>/dev/null || true
exit 1
}
# Start container
print_info "Starting container..."
if ! pct start $CTID >/dev/null 2>&1; then
print_error "Failed to start container"
cleanup_on_error
fi
sleep 3
# Wait for network to provide an IP address
print_info "Waiting for network..."
local network_ready=false
for i in {1..60}; do
local container_ip=""
container_ip=$(pct exec $CTID -- hostname -I 2>/dev/null | awk '{print $1}') || true
if [[ -n "$container_ip" ]]; then
network_ready=true
break
fi
sleep 1
done
if [[ "$network_ready" != "true" ]]; then
print_error "Container network failed to obtain an IP address after 60 seconds"
cleanup_on_error
fi
# Install dependencies and optimize container
print_info "Installing dependencies..."
if ! pct exec $CTID -- bash -c "
apt-get update -qq >/dev/null 2>&1 && apt-get install -y -qq curl wget ca-certificates >/dev/null 2>&1
# Set timezone to UTC for consistent logging
ln -sf /usr/share/zoneinfo/UTC /etc/localtime 2>/dev/null
# Optimize sysctl for monitoring workload
echo 'net.core.somaxconn=1024' >> /etc/sysctl.conf
echo 'net.ipv4.tcp_keepalive_time=60' >> /etc/sysctl.conf
sysctl -p >/dev/null 2>&1
# Note: We don't create /usr/local/bin/update to avoid conflicts with Community Scripts
# Native installations should update using: curl -fsSL ... | bash
# Ensure /usr/local/bin is in PATH for all users
if ! grep -q '/usr/local/bin' /etc/profile 2>/dev/null; then
echo 'export PATH="/usr/local/bin:$PATH"' >> /etc/profile
fi
# Also add to bash profile if it exists
if [[ -f /etc/bash.bashrc ]] && ! grep -q '/usr/local/bin' /etc/bash.bashrc 2>/dev/null; then
echo 'export PATH="/usr/local/bin:$PATH"' >> /etc/bash.bashrc
fi
"; then
print_error "Failed to install dependencies in container"
cleanup_on_error
fi
if [[ -n "$ARCHIVE_OVERRIDE" ]]; then
archive_requested=true
container_archive_source="$ARCHIVE_OVERRIDE"
container_archive_dest="/tmp/$(basename "$container_archive_source")"
elif [[ "$BUILD_FROM_SOURCE" != "true" ]]; then
if prefetch_pulse_archive_for_container container_archive_source; then
container_archive_temp=true
container_archive_dest="/tmp/$(basename "$container_archive_source")"
else
print_warn "Host-side Pulse archive prefetch failed; falling back to in-container download."
fi
fi
# Install Pulse inside container
print_info "Installing Pulse..."
# When piped through curl, $0 is "bash" not the script. Download fresh copy.
local script_source="$0"
if [[ "$0" == "bash" ]] || [[ ! -f "$0" ]]; then
if ! download_container_installer script_source container_script_temp_dir; then
cleanup_on_error
fi
fi
# Copy this script to container and run it
if ! pct push $CTID "$script_source" /tmp/install.sh >/dev/null 2>&1; then
print_error "Failed to copy install script to container"
cleanup_on_error
fi
cleanup_container_install_inputs "$container_script_temp_dir" "" false
container_script_temp_dir=""
if [[ -n "$container_archive_source" ]]; then
print_info "Copying Pulse release archive to container..."
if ! pct push $CTID "$container_archive_source" "$container_archive_dest" >/dev/null 2>&1; then
if [[ "$container_archive_temp" == "true" ]]; then
cleanup_temp_archive_path "$container_archive_source"
container_archive_temp=false
fi
if [[ "$archive_requested" == "true" ]]; then
print_error "Failed to copy Pulse release archive to container"
cleanup_on_error
fi
print_warn "Could not copy prefetched archive into container; falling back to in-container download."
container_archive_source=""
container_archive_dest=""
container_archive_temp=false
else
# Signature sidecar must travel with the archive so that
# install_pulse_archive inside the container can verify it.
if [[ -f "${container_archive_source}.sshsig" ]]; then
if ! pct push $CTID "${container_archive_source}.sshsig" "${container_archive_dest}.sshsig" >/dev/null 2>&1; then
if [[ "$container_archive_temp" == "true" ]]; then
cleanup_temp_archive_path "$container_archive_source"
container_archive_temp=false
fi
print_error "Failed to copy Pulse release archive signature to container"
cleanup_on_error
fi
else
if [[ "$container_archive_temp" == "true" ]]; then
cleanup_temp_archive_path "$container_archive_source"
container_archive_temp=false
fi
print_error "Pulse release archive signature missing alongside ${container_archive_source}"
cleanup_on_error
fi
fi
fi
if [[ "$container_archive_temp" == "true" ]]; then
cleanup_temp_archive_path "$container_archive_source"
container_archive_temp=false
fi
# Run installation with visible progress
local install_cmd=""
install_cmd=$(build_container_install_command)
# Run installation showing output in real-time so users can see progress/errors
# Use timeout wrapper if available
local install_status
local timeout_duration=300
if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
timeout_duration=1200
fi
if [[ -n "${PULSE_CONTAINER_TIMEOUT:-}" ]]; then
if [[ "${PULSE_CONTAINER_TIMEOUT}" =~ ^[0-9]+$ ]]; then
timeout_duration=${PULSE_CONTAINER_TIMEOUT}
else
print_warn "Ignoring invalid PULSE_CONTAINER_TIMEOUT value '${PULSE_CONTAINER_TIMEOUT}'"
fi
fi
if command -v timeout >/dev/null 2>&1 && [[ "$timeout_duration" -ne 0 ]]; then
# Show output in real-time with timeout
timeout "$timeout_duration" pct exec $CTID -- bash -c "$install_cmd"
install_status=$?
if [[ $install_status -eq 124 ]]; then
print_error "Installation timed out after ${timeout_duration}s"
if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
print_info "Building from source can take more than 15 minutes in containers, especially on first run."
else
print_info "This usually happens due to network issues or GitHub rate limiting."
fi
print_info "You can increase or disable the timeout by setting PULSE_CONTAINER_TIMEOUT (set to 0 to disable)."
print_info "Then enter the container and run the same installer command manually:"
print_info " pct enter $CTID"
print_container_recovery_command
cleanup_on_error
fi
else
# Show output in real-time without timeout
pct exec $CTID -- bash -c "$install_cmd"
install_status=$?
fi
if [[ $install_status -ne 0 ]]; then
print_error "Failed to install Pulse inside container"
print_info "You can enter the container to investigate:"
print_info " pct enter $CTID"
print_container_recovery_command
cleanup_on_error
fi
# Get container IP
local IP=$(pct exec $CTID -- hostname -I | awk '{print $1}')
local PULSE_BASE_URL="http://${IP}:${frontend_port}"
# Automatically register the Proxmox host with Pulse to speed setup
auto_register_pve_node "$CTID" "$IP" "$frontend_port"
wait_for_pulse_ready "$PULSE_BASE_URL" 120 1
# Clean final output
echo
print_success "Pulse installation complete!"
echo
echo " Web UI: http://${IP}:${frontend_port}"
echo " Container: $CTID"
if [[ "$AUTO_NODE_REGISTERED" == true ]]; then
echo " Node: Registered ${AUTO_NODE_REGISTERED_NAME} in Pulse"
elif [[ -n "$AUTO_NODE_REGISTER_ERROR" ]]; then
echo " Node: Registration pending (${AUTO_NODE_REGISTER_ERROR})"
fi
echo
echo " First-time setup:"
local QUOTED_CONFIG_DIR
printf -v QUOTED_CONFIG_DIR '%q' "$CONFIG_DIR"
# pct exec runs with PATH=/sbin:/bin:/usr/sbin:/usr/bin, which excludes
# /usr/local/bin where the binary is linked, so these must be absolute.
echo " pct exec $CTID -- env PULSE_DATA_DIR=$QUOTED_CONFIG_DIR $BINARY_LINK_PATH bootstrap-token"
echo
echo " Common commands:"
echo " pct enter $CTID # Enter container"
echo " pct exec $CTID -- $UPDATE_HELPER_PATH # Update Pulse"
echo
CONTAINER_CREATED_FOR_CLEANUP=false
CURRENT_INSTALL_CTID=""
trap - INT TERM
exit 0
}
# Extract the PVE API token secret from `pveum user token add` output. Prefers
# the deterministic JSON form (`pveum ... --output-format json`) and falls back
# to the legacy box-drawing table layout, mirroring the hardened web-setup
# render path (internal/api/setup_script_render.go) so token capture does not
# silently fail or mis-parse when pveum's table formatting drifts across
# versions/locales (#1312).
extract_pve_token_value() {
local token_output="$1"
local token_value=""
token_value=$(printf '%s\n' "$token_output" | sed -n 's/.*"value"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
if [[ -n "$token_value" ]]; then
printf '%s\n' "$token_value"
return 0
fi
# Legacy table form: normalize the box-drawing column separator to a plain
# pipe (byte-wise via sed, so it is locale-independent) before splitting, so
# extraction works regardless of the host locale.
printf '%s\n' "$token_output" | sed 's/│/|/g' | awk -F'|' '
function trim(value) {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
return value
}
{
key = trim($2)
value = trim($3)
if (key == "value" && value != "") {
print value
exit
}
}
'
}
pve_token_already_exists_error() {
local token_output="$1"
printf '%s\n' "$token_output" | grep -Eqi 'already exists|already defined|duplicate|exists already'
}
create_pve_auto_register_token() {
local token_name="$1"
local token_output_var="$2"
local token_status_var="$3"
local token_output=""
local token_status=0
set +e
token_output=$(pveum user token add pulse-monitor@pve "$token_name" --privsep 1 --output-format json 2>&1)
token_status=$?
set -e
# Older pveum builds reject --output-format; fall back to the legacy table form.
if [[ $token_status -ne 0 ]] && printf '%s\n' "$token_output" | grep -Eqi 'unknown option|unknown command|no such option|unable to parse option|output-format'; then
set +e
token_output=$(pveum user token add pulse-monitor@pve "$token_name" --privsep 1 2>&1)
token_status=$?
set -e
fi
printf -v "$token_output_var" '%s' "$token_output"
printf -v "$token_status_var" '%s' "$token_status"
}
smoke_test_pve_auto_register_token() {
local host_url="$1"
local token_id="$2"
local token_value="$3"
local smoke_output=""
local smoke_status=0
set +e
smoke_output=$(printf 'Authorization: PVEAPIToken=%s=%s\n' "$token_id" "$token_value" | curl --retry 2 --retry-delay 1 -kfsS -H @- "${host_url%/}/api2/json/nodes" 2>&1)
smoke_status=$?
set -e
if [[ $smoke_status -ne 0 ]]; then
AUTO_NODE_REGISTER_ERROR="token smoke check failed"
print_warn "Created Proxmox monitoring token, but a local API smoke check failed; skipping automatic node registration"
print_warn "Smoke check error: ${smoke_output}"
return 1
fi
return 0
}
auto_register_pve_node() {
local ctid="$1"
local pulse_ip="$2"
local pulse_port="${3:-7655}"
if [[ "$IN_CONTAINER" == "true" ]]; then
return
fi
if [[ -z "$ctid" || -z "$pulse_ip" ]]; then
return
fi
local skip_auto="${PULSE_SKIP_AUTO_NODE:-}"
if [[ "$skip_auto" =~ ^([Tt][Rr][Uu][Ee]|[Yy][Ee][Ss]|1|on|ON)$ ]]; then
print_info "Skipping automatic node registration (PULSE_SKIP_AUTO_NODE set)"
return
fi
if ! command -v pveum >/dev/null 2>&1; then
AUTO_NODE_REGISTER_ERROR="pveum unavailable"
print_warn "pveum command not available; skipping automatic node registration"
return
fi
if ! command -v curl >/dev/null 2>&1; then
AUTO_NODE_REGISTER_ERROR="curl unavailable"
print_warn "curl command not available; skipping automatic node registration"
return
fi
local default_port="${PULSE_PVE_API_PORT:-8006}"
local host_input="${PULSE_PVE_HOST_URL:-}"
if [[ -z "$host_input" ]]; then
host_input="$(hostname -f 2>/dev/null || hostname)"
fi
local normalized_host_url
normalized_host_url=$(python3 - <<'PY' "$host_input" "$default_port"
import sys, urllib.parse
raw = sys.argv[1].strip()
default_port = sys.argv[2]
if not raw:
print("")
sys.exit(0)
if "://" not in raw:
raw = f"https://{raw}"
parsed = urllib.parse.urlparse(raw)
scheme = parsed.scheme or "https"
netloc = parsed.netloc or parsed.path
path = parsed.path if parsed.netloc else ""
if not netloc:
print("")
sys.exit(0)
host = netloc.split('@', 1)[-1]
if ':' not in host:
netloc = f"{netloc}:{default_port}"
print(urllib.parse.urlunparse((scheme, netloc, path, "", "", "")))
PY
) || normalized_host_url=""
if [[ -z "$normalized_host_url" ]]; then
AUTO_NODE_REGISTER_ERROR="invalid host URL"
print_warn "Unable to determine Proxmox API URL; skipping automatic node registration"
return
fi
local server_name
server_name="$(hostname -s 2>/dev/null || hostname)"
if [[ -z "$server_name" ]]; then
server_name="pulse-proxmox-host"
fi
local backup_flag="${PULSE_AUTO_BACKUP_PERMS:-true}"
local backup_perms="false"
if [[ "$backup_flag" =~ ^([Tt][Rr][Uu][Ee]|[Yy][Ee][Ss]|1|on|ON)$ ]]; then
backup_perms="true"
fi
local setup_payload
setup_payload=$(python3 - <<'PY' "$normalized_host_url" "$backup_perms"
import json, sys
host = sys.argv[1]
backup = sys.argv[2].lower() == "true"
print(json.dumps({"type": "pve", "host": host, "backupPerms": backup}))
PY
)
# Bootstrap request/response are not retained in shared /tmp diagnostics.
local pulse_url="http://${pulse_ip}:${pulse_port}"
set +xv
local setup_response
if ! setup_response=$(curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/setup-script-url" -H "Content-Type: application/json" -d "$setup_payload"); then
AUTO_NODE_REGISTER_ERROR="setup token request failed"
print_warn "Unable to request setup token from Pulse (${pulse_url}); skipping automatic node registration"
return
fi
# The response contains a one-time credential; do not persist it as a diagnostic.
local setup_token
local setup_type
local setup_host
local setup_url
local setup_download_url
local setup_script_name
local setup_command
local setup_command_with_env
local setup_command_without_env
local setup_token_hint
local setup_expires
local setup_expiry_state
IFS=$'\t' read -r setup_token setup_type setup_host setup_url setup_download_url setup_script_name setup_command setup_command_with_env setup_command_without_env setup_token_hint setup_expires setup_expiry_state <<<"$(python3 - "$pulse_url" "$normalized_host_url" "$backup_perms" 3<<<"$setup_response" <<'PY'
import json, sys
import time
from urllib.parse import quote
try:
data = json.load(__import__("os").fdopen(3))
except Exception:
print("\t\t\t\t\t\t\t\t\t\t")
sys.exit(0)
pulse_url = sys.argv[1]
host = sys.argv[2]
backup_query = "backup_perms=true&" if sys.argv[3] == "true" else ""
expires_raw = data.get("expires", "")
expiry_state = ""
try:
expires_int = int(expires_raw)
except Exception:
expires_int = 0
if expires_int > int(time.time()):
expiry_state = "live"
setup_token = str(data.get("setupToken", ""))
token_hint = str(data.get("tokenHint", ""))
setup_url = str(data.get("url", ""))
setup_download_url = str(data.get("downloadURL", ""))
setup_script_name = str(data.get("scriptFileName", ""))
setup_command = str(data.get("command", ""))
setup_command_with_env = str(data.get("commandWithEnv", ""))
setup_command_without_env = str(data.get("commandWithoutEnv", ""))
expected_setup_url = f"{pulse_url}/api/setup-script?{backup_query}host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&type=pve"
expected_download_url = expected_setup_url
legacy_download_url = f"{pulse_url}/api/setup-script?{backup_query}host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve" if setup_token else ""
modern = setup_download_url == expected_download_url
expected_script_name = "pulse-setup-pve.sh"
if setup_url != expected_setup_url:
setup_url = ""
if setup_download_url not in (expected_download_url, legacy_download_url):
setup_download_url = ""
if setup_script_name != expected_script_name:
setup_script_name = ""
if not setup_command:
setup_command = ""
if not setup_command_with_env:
setup_command_with_env = ""
if not setup_command_without_env:
setup_command_without_env = ""
command_fields = (
("command", setup_command, True),
("commandWithEnv", setup_command_with_env, True),
("commandWithoutEnv", setup_command_without_env, False),
)
for _field_name, _value, _requires_token in command_fields:
if not _value or expected_setup_url not in _value:
if _field_name == "command":
setup_command = ""
elif _field_name == "commandWithEnv":
setup_command_with_env = ""
else:
setup_command_without_env = ""
continue
if 'if [ "$(id -u)" -eq 0 ]; then' not in _value or 'elif command -v sudo >/dev/null 2>&1; then' not in _value:
if _field_name == "command":
setup_command = ""
elif _field_name == "commandWithEnv":
setup_command_with_env = ""
else:
setup_command_without_env = ""
continue
if modern:
valid = "PULSE_SETUP_TOKEN_FILE=" in _value and "PULSE_SETUP_TOKEN=" not in _value and setup_token not in _value
else:
valid = "PULSE_SETUP_TOKEN_FILE=" not in _value and (("PULSE_SETUP_TOKEN=" in _value and setup_token in _value) if _requires_token else ("PULSE_SETUP_TOKEN=" not in _value and setup_token not in _value))
if not valid:
if _field_name == "command":
setup_command = ""
elif _field_name == "commandWithEnv":
setup_command_with_env = ""
else:
setup_command_without_env = ""
if not token_hint or token_hint == setup_token:
token_hint = ""
print("\t".join([
setup_token,
str(data.get("type", "")),
str(data.get("host", "")),
setup_url,
setup_download_url,
setup_script_name,
setup_command,
setup_command_with_env,
setup_command_without_env,
token_hint,
str(expires_raw),
expiry_state,
]))
PY
)" || setup_token=""
local backup_query=""
[[ "$backup_perms" == "true" ]] && backup_query="backup_perms=true&"
local expected_setup_url="${pulse_url}/api/setup-script?${backup_query}host=$(python3 - <<'PY' "$normalized_host_url"
from urllib.parse import quote
import sys
print(quote(sys.argv[1], safe=''))
PY
)&pulse_url=$(python3 - <<'PY' "$pulse_url"
from urllib.parse import quote
import sys
print(quote(sys.argv[1], safe=''))
PY
)&type=pve"
local expected_download_url="$(python3 - <<'PY' "$normalized_host_url" "$pulse_url" "$backup_perms" 3<<<"$setup_token"
from urllib.parse import quote
import sys
host, pulse_url, backup_perms = sys.argv[1:]
backup_query = "backup_perms=true&" if backup_perms == "true" else ""
setup_token = __import__("os").fdopen(3).read().strip()
print(f"{pulse_url}/api/setup-script?{backup_query}host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve")
PY
)"
local expected_script_name="pulse-setup-pve.sh"
if [[ -z "$setup_token" ]] || [[ "$setup_type" != "pve" ]] || [[ "$setup_host" != "$normalized_host_url" ]] || [[ "$setup_url" != "$expected_setup_url" ]] || { [[ "$setup_download_url" != "$expected_download_url" ]] && [[ "$setup_download_url" != "$expected_setup_url" ]]; } || [[ "$setup_script_name" != "$expected_script_name" ]] || [[ -z "$setup_command" ]] || [[ -z "$setup_command_with_env" ]] || [[ -z "$setup_command_without_env" ]] || [[ -z "$setup_token_hint" ]] || [[ -z "$setup_expires" ]] || [[ "$setup_expiry_state" != "live" ]]; then
AUTO_NODE_REGISTER_ERROR="missing setup token"
print_warn "Pulse did not return a setup token; skipping automatic node registration"
return
fi
pveum user add pulse-monitor@pve --comment "Pulse monitoring service" >/dev/null 2>&1 || true
pveum aclmod / -user pulse-monitor@pve -role PVEAuditor >/dev/null 2>&1 || true
if [[ "$backup_perms" == "true" ]]; then
pveum aclmod /storage -user pulse-monitor@pve -role PVEDatastoreAdmin >/dev/null 2>&1 || true
fi
local pulse_monitor_role_ready=false
local extra_privs=()
if pveum role list 2>/dev/null | grep -q "Sys.Audit"; then
extra_privs+=("Sys.Audit")
elif pveum role add PulseSysAuditProbe -privs Sys.Audit >/dev/null 2>&1; then
extra_privs+=("Sys.Audit")
pveum role delete PulseSysAuditProbe >/dev/null 2>&1 || true
fi
local has_guest_audit=false
if pveum role list 2>/dev/null | grep -q "VM.GuestAgent.Audit"; then
has_guest_audit=true
elif pveum role add PulseGuestAuditProbe -privs VM.GuestAgent.Audit >/dev/null 2>&1; then
has_guest_audit=true
pveum role delete PulseGuestAuditProbe >/dev/null 2>&1 || true
fi
if [[ "$has_guest_audit" == true ]]; then
extra_privs+=("VM.GuestAgent.Audit")
if pveum role list 2>/dev/null | grep -q "VM.GuestAgent.FileRead"; then
extra_privs+=("VM.GuestAgent.FileRead")
elif pveum role add PulseGuestFileReadProbe -privs VM.GuestAgent.FileRead >/dev/null 2>&1; then
extra_privs+=("VM.GuestAgent.FileRead")
pveum role delete PulseGuestFileReadProbe >/dev/null 2>&1 || true
fi
else
local has_vm_monitor=false
if pveum role list 2>/dev/null | grep -q "VM.Monitor"; then
has_vm_monitor=true
elif pveum role add PulseVmMonitorProbe -privs VM.Monitor >/dev/null 2>&1; then
has_vm_monitor=true
pveum role delete PulseVmMonitorProbe >/dev/null 2>&1 || true
fi
if [[ "$has_vm_monitor" == true ]]; then
extra_privs+=("VM.Monitor")
fi
fi
if [[ ${#extra_privs[@]} -gt 0 ]]; then
local priv_string
priv_string="$(IFS=,; echo "${extra_privs[*]}")"
if pveum role modify PulseMonitor -privs "$priv_string" >/dev/null 2>&1 || pveum role add PulseMonitor -privs "$priv_string" >/dev/null 2>&1; then
pulse_monitor_role_ready=true
pveum aclmod / -user pulse-monitor@pve -role PulseMonitor >/dev/null 2>&1 || true
fi
fi
local token_name
token_name=$(python3 - <<'PY' "$pulse_url"
import re, sys, urllib.parse
raw = sys.argv[1].strip()
host = ""
if raw:
parsed = urllib.parse.urlparse(raw)
host = (parsed.hostname or "").strip().lower().strip(".")
slug = re.sub(r"[^a-z0-9]+", "-", host)
slug = slug.strip("-")
if len(slug) > 48:
slug = slug[:48].strip("-")
if not slug:
slug = "server"
print(f"pulse-{slug}")
PY
)
local token_output=""
local token_status=0
create_pve_auto_register_token "$token_name" token_output token_status
if [[ $token_status -ne 0 ]] && pve_token_already_exists_error "$token_output"; then
print_info "Existing Proxmox monitoring token '${token_name}' found; rotating it so Pulse receives a fresh secret"
if ! pveum user token remove pulse-monitor@pve "$token_name" >/dev/null 2>&1; then
AUTO_NODE_REGISTER_ERROR="failed to rotate existing token"
print_warn "Unable to remove existing monitoring API token; skipping automatic node registration"
return
fi
create_pve_auto_register_token "$token_name" token_output token_status
fi
if [[ $token_status -ne 0 ]]; then
AUTO_NODE_REGISTER_ERROR="failed to create token"
print_warn "Unable to create monitoring API token; skipping automatic node registration"
return
fi
local token_value
token_value=$(extract_pve_token_value "$token_output" | tail -n1 | tr -d '\r')
if [[ -z "$token_value" ]]; then
AUTO_NODE_REGISTER_ERROR="token value unavailable"
print_warn "Failed to extract token value from pveum output; skipping automatic node registration"
return
fi
local token_id="pulse-monitor@pve!${token_name}"
pveum aclmod / -token "$token_id" -role PVEAuditor >/dev/null 2>&1 || true
if [[ "$backup_perms" == "true" ]]; then
pveum aclmod /storage -token "$token_id" -role PVEDatastoreAdmin >/dev/null 2>&1 || true
fi
if [[ "$pulse_monitor_role_ready" == "true" ]]; then
pveum aclmod / -token "$token_id" -role PulseMonitor >/dev/null 2>&1 || true
fi
if ! smoke_test_pve_auto_register_token "$normalized_host_url" "$token_id" "$token_value"; then
return
fi
local register_payload
register_payload=$(python3 - "$normalized_host_url" "$token_id" "$server_name" 3<<<"$(printf '%s\n%s\n' "$token_value" "$setup_token")" <<'PY'
import json, sys
host, token_id, server_name = sys.argv[1:]
token_value, setup_token = __import__("os").fdopen(3).read().splitlines()
print(json.dumps({
"type": "pve",
"host": host,
"tokenId": token_id,
"tokenValue": token_value,
"serverName": server_name,
"authToken": setup_token,
"source": "script"
}))
PY
)
local register_response
if ! register_response=$(printf %s "$register_payload" | curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d @-); then
AUTO_NODE_REGISTER_ERROR="auto-register request failed"
print_warn "Pulse auto-registration request failed; skipping automatic node registration"
return
fi
local register_status
local register_action
local register_type
local register_source
local register_host
local register_token_id
local register_token_value
local register_node_id
local register_node_name
IFS=$'\t' read -r register_status register_action register_type register_source register_host register_token_id register_token_value register_node_id register_node_name <<<"$(python3 - "$register_response" <<'PY'
import json, sys
try:
data = json.loads(sys.argv[1])
except Exception:
print("\t\t\t\t\t\t\t\t")
sys.exit(0)
print("\t".join([
str(data.get("status", "")),
str(data.get("action", "")),
str(data.get("type", "")),
str(data.get("source", "")),
str(data.get("host", "")),
str(data.get("tokenId", "")),
str(data.get("tokenValue", "")),
str(data.get("nodeId", "")),
str(data.get("nodeName", "")),
]))
PY
)" || register_status=""
if [[ "$register_status" != "success" ]] || [[ "$register_action" != "use_token" ]] || [[ "$register_type" != "pve" ]] || [[ "$register_source" != "script" ]] || [[ "$register_host" != "$normalized_host_url" ]] || [[ "$register_token_id" != "$token_id" ]] || [[ "$register_token_value" != "$token_value" ]] || [[ -z "$register_node_id" ]] || [[ -z "$register_node_name" ]] || [[ "$register_node_id" != "$register_node_name" ]]; then
AUTO_NODE_REGISTER_ERROR="auto-register unsuccessful"
print_warn "Pulse auto-registration reported an error: $register_response"
return
fi
AUTO_NODE_REGISTERED=true
AUTO_NODE_REGISTERED_NAME="$register_node_name"
AUTO_NODE_REGISTER_ERROR=""
print_success "Registered ${register_node_name} with Pulse automatically"
}
# Compare two version strings
# Returns: 0 if equal, 1 if first > second, 2 if first < second
compare_versions() {
local v1="${1#v}" # Remove 'v' prefix
local v2="${2#v}"
# Strip any pre-release suffix (e.g., -rc.1, -beta, etc.)
local base_v1="${v1%%-*}"
local base_v2="${v2%%-*}"
local suffix_v1="${v1#*-}"
local suffix_v2="${v2#*-}"
# If no suffix, suffix equals the full version
[[ "$suffix_v1" == "$v1" ]] && suffix_v1=""
[[ "$suffix_v2" == "$v2" ]] && suffix_v2=""
# Split base versions into parts
IFS='.' read -ra V1_PARTS <<< "$base_v1"
IFS='.' read -ra V2_PARTS <<< "$base_v2"
# Compare major.minor.patch
for i in 0 1 2; do
local p1="${V1_PARTS[$i]:-0}"
local p2="${V2_PARTS[$i]:-0}"
if [[ "$p1" -gt "$p2" ]]; then
return 1
elif [[ "$p1" -lt "$p2" ]]; then
return 2
fi
done
# Base versions are equal, now compare suffixes
# No suffix (stable) > rc suffix
if [[ -z "$suffix_v1" ]] && [[ -n "$suffix_v2" ]]; then
return 1 # v1 (stable) > v2 (rc)
elif [[ -n "$suffix_v1" ]] && [[ -z "$suffix_v2" ]]; then
return 2 # v1 (rc) < v2 (stable)
elif [[ -n "$suffix_v1" ]] && [[ -n "$suffix_v2" ]]; then
# Published preview revisions are numeric: rc.10 follows rc.9, not
# vice versa. Keep the existing lexical fallback for other suffixes.
if [[ "$suffix_v1" =~ ^(beta|rc)[.][0-9]+$ && "$suffix_v2" =~ ^(beta|rc)[.][0-9]+$ && "${suffix_v1%.*}" == "${suffix_v2%.*}" ]]; then
local revision_v1="${suffix_v1##*.}" revision_v2="${suffix_v2##*.}"
if (( 10#$revision_v1 > 10#$revision_v2 )); then
return 1
elif (( 10#$revision_v1 < 10#$revision_v2 )); then
return 2
fi
return 0
fi
# Both have suffixes, compare them lexicographically
if [[ "$suffix_v1" > "$suffix_v2" ]]; then
return 1
elif [[ "$suffix_v1" < "$suffix_v2" ]]; then
return 2
fi
fi
return 0 # versions are equal
}
version_major() {
local version="${1:-}"
version="${version#v}"
version="${version%%-*}"
local major="${version%%.*}"
if [[ ! "$major" =~ ^[0-9]+$ ]]; then
return 1
fi
printf '%s\n' "$major"
}
is_pre_v6_to_v6_upgrade() {
local current_version="${1:-}"
local target_version="${2:-}"
local current_major=""
local target_major=""
current_major=$(version_major "$current_version" 2>/dev/null || true)
target_major=$(version_major "$target_version" 2>/dev/null || true)
if [[ -z "$current_major" || -z "$target_major" ]]; then
return 1
fi
[[ "$current_major" -lt 6 && "$target_major" -ge 6 ]]
}
inspect_api_tokens_for_upgrade() {
local token_file="$1"
if ! command -v python3 >/dev/null 2>&1; then
return 1
fi
python3 - "$token_file" <<'PY'
import datetime
import json
import sys
path = sys.argv[1]
try:
with open(path, "r", encoding="utf-8") as handle:
tokens = json.load(handle)
except Exception:
print("invalid\t0\t0\t0\t0")
sys.exit(0)
if not isinstance(tokens, list):
print("invalid\t0\t0\t0\t0")
sys.exit(0)
now = datetime.datetime.now(datetime.timezone.utc)
agent_scoped = 0
expired = 0
expiring = 0
for token in tokens:
if not isinstance(token, dict):
continue
scopes = token.get("scopes")
if not scopes:
scopes = ["*"]
if not isinstance(scopes, list):
scopes = [str(scopes)]
normalized = {str(scope).strip() for scope in scopes}
if not ({"*", "agent:report", "host-agent:report"} & normalized):
continue
agent_scoped += 1
expires_at = token.get("expiresAt") or token.get("expires_at")
if not expires_at:
continue
try:
raw = str(expires_at).strip()
parsed = datetime.datetime.fromisoformat(raw.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=datetime.timezone.utc)
except Exception:
continue
if parsed <= now:
expired += 1
elif parsed - now <= datetime.timedelta(days=14):
expiring += 1
print(f"ok\t{len(tokens)}\t{agent_scoped}\t{expired}\t{expiring}")
PY
}
run_upgrade_readiness_preflight() {
local current_version="${1:-}"
local target_version="${2:-}"
local token_file="$CONFIG_DIR/api_tokens.json"
if ! is_pre_v6_to_v6_upgrade "$current_version" "$target_version"; then
return 0
fi
if [[ "$UPGRADE_PREFLIGHT_RAN" == "true" ]]; then
return 0
fi
UPGRADE_PREFLIGHT_RAN=true
if [[ "$SKIP_UPGRADE_PREFLIGHT" == "true" ]]; then
print_warn "Skipping Pulse v5 to v6 upgrade preflight because PULSE_SKIP_UPGRADE_PREFLIGHT=true or --skip-upgrade-preflight was set"
return 0
fi
echo
print_info "Running Pulse v5 to v6 upgrade preflight..."
if [[ -e "$token_file" && ! -r "$token_file" ]]; then
print_error "Cannot read API token inventory at $token_file"
print_info "Fix file permissions before upgrading so Pulse can preserve agent credentials."
return 1
fi
if [[ ! -f "$token_file" ]]; then
print_warn "No API token inventory found at $token_file"
print_info "If this instance has v5 host agents, refresh or reinstall their agent token after the server starts on v6."
return 0
fi
local report=""
local status=""
local total_tokens=0
local agent_tokens=0
local expired_tokens=0
local expiring_tokens=0
report=$(inspect_api_tokens_for_upgrade "$token_file" 2>/dev/null || true)
IFS=$'\t' read -r status total_tokens agent_tokens expired_tokens expiring_tokens <<<"$report"
if [[ "$status" == "ok" ]]; then
if [[ "$agent_tokens" -gt 0 && "$expired_tokens" -lt "$agent_tokens" ]]; then
print_success "Agent reporting token scope is present for v6 compatibility"
if [[ "$expired_tokens" -gt 0 || "$expiring_tokens" -gt 0 ]]; then
print_warn "$expired_tokens agent reporting token(s) are expired and $expiring_tokens expire within 14 days"
fi
elif [[ "$agent_tokens" -gt 0 ]]; then
print_warn "All agent reporting tokens in api_tokens.json are expired"
print_info "Existing agents may need a refreshed install token after the upgrade."
else
print_warn "No agent reporting token scope was found in api_tokens.json"
print_info "Existing v5 host agents need a token with agent:report or host-agent:report to reconnect through v6 compatibility routes."
fi
elif grep -Eq '"(agent:report|host-agent:report|\\*)"' "$token_file" 2>/dev/null; then
print_warn "API token inventory could not be parsed, but an agent reporting scope string is present"
else
print_warn "API token inventory could not be parsed and no agent reporting scope string was found"
fi
print_info "The in-app updater performs the live registered-agent readiness check before applying v6 updates."
return 0
}
check_existing_installation() {
CURRENT_VERSION="" # Make it global so we can use it later
local BINARY_PATH=""
local detected_service="$SERVICE_NAME"
local service_available=false
# Check for the binary in expected locations
if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then
BINARY_PATH="$INSTALL_DIR/bin/pulse"
elif [[ -f "$INSTALL_DIR/pulse" ]]; then
BINARY_PATH="$INSTALL_DIR/pulse"
fi
# Detect actual service name if systemd is available
if command -v systemctl >/dev/null 2>&1; then
if [[ "$SERVICE_NAME_EXPLICIT" != "true" ]]; then
# Status 1 below means no installation. Keep an unavailable
# inventory distinct so main cannot fall through to a fresh install.
detected_service=$(detect_service_name) || return 2
SERVICE_NAME="$detected_service"
fi
service_available=true
fi
# Try to get version if binary exists
if [[ -n "$BINARY_PATH" ]]; then
CURRENT_VERSION=$($BINARY_PATH --version 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.]+)?' | head -1 || echo "unknown")
fi
if [[ "$service_available" == true ]] && systemctl is-active --quiet "$detected_service" 2>/dev/null; then
if [[ -n "$CURRENT_VERSION" && "$CURRENT_VERSION" != "unknown" ]]; then
print_info "Pulse $CURRENT_VERSION is currently running"
else
print_info "Pulse is currently running"
fi
return 0
elif [[ -n "$BINARY_PATH" ]]; then
if [[ -n "$CURRENT_VERSION" && "$CURRENT_VERSION" != "unknown" ]]; then
print_info "Pulse $CURRENT_VERSION is installed but not running"
else
print_info "Pulse is installed but not running"
fi
return 0
else
return 1
fi
}
install_dependencies() {
print_info "Installing dependencies..."
apt-get update -qq >/dev/null 2>&1
# Install essential dependencies plus jq for reliable JSON handling.
# openssh-client provides ssh-keygen, which signed release verification
# requires before installing downloaded or local release archives.
apt-get install -y -qq curl wget ca-certificates openssh-client jq >/dev/null 2>&1 || {
# If jq fails to install, keep the installer-critical dependencies.
apt-get install -y -qq curl wget ca-certificates openssh-client >/dev/null 2>&1
}
}
create_user() {
if ! id -u pulse &>/dev/null; then
print_info "Creating pulse user..."
useradd --system --home-dir $INSTALL_DIR --shell /bin/false pulse
fi
}
prune_config_backups() {
# Unattended updates create one snapshot per run; without rotation they
# fill small root filesystems (issue #1646). Keep the newest snapshots
# and delete the rest. Snapshot names embed a sortable timestamp, so a
# lexicographic sort orders them oldest-first.
local backup_parent="$1"
local snapshot_prefix="$2"
local keep="${CONFIG_BACKUP_KEEP_COUNT:-5}"
local backups=()
local entry
while IFS= read -r entry; do
[[ -n "$entry" ]] && backups+=("$entry")
done < <(find "$backup_parent" -maxdepth 1 -mindepth 1 -type d -name "${snapshot_prefix}*" 2>/dev/null | sort)
local excess=$(( ${#backups[@]} - keep ))
if (( excess <= 0 )); then
return 0
fi
local i
for (( i = 0; i < excess; i++ )); do
if ! rm -rf -- "${backups[$i]}"; then
print_warn "Could not remove old configuration backup ${backups[$i]}"
fi
done
}
backup_existing() {
if [[ -d "$CONFIG_DIR" ]]; then
print_info "Backing up existing configuration..."
local backup_parent backup_dir
backup_parent="$(dirname "$CONFIG_DIR")"
if [[ ! -w "$backup_parent" ]]; then
# The hardened pulse-update.service unit mounts /etc read-only
# (ProtectSystem=strict; only /opt/pulse, /etc/pulse and /tmp are
# writable), so keep the safety copy under the install dir there.
backup_parent="$INSTALL_DIR/config-backups"
mkdir -p "$backup_parent" || return 1
fi
ensure_config_backup_headroom "$CONFIG_DIR" "$backup_parent" || return 1
backup_dir="$backup_parent/$(basename "$CONFIG_DIR").backup.$(date +%Y%m%d-%H%M%S)"
if ! cp -a "$CONFIG_DIR" "$backup_dir"; then
rm -rf "$backup_dir"
return 1
fi
LAST_CONFIG_BACKUP_DIR="$backup_dir"
prune_config_backups "$backup_parent" "$(basename "$CONFIG_DIR").backup."
fi
}
# Drop the configuration snapshot most recently taken by backup_existing when
# the update aborts before it stages or replaces anything. That snapshot was a
# safety copy for an install that never began; leaving it behind makes the
# low-space condition that caused the failure progressively worse on every
# automatic retry until the filesystem fills (#2127).
discard_last_config_backup() {
if [[ -n "${LAST_CONFIG_BACKUP_DIR:-}" && -e "$LAST_CONFIG_BACKUP_DIR" ]]; then
if ! rm -rf -- "$LAST_CONFIG_BACKUP_DIR"; then
print_warn "Could not remove unneeded configuration backup ${LAST_CONFIG_BACKUP_DIR}"
fi
fi
LAST_CONFIG_BACKUP_DIR=""
}
resolve_archive_override() {
local archive_path="$1"
if [[ -z "$archive_path" ]]; then
print_error "Archive path is required"
return 1
fi
if [[ ! -f "$archive_path" ]]; then
print_error "Archive not found: $archive_path"
return 1
fi
if [[ ! -r "$archive_path" ]]; then
print_error "Archive is not readable: $archive_path"
return 1
fi
if command -v realpath >/dev/null 2>&1; then
realpath "$archive_path"
else
printf '%s\n' "$archive_path"
fi
}
infer_release_from_archive_name() {
local archive_name
archive_name=$(basename "$1")
if [[ "$archive_name" =~ ^pulse(-pro)?-(v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)*)-linux-(amd64|arm64|armv7|armv6|386)\.tar\.gz$ ]]; then
printf '%s\n' "${BASH_REMATCH[2]}"
return 0
fi
return 1
}
detect_pulse_architecture() {
local raw_arch="${1:-$(uname -m)}"
case $raw_arch in
x86_64|amd64)
printf 'amd64\n'
;;
aarch64|arm64)
printf 'arm64\n'
;;
armv7l|armv7)
printf 'armv7\n'
;;
*)
return 1
;;
esac
}
find_pulse_binary_in_dir() {
local dir="$1"
# Reject symlinks: a malicious archive could place a symlink at the
# expected binary path pointing at an arbitrary file outside the
# extract directory, which would then be cp'd to $INSTALL_DIR/bin/pulse.
if [[ -f "$dir/bin/pulse" && ! -L "$dir/bin/pulse" ]]; then
printf '%s\n' "$dir/bin/pulse"
return 0
fi
if [[ -f "$dir/pulse" && ! -L "$dir/pulse" ]]; then
printf '%s\n' "$dir/pulse"
return 0
fi
return 1
}
detect_pulse_binary_architecture() {
local binary_path="$1"
local file_info=""
local machine=""
local machine_id=""
if [[ ! -f "$binary_path" ]]; then
return 1
fi
if command -v readelf >/dev/null 2>&1; then
machine=$(LC_ALL=C readelf -h "$binary_path" 2>/dev/null | awk -F: '/Machine:/ {gsub(/^[[:space:]]+/, "", $2); print $2; exit}')
case "$machine" in
"Advanced Micro Devices X86-64")
printf 'amd64\n'
return 0
;;
"AArch64")
printf 'arm64\n'
return 0
;;
"ARM")
printf 'armv7\n'
return 0
;;
esac
fi
if command -v file >/dev/null 2>&1; then
file_info=$(LC_ALL=C file -b "$binary_path" 2>/dev/null || true)
case "$file_info" in
*"x86-64"*)
printf 'amd64\n'
return 0
;;
*"aarch64"*|*"ARM64"*)
printf 'arm64\n'
return 0
;;
*" ARM "*|ARM,*|*" EABI5"*)
printf 'armv7\n'
return 0
;;
esac
fi
machine_id=$(dd if="$binary_path" bs=1 skip=18 count=2 2>/dev/null | od -An -tu2 2>/dev/null | tr -d '[:space:]')
case "$machine_id" in
62)
printf 'amd64\n'
return 0
;;
183)
printf 'arm64\n'
return 0
;;
40)
printf 'armv7\n'
return 0
;;
esac
return 1
}
validate_pulse_binary_architecture() {
local binary_path="$1"
local target_arch="$2"
local archive_label="${3:-archive}"
local binary_arch=""
if [[ -z "$target_arch" ]]; then
print_error "Target architecture is required for archive validation"
return 1
fi
binary_arch=$(detect_pulse_binary_architecture "$binary_path" 2>/dev/null || true)
if [[ -z "$binary_arch" ]]; then
print_error "Could not determine Pulse binary architecture from $archive_label"
print_info "Use an official Pulse Linux release tarball for this machine."
return 1
fi
if [[ "$binary_arch" != "$target_arch" ]]; then
print_error "Archive architecture mismatch: $archive_label contains $binary_arch but this target requires $target_arch"
print_info "Download the official Pulse release tarball for the target architecture."
return 1
fi
return 0
}
create_temp_archive_path() {
local prefix="$1"
local temp_dir=""
# Reserving then unlinking a mktemp file does not reserve the .tar.gz or
# sidecar name. Keep both downloads inside one owner-only directory.
# A hidden staging prefix still yields the canonical archive file name.
local archive_name="${prefix##*/}"
archive_name="${archive_name#.}"
temp_dir=$(mktemp -d "${prefix}-XXXXXX") || return 1
printf '%s/%s.tar.gz\n' "$temp_dir" "$archive_name"
}
# Server archives are staged and extracted on the install filesystem. /tmp is
# tmpfs on Debian 13 and many LXC templates, where staged bytes count against
# the container's memory limit rather than its disk (#2785).
prepare_server_staging_parent() {
if ! mkdir -p -- "$INSTALL_DIR"; then
print_error "Could not prepare $INSTALL_DIR for staging the Pulse update; Pulse has not been stopped"
return 1
fi
sweep_stale_server_staging
printf '%s\n' "$INSTALL_DIR"
}
# Owner-only staging names created under $INSTALL_DIR by create_temp_archive_path
# and install_pulse_archive (mktemp's six-character suffix).
is_server_staging_name() {
case "$1" in
.pulse-extract-??????|.pulse-v*-linux-*-??????) return 0 ;;
esac
return 1
}
# Unlike /tmp, the install filesystem keeps an interrupted run's staging
# (~1 GB) across reboots. No installer lock exists, so remove only real
# directories (never symlinks or files) unchanged for longer than any live run
# takes. ctime, unlike mtime, cannot be carried back by extraction.
sweep_stale_server_staging() {
local entry=""
for entry in "$INSTALL_DIR"/.pulse-extract-* "$INSTALL_DIR"/.pulse-v*-linux-*; do
[[ -d "$entry" && ! -L "$entry" ]] || continue
is_server_staging_name "${entry##*/}" || continue
if [[ -n "$(find "$entry" -maxdepth 0 -cmin "+${SERVER_STAGING_STALE_MINUTES}" -print 2>/dev/null)" ]]; then
rm -rf -- "$entry" 2>/dev/null || print_warn "Could not remove stale update staging at $entry"
fi
done
return 0
}
# Recursive pulse ownership for the install directory, excluding root-consumed
# update staging so the extracted payload and archive are never handed to the
# service account while the installer still reads them.
chown_install_dir_for_pulse() {
local entry="" rc=0
chown pulse:pulse "$INSTALL_DIR" || rc=1
for entry in "$INSTALL_DIR"/* "$INSTALL_DIR"/.[!.]* "$INSTALL_DIR"/..?*; do
[[ -e "$entry" || -L "$entry" ]] || continue
is_server_staging_name "${entry##*/}" && continue
chown -R pulse:pulse "$entry" || rc=1
done
return "$rc"
}
cleanup_temp_archive_path() {
local archive_path="$1"
[[ -n "$archive_path" ]] || return 0
rm -f -- "$archive_path" "${archive_path}.sshsig"
# Delete only the two owned leaves and an empty parent, never a tree.
rmdir -- "$(dirname "$archive_path")" 2>/dev/null || true
}
cleanup_container_install_inputs() {
local script_dir="${1:-}" archive_path="${2:-}" archive_owned="${3:-false}"
if [[ -n "$script_dir" ]]; then
rm -f -- "$script_dir/install.sh" "$script_dir/install.sh.sshsig" "$script_dir/curl-error"
rmdir -- "$script_dir" 2>/dev/null || true
fi
if [[ "$archive_owned" == true ]]; then
cleanup_temp_archive_path "$archive_path"
fi
}
download_container_installer() {
local source_output="$1" directory_output="$2"
local download_url="" staging_dir="" staged_installer="" staged_signature=""
local download_success=false download_error="" attempt
local max_retries=3
if ! download_url=$(resolve_install_script_download_url); then
print_error "Failed to determine installer download URL for the selected release channel"
return 1
fi
if ! require_release_signature_verifier; then
print_error "Cannot verify the signed container installer"
return 1
fi
if ! staging_dir=$(mktemp -d /tmp/pulse-lxc-installer-XXXXXX); then
print_error "Could not prepare private container installer staging"
return 1
fi
# Return the owned directory before transport so the caller's EXIT trap
# can clean an interrupted download too. No path is returned for execution
# until both the complete transfer and the pinned signature are admitted.
printf -v "$directory_output" '%s' "$staging_dir"
staged_installer="$staging_dir/install.sh"
staged_signature="$staging_dir/install.sh.sshsig"
for attempt in $(seq 1 "$max_retries"); do
if [[ "$attempt" -gt 1 ]]; then
print_info "Retrying download (attempt $attempt/$max_retries)..."
sleep 2
fi
if command -v timeout >/dev/null 2>&1; then
if timeout 30 curl -fsSL --connect-timeout 10 --max-time 30 "$download_url" > "$staged_installer" 2>"$staging_dir/curl-error"; then
download_success=true
break
fi
elif curl -fsSL --connect-timeout 10 --max-time 30 "$download_url" > "$staged_installer" 2>"$staging_dir/curl-error"; then
download_success=true
break
fi
download_error=$(cat "$staging_dir/curl-error" 2>/dev/null || true)
done
if [[ "$download_success" != true ]]; then
print_error "Failed to download install script after $max_retries attempts"
[[ -z "$download_error" ]] || print_error "Error: $download_error"
return 1
fi
if ! curl -fsSL --connect-timeout 10 --max-time 30 "${download_url}.sshsig" > "$staged_signature" 2>"$staging_dir/curl-error"; then
print_error "Failed to download container installer signature; refusing to copy or run the installer"
return 1
fi
if ! verify_release_signature "$staged_installer" "$staged_signature" "downloaded container installer"; then
return 1
fi
printf -v "$source_output" '%s' "$staged_installer"
}
resolve_target_release() {
if [[ -n "${LATEST_RELEASE:-}" ]]; then
return 0
fi
if [[ -n "${FORCE_VERSION}" ]]; then
LATEST_RELEASE="${FORCE_VERSION}"
print_info "Installing specific version: $LATEST_RELEASE"
if command -v timeout >/dev/null 2>&1; then
if ! timeout 15 curl -fsS --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases/tags/$LATEST_RELEASE" > /dev/null 2>&1; then
print_warn "Could not verify version $LATEST_RELEASE, proceeding anyway..."
fi
else
if ! curl -fsS --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases/tags/$LATEST_RELEASE" > /dev/null 2>&1; then
print_warn "Could not verify version $LATEST_RELEASE, proceeding anyway..."
fi
fi
return 0
fi
if [[ -z "${UPDATE_CHANNEL:-}" ]]; then
UPDATE_CHANNEL="stable"
if [[ -n "${FORCE_CHANNEL}" ]]; then
UPDATE_CHANNEL="${FORCE_CHANNEL}"
print_info "Using $UPDATE_CHANNEL channel from command line"
else
CONFIGURED_CHANNEL=$(read_configured_update_channel 2>/dev/null || true)
if [[ "$CONFIGURED_CHANNEL" == "rc" ]]; then
UPDATE_CHANNEL="rc"
print_info "Prerelease channel detected in configuration"
fi
fi
fi
# Installer-script URLs and server archives must select the same release.
LATEST_RELEASE=$(resolve_latest_release_tag_for_channel "$UPDATE_CHANNEL" 2>/dev/null || true)
if [[ -z "$LATEST_RELEASE" ]]; then
print_error "Could not determine a published Pulse server release for the $UPDATE_CHANNEL channel"
print_info "Check GitHub connectivity and that jq is installed, or pin the release explicitly:"
print_info " bash install.sh --version vX.Y.Z"
exit 1
fi
# Never move an existing install backwards from auto-resolution. Explicit
# downgrades stay available via --version.
if [[ -n "${CURRENT_VERSION:-}" && "$CURRENT_VERSION" != "unknown" ]]; then
local resolve_cmp=0
compare_versions "$LATEST_RELEASE" "$CURRENT_VERSION" || resolve_cmp=$?
if [[ "$resolve_cmp" -eq 2 ]]; then
print_error "Resolved release $LATEST_RELEASE is older than the installed $CURRENT_VERSION; refusing to downgrade automatically"
print_info "To downgrade on purpose, run: bash install.sh --version $LATEST_RELEASE"
exit 1
fi
fi
print_info "Latest version: $LATEST_RELEASE"
}
download_release_archive() {
local release="$1"
local pulse_arch="$2"
local archive_path="$3"
local archive_name="pulse-${release}-linux-${pulse_arch}.tar.gz"
local download_url="https://github.com/$GITHUB_REPO/releases/download/$release/${archive_name}"
local signature_url="${download_url}.sshsig"
local signature_file="${archive_path}.sshsig"
DOWNLOAD_URL="$download_url"
print_info "Downloading from: $DOWNLOAD_URL"
if ! require_release_signature_verifier; then
print_error "Cannot verify signed Pulse release downloads"
return 1
fi
if ! wget -q --timeout=60 --tries=2 -O "$archive_path" "$download_url"; then
print_error "Failed to download Pulse release"
print_info "This can happen due to network issues or GitHub rate limiting"
print_info "You can try downloading manually from: $download_url"
return 1
fi
if ! wget -q --timeout=60 --tries=2 -O "$signature_file" "$signature_url"; then
rm -f "$signature_file" "$archive_path"
print_error "Failed to download signature for Pulse release"
print_info "Refusing to install without signature verification"
return 1
fi
# The signature stays alongside the archive (not in a mktemp temp file)
# so that install_pulse_archive can re-verify before extraction. This
# also lets the Proxmox LXC bootstrap path propagate the signature
# into the container alongside the archive.
if ! verify_release_signature "$archive_path" "$signature_file" "downloaded Pulse release"; then
rm -f "$signature_file" "$archive_path"
return 1
fi
print_info "Pulse release signature verified"
return 0
}
# Create (or refresh) the convenience symlink to the installed binary.
# Never fatal and idempotent: the link path can be unwritable — the generated
# pulse-update.service runs the unattended updater with ProtectSystem=strict
# (only the install dir, config dir and /tmp are writable), and transient
# read-only filesystems hit the same error (#1630). The symlink is a
# nice-to-have; an existing correct link is kept and a failure only warns.
install_binary_symlink() {
local target="$1"
local link_path="$2"
if [[ "$(readlink "$link_path" 2>/dev/null)" == "$target" ]]; then
print_success "Symlink already in place at $link_path"
return 0
fi
mkdir -p "$(dirname "$link_path")" 2>/dev/null || true
if ln -sf "$target" "$link_path" 2>/dev/null; then
print_success "Symlink created at $link_path"
else
print_warn "Could not create symlink at $link_path (read-only filesystem?); pulse remains available at $target"
fi
return 0
}
install_pulse_archive() {
local archive_path="$1"
local expected_release="${2:-}"
local signature_path="${archive_path}.sshsig"
local temp_extract=""
local binary_stage=""
local version_output=""
local service_name=""
local installed_version=""
local pulse_binary_path=""
local target_arch=""
if [[ ! -f "$archive_path" ]]; then
print_error "Archive not found: $archive_path"
return 1
fi
# Signature verification is mandatory at install time, regardless of
# how the archive got here. download_release_archive already verifies
# before this is called, but the --archive PATH path (and the LXC
# bootstrap that propagates --archive into the container) reach this
# function without going through download. Re-verifying here closes
# the local-tarball-swap window between download/propagation and
# extraction.
if [[ ! -f "$signature_path" ]]; then
print_error "Required signature sidecar not found: $signature_path"
print_info "Pulse archives must ship with a matching .sshsig file from the canonical release pipeline."
return 1
fi
if ! verify_release_signature "$archive_path" "$signature_path" "Pulse release archive at $archive_path"; then
return 1
fi
if [[ -z "$expected_release" ]]; then
expected_release=$(infer_release_from_archive_name "$archive_path" 2>/dev/null || true)
fi
local staging_parent=""
if ! staging_parent=$(prepare_server_staging_parent) ||
! temp_extract=$(mktemp -d "$staging_parent/.pulse-extract-XXXXXX"); then
print_error "Could not prepare archive extraction; Pulse has not been stopped"
return 1
fi
# --no-same-owner: do not honor uid/gid stored in the archive (extract as root, owned by root)
# --no-overwrite-dir: refuse to replace existing directory metadata with archive entries
if ! tar --no-same-owner --no-overwrite-dir -xzf "$archive_path" -C "$temp_extract"; then
print_error "Failed to extract Pulse release archive"
rm -rf "$temp_extract"
return 1
fi
pulse_binary_path=$(find_pulse_binary_in_dir "$temp_extract" 2>/dev/null || true)
if [[ -z "$pulse_binary_path" ]]; then
print_error "Pulse binary not found in archive"
rm -rf "$temp_extract"
return 1
fi
target_arch=$(detect_pulse_architecture 2>/dev/null || true)
if [[ -z "$target_arch" ]]; then
print_error "Unsupported architecture for archive install: $(uname -m)"
rm -rf "$temp_extract"
return 1
fi
if ! validate_pulse_binary_architecture "$pulse_binary_path" "$target_arch" "$(basename "$archive_path")"; then
rm -rf "$temp_extract"
return 1
fi
# Prepare the final executable on the destination filesystem while the old
# service keeps running. Copy/permissions/version failures must not create
# an outage, and rename must never expose a partially copied live binary.
if ! mkdir -p "$INSTALL_DIR/bin" || ! binary_stage=$(mktemp -d "$INSTALL_DIR/bin/.pulse-stage-XXXXXX"); then
print_error "Could not stage the Pulse binary; Pulse has not been stopped"
rm -rf "$temp_extract"
return 1
fi
if ! cp "$pulse_binary_path" "$binary_stage/pulse" || ! chmod 755 "$binary_stage/pulse" || ! chown pulse:pulse "$binary_stage/pulse"; then
print_error "Could not prepare the Pulse binary; Pulse has not been stopped"
rm -rf "$binary_stage" "$temp_extract"
return 1
fi
if ! version_output=$(timeout 5 "$binary_stage/pulse" --version 2>/dev/null); then
print_error "Staged Pulse binary could not report its version; Pulse has not been stopped"
rm -rf "$binary_stage" "$temp_extract"
return 1
fi
installed_version=$(printf '%s\n' "$version_output" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.]+)?' | head -1 || true)
if [[ -z "$installed_version" || ( -n "$expected_release" && "$installed_version" != "$expected_release" ) ]]; then
print_error "Staged Pulse version ${installed_version:-unknown} does not match ${expected_release:-a release version}; Pulse has not been stopped"
rm -rf "$binary_stage" "$temp_extract"
return 1
fi
if ! service_name=$(detect_service_name); then
rm -rf "$binary_stage" "$temp_extract"
return 1
fi
PULSE_WAS_ACTIVE="false"
if ! stop_pulse_for_replacement "$service_name"; then
rm -rf "$binary_stage" "$temp_extract"
return 1
fi
# Both paths are on the same filesystem. If rename fails the old binary is
# untouched; do not move it aside, delete it or re-extract an admitted file.
if ! mv -fT "$binary_stage/pulse" "$INSTALL_DIR/bin/pulse"; then
print_error "Failed to replace Pulse; the previous binary is unchanged"
rm -rf "$binary_stage" "$temp_extract"
recover_pulse_after_failed_replacement "$service_name"
return 1
fi
rm -rf "$binary_stage"
install_additional_agent_binaries "$expected_release" "$temp_extract"
deploy_agent_scripts "$temp_extract"
chown_install_dir_for_pulse
print_success "Pulse binary installed to $INSTALL_DIR/bin/pulse"
install_binary_symlink "$INSTALL_DIR/bin/pulse" "$BINARY_LINK_PATH"
if [[ -f "$temp_extract/VERSION" ]]; then
cp "$temp_extract/VERSION" "$INSTALL_DIR/VERSION"
chown pulse:pulse "$INSTALL_DIR/VERSION"
fi
print_success "Version verified: $installed_version"
restore_selinux_contexts
rm -rf "$temp_extract"
return 0
}
download_pulse() {
# Check if we should build from source - do this FIRST to avoid confusing version messages
if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
if ! build_from_source "$SOURCE_BRANCH"; then
print_error "Source build failed"
exit 1
fi
return 0
fi
if [[ "$BUILD_FROM_SOURCE" == "true" && "$SKIP_DOWNLOAD" != "true" ]]; then
print_error "Source build requested but download path was reached (internal error)"
exit 1
fi
# Only do download if not building from source
if [[ "$SKIP_DOWNLOAD" != "true" ]]; then
local archive_path=""
local expected_release=""
local raw_arch=""
local pulse_arch=""
local archive_from_temp=false
local inferred_release=""
local staging_parent=""
# The archive download and its extraction live on the install
# filesystem, never /tmp. Debian 13 and many LXC templates mount /tmp
# as tmpfs, which the kernel charges to the container's memory limit:
# a ~280 MB archive plus its ~750 MB extracted tree OOM-killed a 1 GiB
# LXC's init mid-update and Proxmox stopped the container (#2785).
if ! staging_parent=$(prepare_server_staging_parent) ||
! ensure_update_disk_headroom "$staging_parent" "$INSTALL_DIR"; then
# The configuration snapshot taken earlier is not needed: the update
# never reached staging. Remove it so a retry loop on a low-space
# filesystem does not accumulate backups and worsen the shortage
# (#2127).
discard_last_config_backup
exit 1
fi
raw_arch=$(uname -m)
pulse_arch=$(detect_pulse_architecture "$raw_arch") || {
print_error "Unsupported architecture: $raw_arch"
exit 1
}
print_info "Detected architecture: $raw_arch ($pulse_arch)"
if [[ -n "$ARCHIVE_OVERRIDE" ]]; then
archive_path=$(resolve_archive_override "$ARCHIVE_OVERRIDE") || exit 1
inferred_release=$(infer_release_from_archive_name "$archive_path" 2>/dev/null || true)
if [[ -n "$FORCE_VERSION" && -n "$inferred_release" && "$FORCE_VERSION" != "$inferred_release" ]]; then
print_error "Archive version $inferred_release does not match requested version $FORCE_VERSION"
exit 1
fi
if [[ -n "$FORCE_VERSION" ]]; then
expected_release="$FORCE_VERSION"
else
expected_release="$inferred_release"
fi
if [[ -n "$expected_release" ]]; then
LATEST_RELEASE="$expected_release"
print_info "Installing Pulse from local archive: $archive_path"
print_info "Archive version: $LATEST_RELEASE"
else
print_info "Installing Pulse from local archive: $archive_path"
fi
else
print_info "Downloading Pulse..."
resolve_target_release
archive_path=$(create_temp_archive_path "$staging_parent/.pulse-${LATEST_RELEASE}-linux-${pulse_arch}") || {
print_error "Failed to create temporary archive path"
exit 1
}
archive_from_temp=true
if ! download_release_archive "$LATEST_RELEASE" "$pulse_arch" "$archive_path"; then
cleanup_temp_archive_path "$archive_path"
exit 1
fi
expected_release="$LATEST_RELEASE"
fi
if ! run_upgrade_readiness_preflight "$CURRENT_VERSION" "$expected_release"; then
if [[ "$archive_from_temp" == true ]]; then
cleanup_temp_archive_path "$archive_path"
fi
exit 1
fi
# Archive admission and destination staging own the confirmed stop:
# local signature/content/architecture/version failures leave Pulse up.
if ! install_pulse_archive "$archive_path" "$expected_release"; then
if [[ "$archive_from_temp" == "true" ]]; then
cleanup_temp_archive_path "$archive_path"
fi
exit 1
fi
rm -f "$BUILD_FROM_SOURCE_MARKER"
if [[ "$archive_from_temp" == "true" ]]; then
cleanup_temp_archive_path "$archive_path"
fi
fi # End of SKIP_DOWNLOAD check
}
prefetch_pulse_archive_for_container() {
local output_var="$1"
local raw_arch=""
local pulse_arch=""
local archive_path=""
resolve_target_release
raw_arch=$(uname -m)
pulse_arch=$(detect_pulse_architecture "$raw_arch") || {
print_error "Unsupported architecture for container install: $raw_arch"
return 1
}
print_info "Prefetching Pulse release archive on Proxmox host..."
print_info "Detected architecture: $raw_arch ($pulse_arch)"
archive_path=$(create_temp_archive_path "/tmp/pulse-${LATEST_RELEASE}-linux-${pulse_arch}-lxc") || {
print_error "Failed to create temporary archive path"
return 1
}
if ! download_release_archive "$LATEST_RELEASE" "$pulse_arch" "$archive_path"; then
cleanup_temp_archive_path "$archive_path"
return 1
fi
printf -v "$output_var" '%s' "$archive_path"
return 0
}
copy_unified_agent_binaries_from_dir() {
local source_dir="$1"
if [[ -z "$source_dir" ]] || [[ ! -d "$source_dir/bin" ]]; then
return 1
fi
local copied=0
shopt -s nullglob
for agent_file in "$source_dir"/bin/pulse-agent-*; do
[[ -e "$agent_file" ]] || continue
local base
base=$(basename "$agent_file")
# Skip the wrapper script (pulse-agent without arch suffix)
if [[ "$base" == "pulse-agent" ]]; then
continue
fi
cp -a "$agent_file" "$INSTALL_DIR/bin/$base"
if [[ ! -L "$INSTALL_DIR/bin/$base" ]]; then
chmod +x "$INSTALL_DIR/bin/$base"
fi
chown -h pulse:pulse "$INSTALL_DIR/bin/$base" || true
copied=1
done
shopt -u nullglob
if [[ $copied -eq 0 ]]; then
return 1
fi
return 0
}
install_additional_agent_binaries() {
local version="$1"
local source_dir="${2:-}"
local local_unified_installed=0
if [[ -z "$version" ]]; then
return
fi
local unified_targets=("linux-amd64" "linux-arm64" "linux-armv7" "linux-armv6" "linux-386" "darwin-amd64" "darwin-arm64" "windows-amd64" "windows-arm64" "windows-386")
# Prefer locally available agents from the extracted archive to avoid network reliance
if copy_unified_agent_binaries_from_dir "$source_dir"; then
local_unified_installed=1
fi
local unified_missing_targets=()
for target in "${unified_targets[@]}"; do
if [[ "$target" == windows-* ]]; then
if [[ ! -e "$INSTALL_DIR/bin/pulse-agent-$target" && ! -e "$INSTALL_DIR/bin/pulse-agent-$target.exe" ]]; then
unified_missing_targets+=("$target")
fi
else
if [[ ! -e "$INSTALL_DIR/bin/pulse-agent-$target" ]]; then
unified_missing_targets+=("$target")
fi
fi
done
if [[ ${#unified_missing_targets[@]} -eq 0 ]]; then
if [[ $local_unified_installed -eq 1 ]]; then
print_success "Unified agent binaries installed"
fi
return
fi
if [[ $local_unified_installed -eq 1 ]]; then
print_success "Unified agent binaries installed"
fi
print_info "Skipping eager cross-architecture agent bundle download during install"
print_info "Missing unified agent binaries will be fetched on demand when requested"
return 0
}
deploy_agent_scripts() {
local extract_dir="$1"
if [[ -z "$extract_dir" ]]; then
print_warn "No extraction directory provided for script deployment"
return
fi
mkdir -p "$INSTALL_DIR/scripts"
local scripts=(
"install-container-agent.sh"
"install-docker.sh"
"install.sh"
"install.ps1"
)
local deployed=0
for script in "${scripts[@]}"; do
if [[ -f "$extract_dir/scripts/$script" ]]; then
cp "$extract_dir/scripts/$script" "$INSTALL_DIR/scripts/$script"
chmod 755 "$INSTALL_DIR/scripts/$script"
chown pulse:pulse "$INSTALL_DIR/scripts/$script"
# Deploy the detached release signatures next to the script. The
# running server serves these local copies at /install.sh and
# /install.ps1 (the "Install on Linux/Windows" agent wizard), and for
# published releases the handler only serves the local file when its
# .sig and .sshsig sidecars are present. Without them it falls back to
# proxying the top-level GitHub install.sh asset, which is the SERVER
# installer, not the agent installer, so the wizard's --url/--token-file
# command is rejected with "Unknown option". The Docker image deploys
# these same sidecars (see Dockerfile); LXC/systemd installs must match.
local sidecar
for sidecar in "${script}.sig" "${script}.sshsig"; do
if [[ -f "$extract_dir/scripts/$sidecar" ]]; then
cp "$extract_dir/scripts/$sidecar" "$INSTALL_DIR/scripts/$sidecar"
chmod 644 "$INSTALL_DIR/scripts/$sidecar"
chown pulse:pulse "$INSTALL_DIR/scripts/$sidecar"
fi
done
deployed=$((deployed + 1))
fi
done
if [[ $deployed -gt 0 ]]; then
print_success "Deployed $deployed agent installation script(s)"
else
print_warn "No agent installation scripts found in archive"
fi
}
build_from_source() {
local branch="${1:-main}"
local original_dir
original_dir=$(pwd)
local temp_build=""
# Keep this aligned with go.mod's toolchain directive for consistent builds and security posture.
local GO_MIN_VERSION="1.25.9"
local GO_INSTALLED=false
local arch=""
local go_arch=""
local service_name=""
local binary_stage=""
local version_output=""
local source_revision=""
print_info "Building Pulse from source (branch: $branch)..."
print_info "Installing build dependencies..."
if ! (apt-get update >/dev/null 2>&1 && apt-get install -y git make nodejs npm wget >/dev/null 2>&1); then
print_error "Failed to install build dependencies"
return 1
fi
if command -v go >/dev/null 2>&1; then
local GO_VERSION
GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//')
if [[ "$(printf '%s\n' "$GO_MIN_VERSION" "$GO_VERSION" | sort -V | head -n1)" == "$GO_MIN_VERSION" ]]; then
GO_INSTALLED=true
print_info "Go $GO_VERSION is installed (meets minimum $GO_MIN_VERSION)"
else
print_info "Go $GO_VERSION is too old. Installing Go $GO_MIN_VERSION..."
fi
fi
if [[ "$GO_INSTALLED" != "true" ]]; then
arch=$(uname -m)
case "$arch" in
x86_64)
go_arch="amd64"
;;
aarch64)
go_arch="arm64"
;;
armv7l)
go_arch="armv6l"
;;
*)
print_error "Unsupported architecture for Go: $arch"
return 1
;;
esac
if ! cd /tmp; then
print_error "Failed to prepare Go installation directory"
cd "$original_dir" >/dev/null 2>&1 || true
return 1
fi
if ! wget -q "https://go.dev/dl/go${GO_MIN_VERSION}.linux-${go_arch}.tar.gz"; then
print_error "Failed to download Go toolchain"
cd "$original_dir" >/dev/null 2>&1 || true
return 1
fi
rm -rf /usr/local/go
if ! tar -C /usr/local -xzf "go${GO_MIN_VERSION}.linux-${go_arch}.tar.gz"; then
print_error "Failed to extract Go toolchain"
rm -f "go${GO_MIN_VERSION}.linux-${go_arch}.tar.gz"
cd "$original_dir" >/dev/null 2>&1 || true
return 1
fi
rm -f "go${GO_MIN_VERSION}.linux-${go_arch}.tar.gz"
cd "$original_dir" >/dev/null 2>&1 || true
fi
export PATH=/usr/local/go/bin:$PATH
temp_build=$(mktemp -d /tmp/pulse-build-XXXXXX)
if [[ ! -d "$temp_build" ]]; then
print_error "Failed to create temporary build directory"
return 1
fi
if ! git clone --depth 1 --branch "$branch" "https://github.com/$GITHUB_REPO.git" "$temp_build/Pulse" >/dev/null 2>&1; then
print_error "Failed to clone repository (branch: $branch)"
cd "$original_dir" >/dev/null 2>&1 || true
rm -rf "$temp_build"
return 1
fi
if ! cd "$temp_build/Pulse"; then
print_error "Failed to enter source checkout"
cd "$original_dir" >/dev/null 2>&1 || true
rm -rf "$temp_build"
return 1
fi
if ! cd frontend-modern; then
print_error "Frontend directory missing in repository"
cd "$original_dir" >/dev/null 2>&1 || true
rm -rf "$temp_build"
return 1
fi
if ! npm ci >/dev/null 2>&1; then
print_warn "npm ci failed, falling back to npm install..."
if ! npm install >/dev/null 2>&1; then
print_error "Failed to install frontend dependencies"
cd "$original_dir" >/dev/null 2>&1 || true
rm -rf "$temp_build"
return 1
fi
fi
if ! npm run build >/dev/null 2>&1; then
print_error "Failed to build frontend"
cd "$original_dir" >/dev/null 2>&1 || true
rm -rf "$temp_build"
return 1
fi
cd ..
if ! make build >/dev/null 2>&1; then
print_error "Failed to build backend"
cd "$original_dir" >/dev/null 2>&1 || true
rm -rf "$temp_build"
return 1
fi
# A successful build command alone does not admit its executable. Prepare
# it on the destination filesystem before stopping the running service,
# just as for signed archives. Source versions may be development builds.
if ! mkdir -p "$INSTALL_DIR/bin" || ! binary_stage=$(mktemp -d "$INSTALL_DIR/bin/.pulse-stage-XXXXXX"); then
print_error "Could not stage the built Pulse binary; Pulse has not been stopped"
cd "$original_dir" >/dev/null 2>&1 || true
rm -rf "$temp_build"
return 1
fi
if ! cp pulse "$binary_stage/pulse" || ! chmod 755 "$binary_stage/pulse" || ! chown pulse:pulse "$binary_stage/pulse"; then
print_error "Could not prepare the built Pulse binary; Pulse has not been stopped"
cd "$original_dir" >/dev/null 2>&1 || true
rm -rf "$binary_stage" "$temp_build"
return 1
fi
if ! version_output=$(timeout 5 "$binary_stage/pulse" --version 2>/dev/null) || [[ -z "${version_output//[[:space:]]/}" ]]; then
print_error "Built Pulse binary could not report its version; Pulse has not been stopped"
cd "$original_dir" >/dev/null 2>&1 || true
rm -rf "$binary_stage" "$temp_build"
return 1
fi
if ! source_revision=$(git rev-parse --short HEAD) || [[ -z "$source_revision" ]]; then
print_error "Could not identify the source build; Pulse has not been stopped"
cd "$original_dir" >/dev/null 2>&1 || true
rm -rf "$binary_stage" "$temp_build"
return 1
fi
if ! service_name=$(detect_service_name); then
cd "$original_dir" >/dev/null 2>&1 || true
rm -rf "$binary_stage" "$temp_build"
return 1
fi
PULSE_WAS_ACTIVE="false"
if ! stop_pulse_for_replacement "$service_name"; then
cd "$original_dir" >/dev/null 2>&1 || true
rm -rf "$binary_stage" "$temp_build"
return 1
fi
if ! mv -fT "$binary_stage/pulse" "$INSTALL_DIR/bin/pulse"; then
print_error "Failed to replace built Pulse; the previous binary is unchanged"
cd "$original_dir" >/dev/null 2>&1 || true
rm -rf "$binary_stage" "$temp_build"
recover_pulse_after_failed_replacement "$service_name"
return 1
fi
rm -rf "$binary_stage"
mkdir -p "$INSTALL_DIR/scripts"
for script_name in install-container-agent.sh install-docker.sh install.sh install.ps1; do
if [[ -f "scripts/$script_name" ]]; then
cp "scripts/$script_name" "$INSTALL_DIR/scripts/$script_name"
chmod 755 "$INSTALL_DIR/scripts/$script_name"
fi
done
install_binary_symlink "$INSTALL_DIR/bin/pulse" "$BINARY_LINK_PATH"
echo "$branch-$source_revision" > "$INSTALL_DIR/VERSION"
echo "$branch" > "$BUILD_FROM_SOURCE_MARKER"
chown_install_dir_for_pulse 2>/dev/null || true
chown pulse:pulse "$BUILD_FROM_SOURCE_MARKER" 2>/dev/null || true
cd "$original_dir" >/dev/null 2>&1 || true
rm -rf "$temp_build"
SKIP_DOWNLOAD=true
print_success "Successfully built and installed Pulse from source (branch: $branch)"
return 0
}
setup_directories() {
print_info "Setting up directories..."
# Create directories (only if they don't exist)
mkdir -p "$CONFIG_DIR"
mkdir -p "$INSTALL_DIR"
# Set permissions (preserve existing files)
# Use chown without -R on CONFIG_DIR to avoid changing existing file permissions
chown pulse:pulse "$CONFIG_DIR"
chown_install_dir_for_pulse
chmod 700 "$CONFIG_DIR"
# Ensure critical config files retain proper permissions if they exist
for config_file in "$CONFIG_DIR"/alerts.json "$CONFIG_DIR"/system.json "$CONFIG_DIR"/*.enc "$CONFIG_DIR"/*.json; do
if [[ -f "$config_file" ]]; then
chown pulse:pulse "$config_file"
fi
done
for config_dir in "$CONFIG_DIR"/alerts "$CONFIG_DIR"/notifications "$CONFIG_DIR"/audit; do
if [[ -d "$config_dir" ]]; then
chown -R pulse:pulse "$config_dir"
fi
done
# Create .env file with mock mode explicitly disabled (unless it already exists)
if [[ ! -f "$CONFIG_DIR/.env" ]]; then
cat > "$CONFIG_DIR/.env" << 'EOF'
# Pulse Environment Configuration
# This file is loaded by systemd when starting Pulse
# Mock mode - set to "true" to enable mock/demo data for testing
# WARNING: Only enable this on demo/test servers, never in production!
PULSE_MOCK_MODE=false
# Mock configuration (only used when PULSE_MOCK_MODE=true)
#PULSE_MOCK_NODES=3
#PULSE_MOCK_VMS_PER_NODE=3
#PULSE_MOCK_LXCS_PER_NODE=3
#PULSE_MOCK_RANDOM_METRICS=true
#PULSE_MOCK_STOPPED_PERCENT=6
EOF
fi
if [[ -f "$CONFIG_DIR/.env" ]]; then
chown pulse:pulse "$CONFIG_DIR/.env"
chmod 600 "$CONFIG_DIR/.env"
fi
if [[ -f "$CONFIG_DIR/.encryption.key" ]]; then
chown pulse:pulse "$CONFIG_DIR/.encryption.key"
chmod 600 "$CONFIG_DIR/.encryption.key"
fi
}
setup_update_command() {
# Create update command at /bin/update for ProxmoxVE LXC detection
# This allows the backend to detect ProxmoxVE installations
local service_name="${SERVICE_NAME:-${PULSE_SERVICE_NAME:-pulse}}"
local install_dir="${INSTALL_DIR:-${PULSE_INSTALL_DIR:-/opt/pulse}}"
local config_dir="${CONFIG_DIR:-${PULSE_CONFIG_DIR:-/etc/pulse}}"
local binary_link_path="${BINARY_LINK_PATH:-${PULSE_BINARY_LINK_PATH:-/usr/local/bin/pulse}}"
local update_helper_path="${UPDATE_HELPER_PATH:-${PULSE_UPDATE_HELPER_PATH:-/bin/update}}"
local auto_update_dest="${AUTO_UPDATE_DEST:-${PULSE_AUTO_UPDATE_DEST:-/usr/local/bin/pulse-auto-update.sh}}"
local update_service_path="${UPDATE_SERVICE_PATH:-${PULSE_UPDATE_SERVICE_PATH:-/etc/systemd/system/${service_name}-update.service}}"
local update_timer_path="${UPDATE_TIMER_PATH:-${PULSE_UPDATE_TIMER_PATH:-/etc/systemd/system/${service_name}-update.timer}}"
local profile_path="${PULSE_PROFILE_PATH:-/etc/profile}"
local bashrc_path="${PULSE_BASHRC_PATH:-/etc/bash.bashrc}"
# Writing the helper must never abort the installer: with errexit active a
# failed redirect here used to kill the run after the new binary was
# already installed and the service stopped (#1630). The path can
# legitimately be unwritable — the unattended updater runs under
# ProtectSystem=strict, and transient read-only filesystems hit this too.
mkdir -p "$(dirname "$update_helper_path")" 2>/dev/null || true
if ! cat 2>/dev/null > "$update_helper_path" <<EOF
#!/usr/bin/env bash
# Pulse update command
# This script re-runs the Pulse installer using the configured manual channel
set -euo pipefail
INSTALL_ROOT=$(printf '%q' "$install_dir")
MARKER_FILE="\${INSTALL_ROOT}/BUILD_FROM_SOURCE"
CONFIG_DIR=$(printf '%q' "$config_dir")
INSTALLER_URL="https://github.com/${GITHUB_REPO}/releases/latest/download/install.sh"
PULSE_SERVICE_NAME=$(printf '%q' "$service_name")
PULSE_INSTALL_DIR=$(printf '%q' "$install_dir")
PULSE_CONFIG_DIR=$(printf '%q' "$config_dir")
PULSE_BINARY_LINK_PATH=$(printf '%q' "$binary_link_path")
PULSE_UPDATE_HELPER_PATH=$(printf '%q' "$update_helper_path")
PULSE_AUTO_UPDATE_DEST=$(printf '%q' "$auto_update_dest")
PULSE_UPDATE_SERVICE_PATH=$(printf '%q' "$update_service_path")
PULSE_UPDATE_TIMER_PATH=$(printf '%q' "$update_timer_path")
INSTALLER_SIG_URL="\${INSTALLER_URL}.sshsig"
INSTALL_SIGNATURE_IDENTITY=$(printf '%q' "$INSTALL_SIGNATURE_IDENTITY")
INSTALL_SIGNATURE_NAMESPACE=$(printf '%q' "$INSTALL_SIGNATURE_NAMESPACE")
PINNED_RELEASE_SSH_PUBLIC_KEY=$(printf '%q' "$PINNED_RELEASE_SSH_PUBLIC_KEY")
$(declare -f release_signature_key_available)
$(declare -f require_release_signature_verifier)
$(declare -f verify_release_signature)
extra_args=()
installer_env=(
"PULSE_SERVICE_NAME=\$PULSE_SERVICE_NAME"
"PULSE_INSTALL_DIR=\$PULSE_INSTALL_DIR"
"PULSE_CONFIG_DIR=\$PULSE_CONFIG_DIR"
"PULSE_BINARY_LINK_PATH=\$PULSE_BINARY_LINK_PATH"
"PULSE_UPDATE_HELPER_PATH=\$PULSE_UPDATE_HELPER_PATH"
"PULSE_AUTO_UPDATE_DEST=\$PULSE_AUTO_UPDATE_DEST"
"PULSE_UPDATE_SERVICE_PATH=\$PULSE_UPDATE_SERVICE_PATH"
"PULSE_UPDATE_TIMER_PATH=\$PULSE_UPDATE_TIMER_PATH"
)
helper_args=()
if [[ \$# -gt 0 ]]; then
helper_args=("\$@")
fi
auto_selector_allowed=true
if [[ \${#helper_args[@]} -gt 0 ]]; then
for helper_arg in "\${helper_args[@]}"; do
case "\$helper_arg" in
-h|--help|--uninstall|--version|--rc|--pre|--prerelease|--stable|--source|--from-source|--branch|--archive|--archive=*|--skip-upgrade-preflight)
auto_selector_allowed=false
break
;;
esac
done
fi
if [[ "\$auto_selector_allowed" == "true" ]]; then
if [[ -f "\$MARKER_FILE" ]]; then
branch=\$(tr -d '\r\n' <"\$MARKER_FILE" 2>/dev/null || true)
if [[ -n "\$branch" ]]; then
extra_args+=(--source "\$branch")
fi
elif [[ -e "\${CONFIG_DIR}/system.json" || -L "\${CONFIG_DIR}/system.json" ]]; then
# Never infer a release channel from nested, quoted or partial JSON.
# Missing/null channels retain the stable default; malformed or unknown
# preferences stop before download. An explicit selector above wins.
if [[ ! -f "\${CONFIG_DIR}/system.json" ]] || ! command -v jq >/dev/null 2>&1; then
echo "Cannot read the saved update channel; a regular system.json and jq are required. Select --stable or --rc explicitly, or restore the configuration." >&2
exit 1
fi
if ! configured_channel=\$(jq -er -s '
if length != 1 or (.[0] | type) != "object" then
error("expected one configuration object")
else .[0] end
| .updateChannel
| if . == null then "stable" else . end
| if type != "string" then error("invalid update channel") else . end
| gsub("^\\\\s+|\\\\s+\$"; "") | ascii_downcase
| if . == "" then "stable" else . end
| if . == "stable" or . == "rc" then . else error("unknown update channel") end
' "\${CONFIG_DIR}/system.json" 2>/dev/null); then
echo "Cannot parse the saved update channel; no installer downloaded. Select --stable or --rc explicitly, or restore the configuration." >&2
exit 1
fi
if [[ "\$configured_channel" == "rc" ]]; then
extra_args+=(--rc)
else
# Bind the parsed default too: the installer has its own saved
# preference reader, which must not re-interpret the same file.
extra_args+=(--stable)
fi
fi
fi
if [[ \${#helper_args[@]} -gt 0 ]]; then
extra_args+=("\${helper_args[@]}")
fi
echo "Updating Pulse..."
tmp_installer=\$(mktemp /tmp/pulse-update-installer.XXXXXX)
tmp_signature=\$(mktemp /tmp/pulse-update-installer.sig.XXXXXX)
trap 'rm -f "\$tmp_installer" "\$tmp_signature"' EXIT
if ! curl -fsSL "\$INSTALLER_URL" -o "\$tmp_installer"; then
echo "Failed to download Pulse installer from \$INSTALLER_URL" >&2
exit 1
fi
if ! curl -fsSL "\$INSTALLER_SIG_URL" -o "\$tmp_signature"; then
echo "Failed to download Pulse installer signature from \$INSTALLER_SIG_URL" >&2
exit 1
fi
if ! verify_release_signature "\$tmp_installer" "\$tmp_signature" "downloaded Pulse installer"; then
exit 1
fi
if [[ \${#extra_args[@]} -gt 0 ]]; then
env "\${installer_env[@]}" bash "\$tmp_installer" "\${extra_args[@]}"
else
env "\${installer_env[@]}" bash "\$tmp_installer"
fi
echo ""
echo "Update complete! Pulse will restart automatically."
EOF
then
print_warn "Could not write the 'update' helper to $update_helper_path (read-only filesystem?); skipping optional update command setup"
return 0
fi
chmod +x "$update_helper_path" 2>/dev/null || \
print_warn "Could not mark $update_helper_path executable"
# Ensure /usr/local/bin is in PATH for all users
if ! grep -q '/usr/local/bin' "$profile_path" 2>/dev/null; then
echo 'export PATH="/usr/local/bin:$PATH"' 2>/dev/null >> "$profile_path" || \
print_warn "Could not update PATH in $profile_path"
fi
# Also add to bash profile if it exists
if [[ -f "$bashrc_path" ]] && ! grep -q '/usr/local/bin' "$bashrc_path" 2>/dev/null; then
echo 'export PATH="/usr/local/bin:$PATH"' 2>/dev/null >> "$bashrc_path" || \
print_warn "Could not update PATH in $bashrc_path"
fi
}
download_auto_update_script() {
local asset_base_url=""
asset_base_url=$(resolve_release_asset_base_url)
local url="${asset_base_url}/pulse-auto-update.sh"
local signature_url="${url}.sshsig"
local dest="${AUTO_UPDATE_DEST:-${PULSE_AUTO_UPDATE_DEST:-/usr/local/bin/pulse-auto-update.sh}}"
local attempts=0
local max_attempts=3
local connect_timeout=15
local max_time=60
if ! require_release_signature_verifier; then
print_warn "Cannot verify signed auto-update helper downloads"
return 1
fi
while (( attempts < max_attempts )); do
((attempts++))
local curl_status=0
local signature_file=""
if command -v timeout >/dev/null 2>&1; then
if timeout $((max_time + 10)) curl -fsSL --connect-timeout "$connect_timeout" --max-time "$max_time" -o "$dest" "$url"; then
:
else
curl_status=$?
fi
else
if curl -fsSL --connect-timeout "$connect_timeout" --max-time "$max_time" -o "$dest" "$url"; then
:
else
curl_status=$?
fi
fi
if [[ $curl_status -eq 0 ]]; then
signature_file=$(mktemp /tmp/pulse-auto-update-signature.XXXXXX)
if command -v timeout >/dev/null 2>&1; then
timeout $((max_time + 10)) curl -fsSL --connect-timeout "$connect_timeout" --max-time "$max_time" -o "$signature_file" "$signature_url" || curl_status=$?
else
curl -fsSL --connect-timeout "$connect_timeout" --max-time "$max_time" -o "$signature_file" "$signature_url" || curl_status=$?
fi
if [[ $curl_status -ne 0 ]]; then
print_warn "Failed to download signature for pulse-auto-update.sh"
rm -f "$signature_file" "$dest"
elif ! verify_release_signature "$dest" "$signature_file" "downloaded pulse-auto-update.sh"; then
rm -f "$signature_file" "$dest"
curl_status=1
else
rm -f "$signature_file"
chmod +x "$dest"
print_info "pulse-auto-update.sh signature verified"
return 0
fi
fi
print_warn "Auto-update download attempt $attempts/$max_attempts failed (curl exit code $curl_status)"
if (( attempts < max_attempts )); then
local wait_time=$((attempts * 3))
print_info "Retrying in ${wait_time}s..."
sleep "$wait_time"
fi
done
return 1
}
configure_auto_update_script_repo() {
local dest="${1:-${AUTO_UPDATE_DEST:-${PULSE_AUTO_UPDATE_DEST:-/usr/local/bin/pulse-auto-update.sh}}}"
if [[ ! -f "$dest" ]]; then
return 1
fi
local tmp
tmp=$(mktemp /tmp/pulse-auto-update-script.XXXXXX)
if ! awk -v repo="$GITHUB_REPO" '
BEGIN { inserted = 0 }
/^#!/ {
print
if (!inserted) {
print "GITHUB_REPO=\"" repo "\""
inserted = 1
}
next
}
/^GITHUB_REPO=/ {
if (!inserted) {
print "GITHUB_REPO=\"" repo "\""
inserted = 1
}
next
}
{ print }
END {
if (!inserted) {
print "GITHUB_REPO=\"" repo "\""
}
}
' "$dest" > "$tmp"; then
rm -f "$tmp"
return 1
fi
mv "$tmp" "$dest"
chmod +x "$dest"
}
# Probes whether new entries can be created in a directory. This is exactly
# the capability the staged-rename swaps below need (rename creates and
# unlinks entries in the target directory), and unlike `test -w` it gives an
# unambiguous answer for root under a ProtectSystem= sandbox.
auto_update_dir_writable() {
local dir="$1"
local probe=""
[[ -d "$dir" ]] || return 1
probe=$(mktemp "${dir}/.pulse-write-probe.XXXXXX" 2>/dev/null) || return 1
rm -f "$probe"
return 0
}
# Guards the helper swap: a truncated or shebang-less helper would replace a
# working one with a file systemd cannot execute as the update unit's
# ExecStart. This matters because configure_auto_update_script_repo's awk
# happily emits a lone GITHUB_REPO= line from empty input, so a silently
# failed copy would otherwise produce a plausible-looking one-line "helper"
# whose only effect is to return success and never update anything.
auto_update_helper_is_sane() {
local path="$1"
[[ -s "$path" ]] || return 1
local first_line=""
IFS= read -r first_line < "$path" || true
[[ "$first_line" == '#!'* ]]
}
# Migration for boxes installed before the update sandbox was widened.
#
# Unattended updates run this installer from pulse-update.service, and on a box
# whose unit predates the widened ReadWritePaths that sandbox covers only the
# install dir, config dir and /tmp. The corrected unit therefore cannot be
# written by the very run that would install it (EROFS), so without an escape
# no deployed box ever receives the single-schedule timer or the widened
# sandbox through auto-update — updater fixes reach only manually reinstalled
# boxes. The in-app Go update pipeline cannot carry the migration either:
# pulse.service runs as User=pulse under its own ProtectSystem=strict with
# ReadWritePaths limited to the install and config dirs, so it cannot write
# /etc/systemd/system or /usr/local/bin.
#
# systemd-run asks PID 1 to fork the repair, so the transient unit starts in
# the host mount namespace rather than inheriting this unit's sandbox. The
# system bus stays reachable from inside the sandbox because ProtectSystem=
# only remounts the hierarchy read-only and a read-only mount does not block
# connect() on an AF_UNIX socket (the same reason the unit's own
# ExecCondition=systemctl call works). The installer is copied into the
# install dir rather than run from its current path because the helper stages
# it under /tmp and PrivateTmp=yes hides that from PID 1; re-execing this
# already-signature-verified file also avoids a second unverified download.
migrate_auto_update_assets_outside_sandbox() {
local blocked_dir="$1"
# The escaped run writes with no sandbox; if it still cannot write, a
# second escape would loop forever.
if [[ "${PULSE_AUTO_UPDATE_ASSET_REPAIR:-}" == "1" ]]; then
return 1
fi
if [[ "$(id -u)" -ne 0 ]]; then
return 1
fi
# Absent on hosts without systemd (and inside Docker), where there is no
# timer driving this path in the first place.
if ! command -v systemd-run >/dev/null 2>&1; then
return 1
fi
local installer_src="${BASH_SOURCE[0]:-}"
if [[ -z "$installer_src" || ! -r "$installer_src" ]]; then
# curl | bash leaves no file to re-exec.
return 1
fi
local install_dir="${INSTALL_DIR:-${PULSE_INSTALL_DIR:-/opt/pulse}}"
local repair_copy="${install_dir}/.pulse-update-asset-repair.sh"
if ! cp "$installer_src" "$repair_copy" 2>/dev/null; then
return 1
fi
chmod 0700 "$repair_copy" 2>/dev/null || true
print_info "The auto-update sandbox blocks ${blocked_dir}; refreshing the update assets through a transient systemd unit."
local status=0
systemd-run --quiet --collect --wait \
--unit="pulse-update-asset-repair-$$" \
--description="Refresh Pulse auto-update helper and units outside the update sandbox" \
--property=Type=oneshot \
--setenv=PULSE_AUTO_UPDATE_ASSET_REPAIR=1 \
--setenv="PULSE_SERVICE_NAME=${SERVICE_NAME:-${PULSE_SERVICE_NAME:-pulse}}" \
--setenv="PULSE_INSTALL_DIR=${install_dir}" \
--setenv="PULSE_CONFIG_DIR=${CONFIG_DIR:-${PULSE_CONFIG_DIR:-/etc/pulse}}" \
--setenv="PULSE_AUTO_UPDATE_DEST=${AUTO_UPDATE_DEST:-${PULSE_AUTO_UPDATE_DEST:-/usr/local/bin/pulse-auto-update.sh}}" \
--setenv="PULSE_UPDATE_SERVICE_PATH=${UPDATE_SERVICE_PATH:-${PULSE_UPDATE_SERVICE_PATH:-/etc/systemd/system/pulse-update.service}}" \
--setenv="PULSE_UPDATE_TIMER_PATH=${UPDATE_TIMER_PATH:-${PULSE_UPDATE_TIMER_PATH:-/etc/systemd/system/pulse-update.timer}}" \
/usr/bin/env bash "$repair_copy" --repair-auto-update-units || status=$?
rm -f "$repair_copy"
if [[ "$status" -ne 0 ]]; then
print_warn "Could not refresh the auto-update assets outside the sandbox (systemd-run exited ${status})."
return 1
fi
print_success "Auto-update helper and units refreshed outside the update sandbox."
return 0
}
# A runtime mask may live in /run while we write /etc, and a persistent mask
# may live outside a configured destination. Checking only those destination
# symlinks can therefore override an effective operator stop. Inspect both
# exact unit names before even staging assets, including sandbox repair/setup.
auto_update_units_refreshable() {
local timer_path="$1" service_path="$2" asset=""
for asset in "$timer_path" "$service_path"; do
if [[ -L "$asset" && "$asset" -ef /dev/null ]]; then
print_info "Auto-update unit is deliberately masked ($asset); preserving the existing helper and units."
return 1
fi
done
local timer_unit service_unit unit_files=""
timer_unit="$(basename "$timer_path")"
service_unit="$(basename "$service_path")"
if ! command -v systemctl >/dev/null 2>&1 ||
! unit_files=$(unit_file_inventory "$timer_unit" "$service_unit"); then
print_warn "Cannot confirm auto-update unit masks; preserving the existing helper and units."
return 1
fi
local unit="" state="" remainder="" seen_timer=false seen_service=false
while read -r unit state remainder; do
[[ -n "$unit" ]] || continue
if [[ "$unit" == "$timer_unit" && "$seen_timer" == false ]]; then
seen_timer=true
elif [[ "$unit" == "$service_unit" && "$seen_service" == false ]]; then
seen_service=true
else
print_warn "Unexpected auto-update unit inventory; preserving the existing helper and units."
return 1
fi
case "$state" in
masked|masked-runtime)
print_info "Auto-update unit is deliberately $state ($unit); preserving the existing helper and units."
return 1 ;;
enabled|enabled-runtime|disabled|static|indirect|linked|linked-runtime|alias|generated|transient) ;;
*)
print_warn "Cannot confirm auto-update unit state ($unit); preserving the existing helper and units."
return 1 ;;
esac
done <<< "$unit_files"
# A successful empty inventory is valid for first-time setup; failed or
# malformed stdout above is never treated as absence or consent.
return 0
}
# Installs the auto-update helper script and rewrites the systemd
# service/timer units. Shared by setup_auto_updates (first-time enable) and
# refresh_auto_updates (updates/reinstalls where the timer already exists).
# Does not touch system.json or the timer's enabled/started state.
install_auto_update_assets() {
local service_name="${SERVICE_NAME:-${PULSE_SERVICE_NAME:-pulse}}"
local install_dir="${INSTALL_DIR:-${PULSE_INSTALL_DIR:-/opt/pulse}}"
local config_dir="${CONFIG_DIR:-${PULSE_CONFIG_DIR:-/etc/pulse}}"
local auto_update_dest="${AUTO_UPDATE_DEST:-${PULSE_AUTO_UPDATE_DEST:-/usr/local/bin/pulse-auto-update.sh}}"
local update_service_path="${UPDATE_SERVICE_PATH:-${PULSE_UPDATE_SERVICE_PATH:-/etc/systemd/system/${service_name}-update.service}}"
local update_timer_path="${UPDATE_TIMER_PATH:-${PULSE_UPDATE_TIMER_PATH:-/etc/systemd/system/${service_name}-update.timer}}"
local update_timer_unit
update_timer_unit="$(basename "$update_timer_path")"
auto_update_units_refreshable "$update_timer_path" "$update_service_path" || return 1
local auto_update_bin_dir update_unit_dir update_timer_dir
auto_update_bin_dir="$(dirname "$auto_update_dest")"
update_unit_dir="$(dirname "$update_service_path")"
update_timer_dir="$(dirname "$update_timer_path")"
local unit_write_dirs="$update_unit_dir"
if [[ "$update_timer_dir" != "$update_unit_dir" ]]; then
unit_write_dirs="$unit_write_dirs $update_timer_dir"
fi
# Everything below stages into these directories and commits with a
# rename, so check up front that entries can be created in each of them.
# A box whose update unit predates the widened sandbox fails here, and the
# migration escapes that sandbox to write the corrected assets instead.
local asset_dir
for asset_dir in "$auto_update_bin_dir" $unit_write_dirs; do
if auto_update_dir_writable "$asset_dir"; then
continue
fi
if migrate_auto_update_assets_outside_sandbox "$asset_dir"; then
return 0
fi
print_warn "Cannot write to ${asset_dir} to refresh the auto-update assets."
return 1
done
# Stage the helper next to its destination and only swap it in once it is
# fully configured. During unattended updates install.sh runs under the
# pulse-update.service sandbox, where the file being replaced is the very
# script bash is executing — the same-directory mv keeps the swap an
# atomic rename — and a failed download or configure must leave the
# previously working helper in place rather than deleting it out from
# under the timer's ExecStart.
local staged_helper
if ! staged_helper=$(mktemp "${auto_update_dest}.staged.XXXXXX"); then
print_warn "Cannot write to ${auto_update_bin_dir} to stage the auto-update helper."
return 1
fi
if [[ -f "$install_dir/scripts/pulse-auto-update.sh" ]]; then
# Copy auto-update script if it exists in the release. Both callers
# invoke this function under `if !`, which suppresses errexit for the
# whole body, so an unchecked cp (ENOSPC, EIO) would fall through and
# swap a zero-byte "helper" over the working one.
if ! cp "$install_dir/scripts/pulse-auto-update.sh" "$staged_helper"; then
print_warn "Could not copy the bundled auto-update helper into ${auto_update_bin_dir}."
rm -f "$staged_helper"
return 1
fi
else
print_info "Downloading auto-update script..."
if ! AUTO_UPDATE_DEST="$staged_helper" download_auto_update_script; then
print_warn "Could not download the auto-update helper after multiple attempts."
rm -f "$staged_helper"
return 1
fi
fi
if ! configure_auto_update_script_repo "$staged_helper"; then
print_warn "Could not configure the auto-update helper for the selected release repo."
rm -f "$staged_helper"
return 1
fi
# Render both units beside their destinations before committing anything.
# A bare truncating `cat > "$unit"` with no status check could leave a
# half-written unit behind and still report success, because the only
# thing left in this function is safe_systemctl daemon-reload, which
# deliberately returns 0 even when it fails.
local staged_service="${update_service_path}.tmp"
local staged_timer="${update_timer_path}.tmp"
# The heredoc is unquoted so the installer substitutes paths/names; the
# rendered unit must contain no unexpanded $ (in particular never $$,
# which bash expands to the installer's PID and which broke ExecCondition
# before v6.0.0).
if ! cat > "$staged_service" <<EOF
[Unit]
Description=Automatic Pulse update check and install
Documentation=$(repo_web_url)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
Group=root
# Skip auto-update run unless the Pulse service it updates is active
ExecCondition=/bin/sh -c 'systemctl is-active --quiet ${service_name}'
ExecStart=${auto_update_dest}
Restart=no
TimeoutStartSec=600
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${service_name}-update
Environment="PULSE_SERVICE_NAME=$service_name"
Environment="PULSE_INSTALL_DIR=$install_dir"
Environment="PULSE_CONFIG_DIR=$config_dir"
Environment="PULSE_UPDATE_TIMER_UNIT=$update_timer_unit"
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
# The helper and unit directories are writable on purpose: the installer this
# service runs refreshes the auto-update helper and rewrites these units, and
# without write access updater fixes only ever reach boxes via manual installs.
# These are directory grants rather than the four individual file paths
# because every one of those writes commits with a rename from a sibling
# staging file (so the previously working helper survives a failed refresh),
# and rename needs write access on the containing directory. The tradeoff is
# that this unit can write any unit file: it already runs the installer as
# root, so the grant does not widen what a compromised installer could do,
# but it does mean the sandbox no longer contains a buggy installer's writes
# to /etc/systemd/system.
ReadWritePaths=$install_dir $config_dir /tmp $auto_update_bin_dir $unit_write_dirs
PrivateNetwork=no
Nice=10
[Install]
WantedBy=multi-user.target
EOF
then
print_warn "Could not write ${update_service_path}."
rm -f "$staged_helper" "$staged_service"
return 1
fi
if ! cat > "$staged_timer" <<EOF
[Unit]
Description=Daily check for Pulse updates
Documentation=$(repo_web_url)
After=network-online.target
Wants=network-online.target
[Timer]
# One update attempt per day, landing in the 02:00-06:00 window. A second
# OnCalendar=daily line here used to double the schedule with an extra
# midnight trigger (issue #1643).
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=4h
Persistent=true
AccuracySec=1h
[Install]
WantedBy=timers.target
EOF
then
print_warn "Could not write ${update_timer_path}."
rm -f "$staged_helper" "$staged_service" "$staged_timer"
return 1
fi
if ! auto_update_helper_is_sane "$staged_helper"; then
print_warn "The staged auto-update helper is empty or has no interpreter line; keeping the previously installed helper."
rm -f "$staged_helper" "$staged_service" "$staged_timer"
return 1
fi
# Explicit mode, not chmod +x: mktemp creates the staging file 0600, and
# the installed helper should keep the 0755 a plain copy would have had.
if ! chmod 0755 "$staged_helper"; then
print_warn "Could not make the staged auto-update helper executable."
rm -f "$staged_helper" "$staged_service" "$staged_timer"
return 1
fi
# Commit. Each rename is atomic and same-filesystem, so no consumer ever
# observes a partial file — including the timer's ExecStart, which on the
# unattended path is the very helper being replaced.
if ! mv "$staged_helper" "$auto_update_dest"; then
print_warn "Could not install the auto-update helper at ${auto_update_dest}."
rm -f "$staged_helper" "$staged_service" "$staged_timer"
return 1
fi
if ! mv "$staged_service" "$update_service_path"; then
print_warn "Could not install ${update_service_path}."
rm -f "$staged_service" "$staged_timer"
return 1
fi
if ! mv "$staged_timer" "$update_timer_path"; then
print_warn "Could not install ${update_timer_path}."
rm -f "$staged_timer"
return 1
fi
# Reload systemd daemon
safe_systemctl daemon-reload
}
setup_auto_updates() {
print_info "Setting up automatic updates..."
local desired_channel
desired_channel=$(selected_update_channel)
local service_name="${SERVICE_NAME:-${PULSE_SERVICE_NAME:-pulse}}"
local config_dir="${CONFIG_DIR:-${PULSE_CONFIG_DIR:-/etc/pulse}}"
local update_timer_path="${UPDATE_TIMER_PATH:-${PULSE_UPDATE_TIMER_PATH:-/etc/systemd/system/${service_name}-update.timer}}"
local update_timer_unit
update_timer_unit="$(basename "$update_timer_path")"
# A reinstall over a removed /etc/pulse reaches setup_auto_updates before
# setup_directories has recreated the config directory, and the
# system.json write below would fail with "No such file or directory"
# while the run still reported that auto-updates were enabled. Do this
# before installing assets or enabling the timer so a failure here leaves
# nothing half-configured.
if ! mkdir -p "$config_dir"; then
print_warn "Could not create $config_dir. Continuing without automatic updates."
ENABLE_AUTO_UPDATES=false
return 0
fi
if ! install_auto_update_assets; then
print_warn "Continuing without automatic updates. Review the reported unit state or asset error before retrying."
ENABLE_AUTO_UPDATES=false
return 0
fi
# Enable timer but don't start it yet
safe_systemctl enable "$update_timer_unit" || true
# Update system.json to enable auto-updates
if [[ -f "$config_dir/system.json" ]]; then
# Update existing file
local temp_file="/tmp/system_$$.json"
if command -v jq &> /dev/null; then
jq --arg channel "$desired_channel" '.autoUpdateEnabled = true | .updateChannel = $channel' "$config_dir/system.json" > "$temp_file" && mv "$temp_file" "$config_dir/system.json"
else
# Fallback to sed if jq not available
# First check if autoUpdateEnabled already exists in the file
if grep -q '"autoUpdateEnabled"' "$config_dir/system.json"; then
# Field exists, update its value
sed -i 's/"autoUpdateEnabled":[^,}]*/"autoUpdateEnabled":true/' "$config_dir/system.json"
else
# Field doesn't exist, add it after the opening brace
sed -i 's/^{/{\"autoUpdateEnabled\":true,/' "$config_dir/system.json"
fi
if grep -q '"updateChannel"' "$config_dir/system.json"; then
sed -i "s/\"updateChannel\":[^,}]*/\"updateChannel\":\"$desired_channel\"/" "$config_dir/system.json"
else
sed -i "s/^{/{\"updateChannel\":\"$desired_channel\",/" "$config_dir/system.json"
fi
fi
else
# Create new file with auto-updates enabled
printf '{"autoUpdateEnabled":true,"updateChannel":"%s","pollingInterval":5}\n' "$desired_channel" > "$config_dir/system.json"
fi
chown pulse:pulse "$config_dir/system.json" 2>/dev/null || true
# Start the timer
safe_systemctl start "$update_timer_unit" || true
print_success "Automatic updates enabled (daily check between 02:00 and 06:00)"
}
# Updates and reinstalls only run setup_auto_updates when the user opts in,
# but a box that already has the timer keeps whatever helper script and units
# a previous install wrote — e.g. a v5.1-pinned helper that never selects a
# v6 release, so unattended updates silently never resume after upgrading.
# Refresh the installed assets unconditionally instead, without changing the
# user's choices: system.json and the timer's enabled state stay untouched.
refresh_auto_updates() {
print_info "Refreshing the installed auto-update helper..."
if ! install_auto_update_assets; then
print_warn "Could not refresh the auto-update helper; the previously installed one may be stale."
print_warn "Review the reported unit state or asset error before retrying; enabling updates is not a mask repair."
fi
return 0
}
install_systemd_service() {
print_info "Installing systemd service..."
# Use existing service name if found, otherwise use default
local existing_service="$SERVICE_NAME"
if [[ "$SERVICE_NAME_EXPLICIT" != "true" ]]; then
existing_service=$(detect_service_name) || return 1
fi
if [[ "$existing_service" == "pulse-backend" ]] && [[ -f "/etc/systemd/system/pulse-backend.service" ]]; then
# Keep using pulse-backend for compatibility (ProxmoxVE)
SERVICE_NAME="pulse-backend"
print_info "Using existing service name: pulse-backend"
fi
cat > /etc/systemd/system/$SERVICE_NAME.service << EOF
[Unit]
Description=Pulse Monitoring Server
After=network.target
[Service]
Type=simple
User=pulse
Group=pulse
WorkingDirectory=$INSTALL_DIR
ExecStart=$INSTALL_DIR/bin/pulse
Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal
SyslogLevelPrefix=true
Environment="HOME=$INSTALL_DIR"
Environment="PATH=$INSTALL_DIR/.local/bin:$INSTALL_DIR/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
Environment="PULSE_DATA_DIR=$CONFIG_DIR"
Environment="PULSE_DEPLOYMENT_METHOD=systemd"
# Pulse prefixes each JSON line with its zerolog priority. systemd consumes
# the prefix into PRIORITY and retains the original JSON as MESSAGE.
Environment="PULSE_LOG_JOURNAL_LEVEL_PREFIX=true"
EnvironmentFile=-$CONFIG_DIR/.env
EOF
# Add port configuration if not default
if [[ "${FRONTEND_PORT:-7655}" != "7655" ]]; then
cat >> /etc/systemd/system/$SERVICE_NAME.service << EOF
Environment="FRONTEND_PORT=$FRONTEND_PORT"
EOF
fi
cat >> /etc/systemd/system/$SERVICE_NAME.service << EOF
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=$INSTALL_DIR $CONFIG_DIR
# ICMP availability probes exec the system ping binary; NoNewPrivileges strips
# its setuid/file capabilities, so grant CAP_NET_RAW ambiently instead (#1554).
AmbientCapabilities=CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_RAW
[Install]
WantedBy=multi-user.target
EOF
# Reload systemd daemon
safe_systemctl daemon-reload
}
# Updates keep an existing unit file untouched (it may carry user
# customizations such as FRONTEND_PORT), but a half-removed installation —
# binary still present, unit deleted by hand (#1663) — must get a fresh unit
# or the enable/start below fails with "Unit ... could not be found".
ensure_systemd_service_installed() {
command -v systemctl >/dev/null 2>&1 || return 0
if [[ ! -f "/etc/systemd/system/${SERVICE_NAME}.service" ]]; then
print_warn "systemd unit ${SERVICE_NAME}.service is missing; recreating it"
install_systemd_service || return 1
fi
}
# Tracks whether Pulse was running before an update-time stop, so start_pulse can
# guarantee it comes back up afterward instead of silently leaving it stopped
# (#1323: on unprivileged LXC the installer's restart can silently fail).
PULSE_WAS_ACTIVE="false"
# Poll until the service is active, up to timeout_seconds (default 20).
wait_for_service_active() {
local service_name=$1
local timeout_seconds="${2:-20}"
local elapsed=0
while (( elapsed < timeout_seconds )); do
if timeout 5 systemctl is-active --quiet "$service_name" 2>/dev/null; then
return 0
fi
sleep 1
((elapsed += 1))
done
return 1
}
# Stop Pulse for an update, remembering whether it was running so start_pulse can
# guarantee it comes back up afterward (#1323).
stop_pulse_for_update() {
PULSE_WAS_ACTIVE="false"
stop_pulse_for_replacement "$SERVICE_NAME"
}
# After an update, if Pulse was running beforehand, make sure it is running again:
# retry one explicit start, and surface a clear error if it still will not come up
# — rather than printing a success completion over a stopped service (#1323).
ensure_pulse_running_after_update() {
[[ "$PULSE_WAS_ACTIVE" == "true" ]] || return 0
PULSE_WAS_ACTIVE="false"
if wait_for_service_active "$SERVICE_NAME" 20; then
return 0
fi
print_warn "Pulse did not come back up after the update; retrying start..."
safe_systemctl start "$SERVICE_NAME" || true
if wait_for_service_active "$SERVICE_NAME" 20; then
print_success "Pulse is running again."
return 0
fi
print_error "Pulse was running before the update but did not come back up (#1323)."
print_info "The new binary is installed; investigate why the service will not start:"
print_info " systemctl status $SERVICE_NAME"
print_info " journalctl -u $SERVICE_NAME -n 50"
return 1
}
start_pulse() {
print_info "Starting Pulse..."
# A missing unit is a broken installation, not an unprivileged-container
# quirk: "Unit ... could not be found" must never be softened into the
# container notes below and then reported as success (#1663). The
# installer always writes /etc/systemd/system/<name>.service; systemctl
# cat covers units living elsewhere.
if command -v systemctl >/dev/null 2>&1 \
&& [[ ! -f "/etc/systemd/system/${SERVICE_NAME}.service" ]] \
&& ! timeout 5 systemctl cat "${SERVICE_NAME}.service" >/dev/null 2>&1; then
print_error "systemd unit ${SERVICE_NAME}.service does not exist; Pulse was not started"
print_info "Re-run the installer to recreate the service unit"
return 1
fi
# Try to enable/start service (may fail in unprivileged containers)
if ! safe_systemctl enable $SERVICE_NAME; then
print_info "Note: systemctl enable failed (common in unprivileged containers)"
fi
if ! safe_systemctl start $SERVICE_NAME; then
print_info "Note: systemctl start failed (common in unprivileged containers)"
if [[ "$PULSE_WAS_ACTIVE" != "true" ]]; then
print_info "The service will start automatically when the container starts"
return 0
fi
# An update stopped a running Pulse; do not accept a silent start failure
# here — fall through to the active-state verification below (#1323).
fi
# Wait for service to start
sleep 3
if timeout 5 systemctl is-active --quiet $SERVICE_NAME 2>/dev/null; then
print_success "Pulse started successfully"
else
print_error "Failed to start Pulse"
journalctl -u $SERVICE_NAME -n 20 2>/dev/null || true
# Don't exit, just warn
print_info "Service may not be running. You might need to start it manually."
fi
ensure_pulse_running_after_update
}
create_marker_file() {
# Create marker file for version tracking (helps with Community Scripts compatibility)
touch ~/.pulse 2>/dev/null || true
}
print_completion() {
local IP=$(hostname -I | awk '{print $1}')
# Get the port from the service file or use default
local PORT
PORT=$(current_frontend_port)
echo
print_header
print_success "Pulse installation completed!"
echo
local PULSE_URL="http://${IP}:${PORT}"
echo -e "${GREEN}Access Pulse at:${NC} ${PULSE_URL}"
echo
echo -e "${YELLOW}Quick commands:${NC}"
echo " systemctl status $SERVICE_NAME - Check status"
echo " systemctl restart $SERVICE_NAME - Restart"
echo " journalctl -u $SERVICE_NAME -f - View logs"
echo
echo -e "${YELLOW}Management:${NC}"
echo " Update: $(build_printed_management_command update)"
echo " Reset: $(build_printed_management_command reset)"
echo " Uninstall: $(build_printed_management_command uninstall)"
# Show auto-update status if timer exists
if update_timer_exists; then
echo
echo -e "${YELLOW}Auto-updates:${NC}"
if update_timer_enabled; then
echo -e " Status: ${GREEN}Enabled${NC} (daily check between 2-6 AM)"
echo " Disable: systemctl disable --now $UPDATE_TIMER_UNIT"
else
echo -e " Status: ${YELLOW}Disabled${NC}"
echo " Enable: systemctl enable --now $UPDATE_TIMER_UNIT"
fi
fi
# Show bootstrap token on fresh install
local TOKEN_DATA_DIR="${CONFIG_DIR:-/etc/pulse}"
local TOKEN_FILE="$TOKEN_DATA_DIR/.bootstrap_token"
if [[ -f "$TOKEN_FILE" ]]; then
local -a BOOTSTRAP_TOKEN_COMMAND=()
if [[ -x "$BINARY_LINK_PATH" ]]; then
BOOTSTRAP_TOKEN_COMMAND=("$BINARY_LINK_PATH" "bootstrap-token")
elif [[ -x "$INSTALL_DIR/bin/pulse" ]]; then
BOOTSTRAP_TOKEN_COMMAND=("$INSTALL_DIR/bin/pulse" "bootstrap-token")
elif command -v pulse >/dev/null 2>&1; then
BOOTSTRAP_TOKEN_COMMAND=("$(command -v pulse)" "bootstrap-token")
fi
echo
if [[ ${#BOOTSTRAP_TOKEN_COMMAND[@]} -gt 0 ]]; then
if ! PULSE_DATA_DIR="$TOKEN_DATA_DIR" "${BOOTSTRAP_TOKEN_COMMAND[@]}"; then
local QUOTED_TOKEN_DATA_DIR
local TOKEN_COMMAND_DISPLAY
printf -v QUOTED_TOKEN_DATA_DIR '%q' "$TOKEN_DATA_DIR"
printf -v TOKEN_COMMAND_DISPLAY '%q ' "${BOOTSTRAP_TOKEN_COMMAND[@]}"
TOKEN_COMMAND_DISPLAY="${TOKEN_COMMAND_DISPLAY% }"
print_warn "Bootstrap token exists but could not be displayed automatically."
print_info "Run: PULSE_DATA_DIR=$QUOTED_TOKEN_DATA_DIR $TOKEN_COMMAND_DISPLAY"
fi
else
echo -e "${YELLOW}╔═══════════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${YELLOW}║ BOOTSTRAP TOKEN REQUIRED FOR FIRST-TIME SETUP ║${NC}"
echo -e "${YELLOW}╠═══════════════════════════════════════════════════════════════════════╣${NC}"
echo -e "${YELLOW}║${NC} Run this command on the Pulse host to reveal the setup token: ${YELLOW}║${NC}"
printf "${YELLOW}║${NC} PULSE_DATA_DIR=%-47s${YELLOW}║${NC}\n" "$TOKEN_DATA_DIR"
printf "${YELLOW}║${NC} %s/bin/pulse bootstrap-token%-37s${YELLOW}║${NC}\n" "$INSTALL_DIR" ""
echo -e "${YELLOW}╠═══════════════════════════════════════════════════════════════════════╣${NC}"
echo -e "${YELLOW}║${NC} Paste the token into the unlock screen in your browser. ${YELLOW}║${NC}"
echo -e "${YELLOW}║${NC} This token will be automatically deleted after successful setup. ${YELLOW}║${NC}"
echo -e "${YELLOW}╚═══════════════════════════════════════════════════════════════════════╝${NC}"
fi
fi
echo
}
build_printed_management_command() {
local action=$1
local update_helper_path="${UPDATE_HELPER_PATH:-${PULSE_UPDATE_HELPER_PATH:-/bin/update}}"
local -a args=()
case "$action" in
update)
;;
reset)
args+=(--reset)
;;
uninstall)
args+=(--uninstall)
;;
*)
return 1
;;
esac
if [[ -n "${FORCE_VERSION:-}" ]] && [[ "$action" != "uninstall" ]]; then
args=(--version "$FORCE_VERSION" "${args[@]}")
elif [[ "${FORCE_CHANNEL:-${UPDATE_CHANNEL:-stable}}" == "rc" ]] && [[ "$action" != "uninstall" ]]; then
args=(--rc "${args[@]}")
fi
printf '%q' "$update_helper_path"
if [[ ${#args[@]} -eq 0 ]]; then
printf '\n'
return 0
fi
local arg
for arg in "${args[@]}"; do
printf ' %q' "$arg"
done
printf '\n'
}
# Main installation flow
main() {
# Skip Proxmox host check if we're already inside a container
if [[ "$IN_CONTAINER" != "true" ]] && check_proxmox_host; then
create_lxc_container
exit 0
fi
print_header
check_root
detect_os
check_docker_environment
# Check for existing installation FIRST before asking for configuration
local existing_install_status=0
check_existing_installation || existing_install_status=$?
if [[ "$existing_install_status" -gt 1 ]]; then
return "$existing_install_status"
fi
if [[ "$existing_install_status" -eq 0 ]]; then
# If building from source was requested, skip the update prompt
if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
create_user
setup_directories
if ! build_from_source "$SOURCE_BRANCH"; then
exit 1
fi
setup_update_command
install_systemd_service || return 1
if [[ "$ENABLE_AUTO_UPDATES" == "true" ]]; then
setup_auto_updates
elif update_timer_exists; then
refresh_auto_updates
fi
start_pulse
create_marker_file
print_completion
exit 0
fi
# If a specific version was requested, just update to it
if [[ -n "${FORCE_VERSION}" ]]; then
# Determine if this is an upgrade, downgrade, or reinstall
local action_word="Installing"
if [[ -n "$CURRENT_VERSION" ]] && [[ "$CURRENT_VERSION" != "unknown" ]]; then
local compare_result
compare_versions "$FORCE_VERSION" "$CURRENT_VERSION" && compare_result=$? || compare_result=$?
case $compare_result in
0) action_word="Reinstalling" ;;
1) action_word="Updating to" ;;
2) action_word="Downgrading to" ;;
esac
fi
print_info "${action_word} version ${FORCE_VERSION}..."
LATEST_RELEASE="${FORCE_VERSION}"
offer_existing_auto_updates
# Detect the actual service name before trying to stop it
SERVICE_NAME=$(detect_service_name) || return 1
if ! run_upgrade_readiness_preflight "$CURRENT_VERSION" "$LATEST_RELEASE"; then
exit 1
fi
backup_existing
create_user
download_pulse
# A half-removed installation (binary present, /etc/pulse or the
# unit file deleted, #1663) reaches this update path; recreate
# the environment the steps below assume instead of crashing on
# a missing config dir or reporting success over a missing unit.
setup_directories
setup_update_command
ensure_systemd_service_installed || return 1
# Setup auto-updates if requested
if [[ "$ENABLE_AUTO_UPDATES" == "true" ]]; then
setup_auto_updates
elif update_timer_exists; then
refresh_auto_updates
fi
start_pulse
create_marker_file
print_completion
return 0
fi
# Use the same complete, server-only metadata admission as downloads.
# A chart tag or a draft must never become an existing-install action.
local STABLE_VERSION=""
STABLE_VERSION=$(resolve_latest_release_tag_for_channel stable 2>/dev/null || true)
local RC_VERSION=""
RC_VERSION=$(resolve_latest_release_tag_for_channel rc 2>/dev/null || true)
# Determine default update channel
UPDATE_CHANNEL="stable"
# Allow override via command line
if [[ -n "${FORCE_CHANNEL}" ]]; then
UPDATE_CHANNEL="${FORCE_CHANNEL}"
else
CONFIGURED_CHANNEL=$(read_configured_update_channel 2>/dev/null || true)
if [[ "$CONFIGURED_CHANNEL" == "rc" ]]; then
UPDATE_CHANNEL="rc"
fi
fi
echo
echo "What would you like to do?"
# Show update options based on available versions
local menu_option=1
local stable_choice="" rc_choice=""
if [[ -n "$STABLE_VERSION" ]] && [[ "$STABLE_VERSION" != "$CURRENT_VERSION" ]]; then
stable_choice=$menu_option
echo "${menu_option}) Update to $STABLE_VERSION (stable)"
((menu_option++))
fi
if [[ -n "$RC_VERSION" ]] && [[ "$RC_VERSION" != "$STABLE_VERSION" ]] && [[ "$RC_VERSION" != "$CURRENT_VERSION" ]]; then
rc_choice=$menu_option
echo "${menu_option}) Update to $RC_VERSION (prerelease preview)"
((menu_option++))
fi
echo "${menu_option}) Reinstall current version"
((menu_option++))
echo "${menu_option}) Remove Pulse"
((menu_option++))
echo "${menu_option}) Cancel"
local max_option=$menu_option
# Try to read user choice interactively
# safe_read handles both normal and piped input (via /dev/tty)
local automatic_choice=false
local requested_channel="$UPDATE_CHANNEL"
if [[ "$IN_DOCKER" == "true" ]]; then
# In Docker, always auto-select
print_info "Docker environment detected. Auto-selecting update option."
automatic_choice=true
elif safe_read "Select option [1-${max_option}]: " choice; then
# Successfully read user choice (either from stdin or /dev/tty)
: # Do nothing, choice was set
else
# safe_read failed - truly non-interactive
print_info "Non-interactive mode detected. Auto-selecting update option."
automatic_choice=true
fi
if [[ "$automatic_choice" == true ]]; then
# Rows disappear when a release is unavailable or already installed.
# Fixed option numbers could then select preview, reinstall or REMOVE.
# Only a newer release in the requested channel is an automatic action.
local automatic_release="$STABLE_VERSION"
choice="$stable_choice"
if [[ "$requested_channel" == rc ]]; then
automatic_release="$RC_VERSION"
if [[ "$RC_VERSION" == "$STABLE_VERSION" ]]; then
choice="$stable_choice"
else
choice="$rc_choice"
fi
fi
if [[ -z "$automatic_release" ]]; then
print_error "Cannot determine a published Pulse server release for the $requested_channel channel. Existing installation is unchanged; check connectivity and jq, or deliberately choose an exact version with --version."
return 1
fi
if [[ -n "$CURRENT_VERSION" && "$CURRENT_VERSION" != unknown ]]; then
local automatic_compare=0
compare_versions "$automatic_release" "$CURRENT_VERSION" || automatic_compare=$?
case "$automatic_compare" in
0)
print_info "$CURRENT_VERSION is already the selected $requested_channel release. Existing installation is unchanged."
return 0
;;
2)
print_error "Selected release $automatic_release is older than $CURRENT_VERSION; refusing to downgrade automatically. Existing installation is unchanged; use --version only for a deliberate rollback."
return 1
;;
esac
fi
if [[ -z "$choice" ]]; then
print_error "No update option matches the selected release. Existing installation is unchanged."
return 1
fi
fi
# Debug: Check if choice was read correctly
if [[ -z "$choice" ]]; then
print_error "No option selected. Exiting."
exit 1
fi
# Debug output to see what's happening
# print_info "DEBUG: You selected option $choice"
# Determine what action to take based on the dynamic menu
local action=""
local target_version=""
local current_choice=1
# Check if user selected stable update
if [[ -n "$STABLE_VERSION" ]] && [[ "$STABLE_VERSION" != "$CURRENT_VERSION" ]]; then
if [[ "$choice" == "$current_choice" ]]; then
action="update"
target_version="$STABLE_VERSION"
UPDATE_CHANNEL="stable"
fi
((current_choice++))
fi
# Check if user selected RC update
if [[ -n "$RC_VERSION" ]] && [[ "$RC_VERSION" != "$STABLE_VERSION" ]] && [[ "$RC_VERSION" != "$CURRENT_VERSION" ]]; then
if [[ "$choice" == "$current_choice" ]]; then
action="update"
target_version="$RC_VERSION"
UPDATE_CHANNEL="rc"
fi
((current_choice++))
fi
# Check if user selected reinstall
if [[ "$choice" == "$current_choice" ]]; then
action="reinstall"
fi
((current_choice++))
# Check if user selected remove
if [[ "$choice" == "$current_choice" ]]; then
action="remove"
fi
((current_choice++))
# Check if user selected cancel
if [[ "$choice" == "$current_choice" ]]; then
action="cancel"
fi
# Preview can legitimately select the newest stable release. Do not
# silently turn that requested preview channel into a stable preference.
if [[ "$automatic_choice" == true && "$action" == update ]]; then
UPDATE_CHANNEL="$requested_channel"
fi
# Debug: Show what action was determined
# print_info "DEBUG: Action determined: ${action:-'none'}"
case $action in
update)
# Determine if this is an upgrade or downgrade
local action_word="Installing"
if [[ -n "$CURRENT_VERSION" ]] && [[ "$CURRENT_VERSION" != "unknown" ]]; then
local cmp_result=0
compare_versions "$target_version" "$CURRENT_VERSION" || cmp_result=$?
case $cmp_result in
0) action_word="Reinstalling" ;;
1) action_word="Updating to" ;;
2) action_word="Downgrading to" ;;
esac
fi
print_info "${action_word} $target_version..."
LATEST_RELEASE="$target_version"
offer_existing_auto_updates
if ! run_upgrade_readiness_preflight "$CURRENT_VERSION" "$LATEST_RELEASE"; then
exit 1
fi
backup_existing
create_user
download_pulse
# Same repair as the --version path: a half-removed
# installation (#1663) must get its config dir and unit
# file back before auto-update setup and start.
setup_directories
setup_update_command
ensure_systemd_service_installed || return 1
# Setup auto-updates if requested during update; otherwise
# refresh assets a previous install already put in place so a
# stale helper (e.g. v5-pinned) doesn't survive the upgrade
if [[ "$ENABLE_AUTO_UPDATES" == "true" ]]; then
setup_auto_updates
elif update_timer_exists; then
refresh_auto_updates
fi
start_pulse
create_marker_file
print_completion
exit 0
;;
reinstall)
if ! is_pulse_release_tag "$CURRENT_VERSION"; then
print_error "Cannot reinstall an unknown current server version. Existing installation is unchanged; deliberately choose an exact version with --version."
return 1
fi
LATEST_RELEASE="$CURRENT_VERSION"
offer_existing_auto_updates
backup_existing
create_user
download_pulse
setup_directories
setup_update_command
install_systemd_service || return 1
# Setup auto-updates if requested during reinstall; otherwise
# refresh assets a previous install already put in place
if [[ "$ENABLE_AUTO_UPDATES" == "true" ]]; then
setup_auto_updates
elif update_timer_exists; then
refresh_auto_updates
fi
start_pulse
create_marker_file
print_completion
exit 0
;;
remove)
if ! quiesce_pulse_for_removal "$SERVICE_NAME"; then
print_error "Pulse removal is incomplete; no files have been removed. Previously stopped units are not automatically restarted."
return 1
fi
# Remove service files
rm -f "/etc/systemd/system/$SERVICE_NAME.service"
if [[ "$SERVICE_NAME_EXPLICIT" != "true" ]]; then
rm -f /etc/systemd/system/pulse.service
rm -f /etc/systemd/system/pulse-backend.service
fi
rm -f "$UPDATE_SERVICE_PATH"
rm -f "$UPDATE_TIMER_PATH"
# Reload systemd daemon
safe_systemctl daemon-reload
# Remove installation directory
rm -rf "$INSTALL_DIR"
# Remove symlink
rm -f "$BINARY_LINK_PATH"
rm -f "$AUTO_UPDATE_DEST"
if [[ "$UPDATE_HELPER_PATH" != "/bin/update" ]]; then
rm -f "$UPDATE_HELPER_PATH"
fi
# Ask about config/data removal
echo
print_info "Config and data files exist in $CONFIG_DIR"
safe_read_with_default "Remove all configuration and data? (y/N): " remove_config "n"
if [[ "$remove_config" =~ ^[Yy]$ ]]; then
rm -rf "$CONFIG_DIR"
print_success "Configuration and data removed"
else
print_info "Configuration preserved in $CONFIG_DIR"
fi
# Ask about user removal
if id "pulse" &>/dev/null; then
safe_read_with_default "Remove pulse user account? (y/N): " remove_user "n"
if [[ "$remove_user" =~ ^[Yy]$ ]]; then
userdel pulse 2>/dev/null || true
print_success "User account removed"
else
print_info "User account preserved"
fi
fi
# Remove any log files
rm -f /var/log/pulse*.log 2>/dev/null || true
if [[ "$SERVICE_NAME_EXPLICIT" != "true" ]]; then
rm -f /opt/pulse.log 2>/dev/null || true
fi
print_success "Pulse removed successfully"
;;
cancel)
print_info "Installation cancelled"
exit 0
;;
*)
print_error "Invalid option"
exit 1
;;
esac
else
# Check if this is truly a fresh installation or an update
# Check for existing installation BEFORE we create directories
# If binary exists OR system.json exists OR --version was specified, it's likely an update
if [[ -f "$INSTALL_DIR/bin/pulse" ]] || [[ -f "$INSTALL_DIR/pulse" ]] || [[ -f "$CONFIG_DIR/system.json" ]] || [[ -n "${FORCE_VERSION}" ]]; then
# This is an update/reinstall, don't prompt for port
FRONTEND_PORT=${FRONTEND_PORT:-7655}
else
# Fresh installation - ask for port configuration and auto-updates
FRONTEND_PORT=${FRONTEND_PORT:-}
if [[ -z "$FRONTEND_PORT" ]]; then
if [[ "$IN_DOCKER" == "true" ]] || [[ "$IN_CONTAINER" == "true" ]]; then
# In Docker/container mode, use default port without prompting
FRONTEND_PORT=7655
else
echo
safe_read_with_default "Frontend port [7655]: " FRONTEND_PORT "7655"
if [[ ! "$FRONTEND_PORT" =~ ^[0-9]+$ ]] || [[ "$FRONTEND_PORT" -lt 1 ]] || [[ "$FRONTEND_PORT" -gt 65535 ]]; then
print_error "Invalid port number. Using default port 7655."
FRONTEND_PORT=7655
fi
fi
fi
# Ask about auto-updates for fresh installation
# Skip if: already set by flag, in Docker, or being installed from host (IN_CONTAINER=true)
if [[ "$AUTO_UPDATE_CHOICE_EXPLICIT" != "true" ]] && [[ "$ENABLE_AUTO_UPDATES" != "true" ]] && [[ "$IN_DOCKER" != "true" ]] && [[ "$IN_CONTAINER" != "true" ]]; then
echo
echo "Enable automatic updates?"
echo "Pulse can automatically install stable updates daily (between 2-6 AM)"
safe_read_with_default "Enable auto-updates? [y/N]: " enable_updates "n"
if [[ "$enable_updates" =~ ^[Yy]$ ]]; then
ENABLE_AUTO_UPDATES=true
fi
fi
fi
install_dependencies
create_user
setup_directories
download_pulse
setup_update_command
install_systemd_service || return 1
# Setup auto-updates if requested; a leftover timer from a previous
# install still gets its helper script and units refreshed
if [[ "$ENABLE_AUTO_UPDATES" == "true" ]]; then
setup_auto_updates
elif update_timer_exists; then
refresh_auto_updates
fi
start_pulse
create_marker_file
print_completion
fi
}
# Legacy pulse-sensor-proxy footprint (v4/v5). install.sh never installs the
# sensor-proxy in v6 — the unified agent replaced it — but a Proxmox host that
# was upgraded from v5 may still carry it. These paths mirror the LOCAL subset
# of scripts/uninstall-sensor-proxy.sh so a full `--uninstall` on such a host
# leaves nothing behind: binary, units, runtime/state, service user, and —
# security-relevant — the managed SSH keys in root's authorized_keys. The
# cluster-wide key removal and Proxmox API-user deletion stay behind the
# explicit standalone script (we print a pointer to it).
SENSOR_PROXY_BINARY_PATH="${PULSE_SENSOR_PROXY_BINARY_PATH:-/usr/local/bin/pulse-sensor-proxy}"
SENSOR_PROXY_INSTALL_ROOT="${PULSE_SENSOR_PROXY_INSTALL_ROOT:-/opt/pulse/sensor-proxy}"
SENSOR_PROXY_RUNTIME_DIR="${PULSE_SENSOR_PROXY_RUNTIME_DIR:-/run/pulse-sensor-proxy}"
SENSOR_PROXY_WORK_DIR="${PULSE_SENSOR_PROXY_WORK_DIR:-/var/lib/pulse-sensor-proxy}"
SENSOR_PROXY_CONFIG_DIR="${PULSE_SENSOR_PROXY_CONFIG_DIR:-/etc/pulse-sensor-proxy}"
SENSOR_PROXY_LOG_DIR="${PULSE_SENSOR_PROXY_LOG_DIR:-/var/log/pulse/sensor-proxy}"
SENSOR_PROXY_SERVICE_USER="${PULSE_SENSOR_PROXY_SERVICE_USER:-pulse-sensor-proxy}"
SENSOR_PROXY_AUTHORIZED_KEYS_PATH="${PULSE_SENSOR_PROXY_AUTHORIZED_KEYS_PATH:-/root/.ssh/authorized_keys}"
SENSOR_PROXY_SYSTEMD_DIR="${PULSE_SENSOR_PROXY_SYSTEMD_DIR:-/etc/systemd/system}"
local_sensor_proxy_present() {
[[ -e "$SENSOR_PROXY_BINARY_PATH" ]] && return 0
[[ -e "${SENSOR_PROXY_SYSTEMD_DIR}/pulse-sensor-proxy.service" ]] && return 0
[[ -d "$SENSOR_PROXY_INSTALL_ROOT" ]] && return 0
[[ -d "$SENSOR_PROXY_WORK_DIR" ]] && return 0
[[ -d "$SENSOR_PROXY_CONFIG_DIR" ]] && return 0
return 1
}
remove_local_sensor_proxy_managed_keys() {
local auth_file="$SENSOR_PROXY_AUTHORIZED_KEYS_PATH"
local tmp_file=""
[[ -f "$auth_file" ]] || return 0
tmp_file=$(mktemp) || return 0
grep -v -E '# pulse-(managed|proxy)-key$' "$auth_file" >"$tmp_file" 2>/dev/null || true
if cmp -s "$tmp_file" "$auth_file"; then
rm -f "$tmp_file"
return 0
fi
chmod --reference="$auth_file" "$tmp_file" 2>/dev/null || chmod 600 "$tmp_file" 2>/dev/null || true
chown --reference="$auth_file" "$tmp_file" 2>/dev/null || true
if ! mv "$tmp_file" "$auth_file"; then
rm -f "$tmp_file"
return 1
fi
echo "Removed legacy pulse-sensor-proxy SSH key entries from $auth_file"
}
cleanup_local_sensor_proxy() {
local_sensor_proxy_present || return 0
echo "Removing legacy pulse-sensor-proxy footprint from this host..."
local unit=""
for unit in \
pulse-sensor-proxy.service \
pulse-sensor-proxy-selfheal.timer \
pulse-sensor-proxy-selfheal.service \
pulse-sensor-cleanup.path \
pulse-sensor-cleanup.service; do
stop_pulse_unit_for_removal "$unit" || return 1
done
rm -f "$SENSOR_PROXY_BINARY_PATH" || return 1
rm -f /usr/local/bin/pulse-sensor-cleanup.sh || return 1
rm -f "${SENSOR_PROXY_SYSTEMD_DIR}/pulse-sensor-proxy.service" || return 1
rm -f "${SENSOR_PROXY_SYSTEMD_DIR}/pulse-sensor-proxy-selfheal.service" || return 1
rm -f "${SENSOR_PROXY_SYSTEMD_DIR}/pulse-sensor-proxy-selfheal.timer" || return 1
rm -f "${SENSOR_PROXY_SYSTEMD_DIR}/pulse-sensor-cleanup.service" || return 1
rm -f "${SENSOR_PROXY_SYSTEMD_DIR}/pulse-sensor-cleanup.path" || return 1
rm -rf "$SENSOR_PROXY_RUNTIME_DIR" || return 1
rm -rf "$SENSOR_PROXY_INSTALL_ROOT" || return 1
rm -rf "$SENSOR_PROXY_WORK_DIR" || return 1
rm -rf "$SENSOR_PROXY_CONFIG_DIR" || return 1
rm -rf "$SENSOR_PROXY_LOG_DIR" || return 1
remove_local_sensor_proxy_managed_keys || return 1
if id -u "$SENSOR_PROXY_SERVICE_USER" >/dev/null 2>&1; then
userdel --remove "$SENSOR_PROXY_SERVICE_USER" >/dev/null 2>&1 || userdel "$SENSOR_PROXY_SERVICE_USER" >/dev/null 2>&1 || true
fi
if getent group "$SENSOR_PROXY_SERVICE_USER" >/dev/null 2>&1; then
groupdel "$SENSOR_PROXY_SERVICE_USER" >/dev/null 2>&1 || true
fi
echo "Removed legacy pulse-sensor-proxy footprint from this host."
echo "If this host belongs to a Proxmox cluster, run the full cleanup on the"
echo "other nodes (and to remove the pulse-monitor@pam API user):"
echo " curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | bash -s -- --purge --remove-proxmox-access --local-only"
}
# Uninstall function
uninstall_pulse() {
check_root
print_header
echo -e "\033[0;33mUninstalling Pulse...\033[0m"
echo
# Detect service name
local service_name
service_name=$(detect_service_name) || return 1
if ! quiesce_pulse_for_removal "$service_name"; then
print_error "Pulse removal is incomplete; no files have been removed. Previously stopped units are not automatically restarted."
return 1
fi
# Remove files
echo "Removing Pulse files..."
rm -rf "$INSTALL_DIR"
rm -rf "$CONFIG_DIR"
rm -f "/etc/systemd/system/$service_name.service"
if [[ "$SERVICE_NAME_EXPLICIT" != "true" ]]; then
rm -f /etc/systemd/system/pulse.service
rm -f /etc/systemd/system/pulse-backend.service
fi
rm -f "$UPDATE_SERVICE_PATH"
rm -f "$UPDATE_TIMER_PATH"
rm -f "$BINARY_LINK_PATH"
rm -f "$AUTO_UPDATE_DEST"
if [[ "$UPDATE_HELPER_PATH" != "/bin/update" ]]; then
rm -f "$UPDATE_HELPER_PATH"
fi
# Remove user (if it exists and isn't being used by other services)
if id "pulse" &>/dev/null; then
echo "Removing pulse user..."
userdel pulse 2>/dev/null || true
fi
# Remove any leftover legacy pulse-sensor-proxy footprint on this host so a
# full uninstall on a v5-upgraded Proxmox host leaves nothing behind.
cleanup_local_sensor_proxy || return 1
# Reload systemd
systemctl daemon-reload
echo
echo -e "\033[0;32m✓ Pulse has been completely uninstalled\033[0m"
exit 0
}
# Reset retains the install and unit files. Observe every possible writer before
# mutation; only timers lose enablement temporarily. Never replay an interrupted
# updater, or restart services after an uncertain stop/deletion.
read_pulse_reset_unit_state() {
local unit="$1"
PULSE_RESET_LOAD="" PULSE_RESET_ACTIVE="" PULSE_RESET_ENABLED=""
if ! PULSE_RESET_LOAD=$(timeout -k 1 5 systemctl show "$unit" --property=LoadState --value 2>/dev/null) ||
! PULSE_RESET_ACTIVE=$(timeout -k 1 5 systemctl show "$unit" --property=ActiveState --value 2>/dev/null); then
print_error "Cannot observe unit ($unit); reset incomplete. No automatic recovery will be attempted; reconcile service state before retrying."
return 1
fi
if [[ "$PULSE_RESET_LOAD" == "not-found" && "$PULSE_RESET_ACTIVE" == "inactive" ]]; then
return 0
fi
case "$PULSE_RESET_LOAD" in
loaded|masked) ;;
*) print_error "Unknown unit load state ($unit); reset incomplete"; return 1 ;;
esac
case "$PULSE_RESET_ACTIVE" in
active|inactive|failed) ;;
*) print_error "Unsettled unit state ($unit); reset incomplete"; return 1 ;;
esac
if ! PULSE_RESET_ENABLED=$(timeout -k 1 5 systemctl show "$unit" --property=UnitFileState --value 2>/dev/null); then
print_error "Cannot observe unit enablement ($unit); reset incomplete"
return 1
fi
case "$PULSE_RESET_ENABLED" in
enabled|enabled-runtime|disabled|static|indirect|masked|masked-runtime|linked|linked-runtime) ;;
*) print_error "Unknown unit enablement ($unit); reset incomplete"; return 1 ;;
esac
}
confirm_pulse_reset_unit_stopped() {
local unit="$1" expected_load="$2" expected_enabled="$3"
read_pulse_reset_unit_state "$unit" || return 1
if [[ "$PULSE_RESET_ACTIVE" != "inactive" || "$PULSE_RESET_LOAD" != "$expected_load" || "$PULSE_RESET_ENABLED" != "$expected_enabled" ]]; then
print_error "Unit quiescence changed or is unconfirmed ($unit); reset incomplete. Files are preserved; no services will be restarted. Reconcile service state before retrying."
return 1
fi
}
# Reset function: the caller's existing --reset intent is unchanged.
reset_pulse() {
check_root
print_header
echo -e "\033[0;33mResetting Pulse configuration...\033[0m"
echo
local service_name unit i prior_enabled
service_name=$(detect_service_name) || return 1
service_name="${service_name%.service}.service"
local units=("$UPDATE_TIMER_UNIT") roles=(timer)
# Historical default aliases share the same configuration. Custom instances
# must not stop, enable or restart another installation's units.
if [[ "$SERVICE_NAME_EXPLICIT" != "true" && "$UPDATE_TIMER_UNIT" != "pulse-backend-update.timer" ]]; then
units+=(pulse-backend-update.timer); roles+=(timer)
fi
units+=("$UPDATE_SERVICE_UNIT"); roles+=(updater)
if [[ "$SERVICE_NAME_EXPLICIT" != "true" && "$UPDATE_SERVICE_UNIT" != "pulse-backend-update.service" ]]; then
units+=(pulse-backend-update.service); roles+=(updater)
fi
units+=("$service_name"); roles+=(server)
if [[ "$SERVICE_NAME_EXPLICIT" != "true" ]]; then
if [[ "$service_name" != "pulse.service" ]]; then units+=(pulse.service); roles+=(server); fi
if [[ "$service_name" != "pulse-backend.service" ]]; then units+=(pulse-backend.service); roles+=(server); fi
fi
local loads=() active_states=() enabled_states=() stopped_enabled_states=()
# A failed observation anywhere must precede every stop and deletion.
for unit in "${units[@]}"; do
read_pulse_reset_unit_state "$unit" || return 1
if [[ "$unit" == "$service_name" && "$PULSE_RESET_LOAD" == "not-found" ]]; then
print_error "Pulse service is missing ($unit); refusing configuration reset"
return 1
fi
loads+=("$PULSE_RESET_LOAD")
active_states+=("$PULSE_RESET_ACTIVE")
enabled_states+=("$PULSE_RESET_ENABLED")
stopped_enabled_states+=("$PULSE_RESET_ENABLED")
done
for i in "${!units[@]}"; do
unit="${units[$i]}"
if [[ "${active_states[$i]}" != "inactive" ]]; then
if ! timeout -k 1 5 systemctl stop "$unit"; then
print_error "Cannot stop unit ($unit); reset incomplete. Configuration is preserved; no services will be restarted. Reconcile service state before retrying."
return 1
fi
fi
prior_enabled="${enabled_states[$i]}"
if [[ "${roles[$i]}" == timer ]]; then
case "$prior_enabled" in
enabled)
if ! timeout -k 1 5 systemctl disable "$unit"; then
print_error "Cannot disable timer ($unit); reset incomplete; configuration is preserved"
return 1
fi
stopped_enabled_states[$i]=disabled
;;
enabled-runtime)
if ! timeout -k 1 5 systemctl disable --runtime "$unit"; then
print_error "Cannot disable runtime timer ($unit); reset incomplete; configuration is preserved"
return 1
fi
stopped_enabled_states[$i]=disabled
;;
esac
fi
confirm_pulse_reset_unit_stopped "$unit" "${loads[$i]}" "${stopped_enabled_states[$i]}" || return 1
done
# Recheck ALL writers immediately before deletion, including an earlier timer
# that reactivated or an updater that appeared while the server was stopping.
for i in "${!units[@]}"; do
confirm_pulse_reset_unit_stopped "${units[$i]}" "${loads[$i]}" "${stopped_enabled_states[$i]}" || return 1
done
echo "Removing configuration and data..."
if ! rm -rf -- "$CONFIG_DIR"/*; then
print_error "Configuration deletion failed; reset incomplete. Services and timers remain stopped; inspect retained data before recovery."
return 1
fi
# Restore only previously active servers. A prior inactive/failed service is
# not consent to start it. Interrupted updater jobs are deliberately not replayed.
for i in "${!units[@]}"; do
unit="${units[$i]}"
[[ "${roles[$i]}" == server ]] || continue
if [[ "${active_states[$i]}" == "active" ]]; then
if ! timeout -k 1 5 systemctl start "$unit"; then
print_error "Cannot restart prior-active Pulse ($unit); reset incomplete. Timers remain stopped; reconcile service state before recovery."
return 1
fi
read_pulse_reset_unit_state "$unit" || return 1
if [[ "$PULSE_RESET_ACTIVE" != "active" || "$PULSE_RESET_LOAD" != "${loads[$i]}" || "$PULSE_RESET_ENABLED" != "${enabled_states[$i]}" ]]; then
print_error "Prior-active Pulse restoration is unconfirmed ($unit); reset incomplete. Timers remain stopped."
return 1
fi
fi
done
for i in "${!units[@]}"; do
unit="${units[$i]}"
[[ "${roles[$i]}" == timer ]] || continue
case "${enabled_states[$i]}" in
enabled) timeout -k 1 5 systemctl enable "$unit" || { print_error "Timer enablement restoration failed ($unit); reset incomplete"; return 1; } ;;
enabled-runtime) timeout -k 1 5 systemctl enable --runtime "$unit" || { print_error "Runtime timer enablement restoration failed ($unit); reset incomplete"; return 1; } ;;
esac
confirm_pulse_reset_unit_stopped "$unit" "${loads[$i]}" "${enabled_states[$i]}" || return 1
if [[ "${active_states[$i]}" == "active" ]]; then
timeout -k 1 5 systemctl start "$unit" || { print_error "Timer restart failed ($unit); reset incomplete"; return 1; }
fi
done
# Completion requires actual readback, not successful start/enable commands.
for i in "${!units[@]}"; do
unit="${units[$i]}"
read_pulse_reset_unit_state "$unit" || return 1
local expected_active="${active_states[$i]}"
[[ "${roles[$i]}" != updater && "$expected_active" != "failed" ]] || expected_active=inactive
if [[ "$PULSE_RESET_ACTIVE" != "$expected_active" || "$PULSE_RESET_LOAD" != "${loads[$i]}" || "$PULSE_RESET_ENABLED" != "${enabled_states[$i]}" ]]; then
print_error "Final unit restoration is unconfirmed ($unit); reset incomplete. Reconcile service state before recovery."
return 1
fi
if [[ "${roles[$i]}" == updater && "${active_states[$i]}" == "active" ]]; then
print_info "Interrupted updater ($unit) remains stopped and was not replayed."
fi
done
echo
echo -e "\033[0;32m✓ Pulse configuration has been reset; prior-active server and timer states are restored\033[0m"
exit 0
}
# When sourced (e.g. by tests) rather than executed, define the functions above
# but do not run the installer. BASH_SOURCE is empty when the script is piped to
# bash (curl ... | bash), which is an execution and not a source, so guard the
# lookup with a default to avoid an "unbound variable" abort under `set -u`.
if [[ -n "${BASH_SOURCE[0]:-}" && "${BASH_SOURCE[0]}" != "$0" ]]; then
return 0
fi
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--uninstall)
uninstall_pulse
;;
--reset)
reset_pulse
;;
--rc|--pre|--prerelease)
FORCE_CHANNEL="rc"
shift
;;
--stable)
FORCE_CHANNEL="stable"
shift
;;
--version)
if [[ $# -lt 2 ]] || [[ -z "${2:-}" ]] || [[ "$2" =~ ^-- ]]; then
print_error "Missing value for --version"
echo "Use --help for usage information"
exit 1
fi
FORCE_VERSION="$2"
shift 2
;;
--archive)
if [[ $# -lt 2 ]] || [[ -z "${2:-}" ]] || [[ "$2" =~ ^-- ]]; then
print_error "--archive requires a local .tar.gz path"
echo "Use --help for usage information"
exit 1
fi
ARCHIVE_OVERRIDE="$2"
shift 2
;;
--archive=*)
ARCHIVE_OVERRIDE="${1#*=}"
if [[ -z "$ARCHIVE_OVERRIDE" ]]; then
print_error "--archive requires a local .tar.gz path"
echo "Use --help for usage information"
exit 1
fi
shift
;;
--in-container)
IN_CONTAINER=true
# Check if this is a Docker container (multiple detection methods)
if [[ -f /.dockerenv ]] || \
grep -q docker /proc/1/cgroup 2>/dev/null || \
grep -q docker /proc/self/cgroup 2>/dev/null || \
[[ -f /run/.containerenv ]] || \
[[ "${container:-}" == "docker" ]]; then
IN_DOCKER=true
fi
shift
;;
--enable-auto-updates)
ENABLE_AUTO_UPDATES=true
AUTO_UPDATE_CHOICE_EXPLICIT=true
shift
;;
--disable-auto-updates)
ENABLE_AUTO_UPDATES=false
AUTO_UPDATE_CHOICE_EXPLICIT=true
shift
;;
--skip-upgrade-preflight)
SKIP_UPGRADE_PREFLIGHT=true
shift
;;
--repair-auto-update-units)
# Internal re-entry point for migrate_auto_update_assets_outside_sandbox:
# rewrite the auto-update helper and units, nothing else. Deliberately
# undocumented in --help; it exists so the transient systemd unit can
# run this installer outside the update sandbox that blocks those writes.
if install_auto_update_assets; then
exit 0
fi
exit 1
;;
--source|--from-source|--branch)
BUILD_FROM_SOURCE=true
# Optional: specify branch
if [[ $# -gt 1 ]] && [[ -n "$2" ]] && [[ ! "$2" =~ ^-- ]]; then
SOURCE_BRANCH="$2"
shift 2
else
SOURCE_BRANCH="main"
shift
fi
;;
-h|--help)
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Installation options:"
echo " --rc, --pre Install latest prerelease preview version"
echo " --stable Install latest stable version (default)"
echo " --version VERSION Install specific version (e.g., v4.4.0-rc.1)"
echo " --archive PATH Install from a local Pulse release tarball"
echo " Requires PATH.sshsig sidecar from the canonical release pipeline."
echo " --source [BRANCH] Build and install from source (default: main)"
echo " --enable-auto-updates Enable automatic stable updates (via systemd timer)"
echo " --disable-auto-updates Explicitly keep automatic updates disabled"
echo " --skip-upgrade-preflight Skip v5 to v6 compatibility preflight"
echo ""
echo "Management options:"
echo " --reset Reset Pulse to fresh configuration"
echo " --uninstall Completely remove Pulse from system"
echo ""
echo " -h, --help Show this help message"
exit 0
;;
*)
print_error "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
if [[ "$BUILD_FROM_SOURCE" == "true" ]] && [[ -n "$ARCHIVE_OVERRIDE" ]]; then
print_error "--archive cannot be used with --source"
exit 1
fi
auto_detect_container_environment
# Export for use in download_pulse function
export FORCE_VERSION FORCE_CHANNEL
# Run main function
main