Files
pulse/install.sh
T
rcourtman 9992d98172 Narrow two over-broad triage changes back to scope
Two follow-ups from auditing this morning's triage commits.

2a1bf2839 placed the config-directory guard after safe_systemctl enable, so
a failure to create the directory would have left the update timer enabled
while the installer reported automatic updates as disabled and never wrote
system.json. Move the guard above both the asset install and the timer
enable so a failure leaves nothing half-configured, and pin the ordering
with a test.

419385ccf regenerated the whole bundle-size baseline when only the FilterBar
chunk was over budget. That raised the recorded baseline for 22 other chunks
that were still passing, absorbing about 15 kB of accumulated drift and
resetting their headroom. Restore every other chunk and both totals to their
previous values and bump FilterBar alone, so the drift those chunks have
already accrued stays visible to the next change.

Contract-Neutral: scope corrections to earlier triage commits

Refs #1663
2026-08-03 18:00:04 +01:00

5618 lines
210 KiB
Bash
Executable File

#!/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))
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]+$ ]]
}
latest_stable_release_tag_from_json() {
local releases_json="${1:-}"
local latest_release=""
if [[ -z "$releases_json" ]]; then
return 1
fi
if command -v jq >/dev/null 2>&1; then
latest_release=$(printf '%s' "$releases_json" | jq -r '[.[] | select(.draft == false and .prerelease == false and (.tag_name | test("^v?[0-9]+\\.[0-9]+\\.[0-9]+$")))][0].tag_name // empty' 2>/dev/null || true)
else
latest_release=$(printf '%s' "$releases_json" | grep -oE '"tag_name":[[:space:]]*"v?[0-9]+\.[0-9]+\.[0-9]+"' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true)
fi
if is_stable_release_tag "$latest_release"; then
printf '%s\n' "$latest_release"
return 0
fi
return 1
}
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
}
}
# 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
if systemctl list-unit-files --no-legend | grep -q "^pulse-backend.service"; then
echo "pulse-backend"
elif systemctl list-unit-files --no-legend | grep -q "^pulse.service"; then
echo "pulse"
else
echo "pulse" # Default for new installations
fi
}
update_timer_exists() {
command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files --no-legend 2>/dev/null | grep -q "^${UPDATE_TIMER_UNIT}$"
}
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
}
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}"
if [[ "$channel" != "rc" ]]; then
local releases_json=""
local stable_release=""
if command -v timeout >/dev/null 2>&1; then
releases_json=$(timeout 15 curl -fsSL --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases" 2>/dev/null || true)
else
releases_json=$(curl -fsSL --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases" 2>/dev/null || true)
fi
stable_release=$(latest_stable_release_tag_from_json "$releases_json" 2>/dev/null || true)
if [[ -n "$stable_release" ]]; then
printf '%s\n' "$stable_release"
return 0
fi
stable_release=$(get_latest_release_from_redirect 2>/dev/null || true)
if is_stable_release_tag "$stable_release"; then
printf '%s\n' "$stable_release"
return 0
fi
return 1
fi
local releases_json=""
if command -v timeout >/dev/null 2>&1; then
releases_json=$(timeout 15 curl -fsSL --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases" 2>/dev/null || true)
else
releases_json=$(curl -fsSL --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases" 2>/dev/null || true)
fi
local rc_release=""
if [[ -n "$releases_json" ]]; then
if command -v jq >/dev/null 2>&1; then
rc_release=$(echo "$releases_json" | jq -r '[.[] | select(.draft == false)][0].tag_name' 2>/dev/null || true)
else
rc_release=$(echo "$releases_json" | grep -v '"draft": true' | grep '"tag_name":' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true)
fi
fi
if [[ -z "$rc_release" || "$rc_release" == "null" ]]; then
return 1
fi
printf '%s\n' "$rc_release"
}
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() {
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
local container_archive_source=""
local container_archive_dest=""
local container_archive_temp=false
local archive_requested=false
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="/tmp/pulse_install_$$.sh"
if [[ "$0" == "bash" ]] || [[ ! -f "$0" ]]; then
# We're being piped, download the script with retry logic
local download_url=""
if ! download_url=$(resolve_install_script_download_url); then
print_error "Failed to determine installer download URL for the selected release channel"
cleanup_on_error
fi
local download_success=false
local download_error=""
local max_retries=3
for attempt in $(seq 1 $max_retries); do
if [[ $attempt -gt 1 ]]; then
print_info "Retrying download (attempt $attempt/$max_retries)..."
sleep 2
fi
local curl_stderr="/tmp/curl_error_$$.txt"
if command -v timeout >/dev/null 2>&1; then
if timeout 30 curl -fsSL --connect-timeout 10 --max-time 30 "$download_url" > "$script_source" 2>"$curl_stderr"; then
download_success=true
rm -f "$curl_stderr"
break
fi
else
if curl -fsSL --connect-timeout 10 --max-time 30 "$download_url" > "$script_source" 2>"$curl_stderr"; then
download_success=true
rm -f "$curl_stderr"
break
fi
fi
download_error=$(cat "$curl_stderr" 2>/dev/null || echo "unknown error")
rm -f "$curl_stderr"
done
if [[ "$download_success" != "true" ]]; then
print_error "Failed to download install script after $max_retries attempts"
print_error "URL: $download_url"
if [[ -n "$download_error" ]]; then
print_error "Error: $download_error"
fi
print_info ""
print_info "Workaround: Download the script manually and run it locally:"
print_info " curl -fsSL $download_url -o install.sh"
print_info " bash install.sh"
cleanup_on_error
fi
else
# We have a local script file
script_source="$0"
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
# Clean up temp file if we created one
if [[ "$script_source" == "/tmp/pulse_install_"* ]]; then
rm -f "$script_source"
fi
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
rm -f "$container_archive_source" "${container_archive_source}.sshsig"
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
rm -f "$container_archive_source" "${container_archive_source}.sshsig"
fi
print_error "Failed to copy Pulse release archive signature to container"
cleanup_on_error
fi
else
if [[ "$container_archive_temp" == "true" ]]; then
rm -f "$container_archive_source"
fi
print_error "Pulse release archive signature missing alongside ${container_archive_source}"
cleanup_on_error
fi
fi
fi
if [[ "$container_archive_temp" == "true" ]]; then
rm -f "$container_archive_source" "${container_archive_source}.sshsig"
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"
echo " pct exec $CTID -- env PULSE_DATA_DIR=$QUOTED_CONFIG_DIR pulse bootstrap-token"
echo
echo " Common commands:"
echo " pct enter $CTID # Enter container"
echo " pct exec $CTID -- $(basename "$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=$(curl --retry 2 --retry-delay 1 -kfsS -H "Authorization: PVEAPIToken=${token_id}=${token_value}" "${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
)
echo "$setup_payload" > /tmp/pulse-auto-register-request.json 2>/dev/null || true
local pulse_url="http://${pulse_ip}:${pulse_port}"
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
# Persist for debugging when running interactively
echo "$setup_response" > /tmp/pulse-auto-register-response.json 2>/dev/null || true
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 - "$setup_response" "$pulse_url" "$normalized_host_url" <<'PY'
import json, sys
import time
from urllib.parse import quote
try:
data = json.loads(sys.argv[1])
except Exception:
print("\t\t\t\t\t\t\t\t\t\t")
sys.exit(0)
pulse_url = sys.argv[2]
host = sys.argv[3]
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?host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&type=pve"
if setup_token:
expected_download_url = f"{pulse_url}/api/setup-script?host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve"
else:
expected_download_url = ""
expected_script_name = "pulse-setup-pve.sh"
if setup_url != expected_setup_url:
setup_url = ""
if setup_download_url != expected_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 _requires_token:
if "PULSE_SETUP_TOKEN=" not in _value or setup_token not in _value:
if _field_name == "command":
setup_command = ""
else:
setup_command_with_env = ""
continue
elif "PULSE_SETUP_TOKEN=" in _value or setup_token in _value:
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 expected_setup_url="${pulse_url}/api/setup-script?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" "$setup_token"
from urllib.parse import quote
import sys
host, pulse_url, setup_token = sys.argv[1:]
print(f"{pulse_url}/api/setup-script?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_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 - <<'PY' "$normalized_host_url" "$token_id" "$token_value" "$server_name" "$setup_token"
import json, sys
host, token_id, token_value, server_name, setup_token = sys.argv[1:]
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=$(curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d "$register_payload"); 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
# 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
detected_service=$(detect_service_name)
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
prune_config_backups "$backup_parent" "$(basename "$CONFIG_DIR").backup."
fi
}
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_base=""
temp_base=$(mktemp "${prefix}-XXXXXX") || return 1
rm -f "$temp_base"
printf '%s.tar.gz\n' "$temp_base"
}
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
local releases_json=""
if command -v timeout >/dev/null 2>&1; then
releases_json=$(timeout 15 curl -s --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases" 2>/dev/null || true)
else
releases_json=$(curl -s --connect-timeout 10 --max-time 30 "https://api.github.com/repos/$GITHUB_REPO/releases" 2>/dev/null || true)
fi
if [[ -n "$releases_json" ]]; then
if [[ "$UPDATE_CHANNEL" == "rc" ]]; then
# Prerelease channel: get latest release (including prereleases, but skip drafts)
if command -v jq >/dev/null 2>&1; then
LATEST_RELEASE=$(echo "$releases_json" | jq -r '[.[] | select(.draft == false)][0].tag_name' 2>/dev/null || true)
else
LATEST_RELEASE=$(echo "$releases_json" | grep -v '"draft": true' | grep '"tag_name":' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true)
fi
else
LATEST_RELEASE=$(latest_stable_release_tag_from_json "$releases_json" 2>/dev/null || true)
fi
fi
if [[ -z "$LATEST_RELEASE" ]]; then
print_info "GitHub API unavailable, trying alternative method..."
local redirect_version=""
redirect_version=$(get_latest_release_from_redirect 2>/dev/null || true)
if [[ "$UPDATE_CHANNEL" == "rc" && -n "$redirect_version" ]]; then
LATEST_RELEASE="$redirect_version"
elif is_stable_release_tag "$redirect_version"; then
LATEST_RELEASE="$redirect_version"
fi
fi
if [[ -z "$LATEST_RELEASE" ]]; then
print_error "Could not determine the latest Pulse release from GitHub"
print_info "GitHub may be unreachable or rate limiting. Retry later, 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 temp_extract2=""
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
temp_extract=$(mktemp -d /tmp/pulse-extract-XXXXXX)
# --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
mkdir -p "$INSTALL_DIR/bin"
if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then
mv "$INSTALL_DIR/bin/pulse" "$INSTALL_DIR/bin/pulse.old" 2>/dev/null || true
fi
if ! cp "$pulse_binary_path" "$INSTALL_DIR/bin/pulse"; then
print_error "Failed to copy new binary to $INSTALL_DIR/bin/pulse"
[[ -f "$INSTALL_DIR/bin/pulse.old" ]] && mv "$INSTALL_DIR/bin/pulse.old" "$INSTALL_DIR/bin/pulse"
rm -rf "$temp_extract"
return 1
fi
if [[ ! -f "$INSTALL_DIR/bin/pulse" ]]; then
print_error "Binary installation failed - file not found after copy"
[[ -f "$INSTALL_DIR/bin/pulse.old" ]] && mv "$INSTALL_DIR/bin/pulse.old" "$INSTALL_DIR/bin/pulse"
rm -rf "$temp_extract"
return 1
fi
install_additional_agent_binaries "$expected_release" "$temp_extract"
deploy_agent_scripts "$temp_extract"
chmod +x "$INSTALL_DIR/bin/pulse"
chown -R pulse:pulse "$INSTALL_DIR"
rm -f "$INSTALL_DIR/bin/pulse.old"
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
installed_version=$("$INSTALL_DIR/bin/pulse" --version 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.]+)?' | head -1 || echo "unknown")
if [[ -n "$expected_release" && "$installed_version" != "$expected_release" ]]; then
print_warn "Version verification issue: Expected $expected_release but binary reports $installed_version"
print_info "This can happen if the binary wasn't properly replaced. Trying to fix..."
rm -f "$INSTALL_DIR/bin/pulse"
temp_extract2=$(mktemp -d /tmp/pulse-extract2-XXXXXX)
if ! tar --no-same-owner --no-overwrite-dir -xzf "$archive_path" -C "$temp_extract2"; then
print_warn "Failed to re-extract archive for version verification retry"
else
pulse_binary_path=$(find_pulse_binary_in_dir "$temp_extract2" 2>/dev/null || true)
if [[ -n "$pulse_binary_path" ]]; then
cp -f "$pulse_binary_path" "$INSTALL_DIR/bin/pulse"
fi
install_additional_agent_binaries "$expected_release" "$temp_extract2"
deploy_agent_scripts "$temp_extract2"
chmod +x "$INSTALL_DIR/bin/pulse"
chown -R pulse:pulse "$INSTALL_DIR"
fi
rm -rf "$temp_extract2"
installed_version=$("$INSTALL_DIR/bin/pulse" --version 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.]+)?' | head -1 || echo "unknown")
if [[ "$installed_version" == "$expected_release" ]]; then
print_success "Version issue resolved - now running $installed_version"
else
print_warn "Version mismatch persists. You may need to restart the service or reboot."
fi
elif [[ -n "$expected_release" ]]; then
print_success "Version verified: $installed_version"
elif [[ "$installed_version" != "unknown" ]]; then
print_success "Version installed: $installed_version"
else
print_warn "Installed Pulse version could not be verified"
fi
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=""
rm -f "$BUILD_FROM_SOURCE_MARKER"
if ! ensure_update_disk_headroom "/tmp" "$INSTALL_DIR"; then
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 "/tmp/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
rm -f "$archive_path"
exit 1
fi
expected_release="$LATEST_RELEASE"
fi
if ! run_upgrade_readiness_preflight "$CURRENT_VERSION" "$expected_release"; then
exit 1
fi
# Detect and stop existing service after the archive is available but before replacing the binary.
EXISTING_SERVICE=$(detect_service_name)
if timeout 5 systemctl is-active --quiet "$EXISTING_SERVICE" 2>/dev/null; then
print_info "Stopping existing Pulse service ($EXISTING_SERVICE)..."
safe_systemctl stop "$EXISTING_SERVICE" || true
sleep 2
fi
if ! install_pulse_archive "$archive_path" "$expected_release"; then
if [[ "$archive_from_temp" == "true" ]]; then
rm -f "$archive_path" "${archive_path}.sshsig"
fi
exit 1
fi
if [[ "$archive_from_temp" == "true" ]]; then
rm -f "$archive_path" "${archive_path}.sshsig"
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
rm -f "$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=""
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
service_name=$(detect_service_name)
if timeout 5 systemctl is-active --quiet "$service_name" 2>/dev/null; then
print_info "Stopping existing Pulse service ($service_name)..."
safe_systemctl stop "$service_name" || true
sleep 2
fi
mkdir -p "$INSTALL_DIR/bin" "$INSTALL_DIR/scripts"
if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then
mv "$INSTALL_DIR/bin/pulse" "$INSTALL_DIR/bin/pulse.old" 2>/dev/null || true
fi
if ! cp pulse "$INSTALL_DIR/bin/pulse"; then
print_error "Failed to copy built Pulse binary"
[[ -f "$INSTALL_DIR/bin/pulse.old" ]] && mv "$INSTALL_DIR/bin/pulse.old" "$INSTALL_DIR/bin/pulse"
cd "$original_dir" >/dev/null 2>&1 || true
rm -rf "$temp_build"
return 1
fi
chmod +x "$INSTALL_DIR/bin/pulse"
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-$(git rev-parse --short HEAD)" > "$INSTALL_DIR/VERSION"
echo "$branch" > "$BUILD_FROM_SOURCE_MARKER"
chown -R pulse:pulse "$INSTALL_DIR" 2>/dev/null || true
chown pulse:pulse "$BUILD_FROM_SOURCE_MARKER" 2>/dev/null || true
rm -f "$INSTALL_DIR/bin/pulse.old"
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 -R pulse:pulse "$INSTALL_DIR"
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|--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 [[ -f "\${CONFIG_DIR}/system.json" ]]; then
configured_channel=\$(grep -o '"updateChannel"[[:space:]]*:[[:space:]]*"[^"]*"' "\${CONFIG_DIR}/system.json" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/' || true)
if [[ "\$configured_channel" == "rc" ]]; then
extra_args+=(--rc)
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
}
# 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")"
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. Re-run install.sh with --enable-auto-updates once the issue above is resolved."
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 "Re-run install.sh with --enable-auto-updates to repair it."
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)
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
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
Environment="PULSE_DATA_DIR=$CONFIG_DIR"
Environment="PULSE_DEPLOYMENT_METHOD=systemd"
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
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"
if timeout 5 systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
PULSE_WAS_ACTIVE="true"
fi
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
}
# 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
if check_existing_installation; 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
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}"
# Check if auto-updates should be offered when using --version
# Same logic as update/reinstall paths
if [[ "$AUTO_UPDATE_CHOICE_EXPLICIT" != "true" ]] && [[ "$ENABLE_AUTO_UPDATES" != "true" ]] && [[ "$IN_DOCKER" != "true" ]]; then
local should_ask_about_updates=false
local prompt_reason=""
if ! update_timer_exists; then
# Timer doesn't exist - new feature
should_ask_about_updates=true
prompt_reason="new"
elif [[ -f "$CONFIG_DIR/system.json" ]]; then
# Timer exists, check if it's properly configured
if grep -q '"autoUpdateEnabled":\s*false' "$CONFIG_DIR/system.json" 2>/dev/null; then
should_ask_about_updates=true
prompt_reason="disabled"
fi
fi
if [[ "$should_ask_about_updates" == "true" ]]; then
echo
if [[ "$prompt_reason" == "disabled" ]]; then
echo -e "${YELLOW}Auto-updates are currently disabled.${NC}"
echo "Would you like to enable automatic updates?"
else
echo -e "${YELLOW}New feature: Automatic updates!${NC}"
fi
echo "Pulse can automatically install stable updates daily (between 2-6 AM)"
echo "This keeps your installation secure and up-to-date."
safe_read_with_default "Enable auto-updates? [Y/n]: " enable_updates "y"
# Default to yes for this prompt since they're already updating
if [[ ! "$enable_updates" =~ ^[Nn]$ ]]; then
ENABLE_AUTO_UPDATES=true
fi
fi
fi
# Detect the actual service name before trying to stop it
SERVICE_NAME=$(detect_service_name)
if ! run_upgrade_readiness_preflight "$CURRENT_VERSION" "$LATEST_RELEASE"; then
exit 1
fi
backup_existing
stop_pulse_for_update
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
# 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
# Get both stable and RC versions
# Try GitHub API first, but have a fallback - with timeout protection
local STABLE_VERSION=""
STABLE_VERSION=$(resolve_latest_release_tag_for_channel stable 2>/dev/null || true)
# For RC, we need the API, so if it fails just use empty
local RC_VERSION=""
if command -v timeout >/dev/null 2>&1; then
RC_VERSION=$(timeout 10 curl -s --connect-timeout 5 --max-time 10 https://api.github.com/repos/$GITHUB_REPO/releases 2>/dev/null | grep '"tag_name":' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true)
else
RC_VERSION=$(curl -s --connect-timeout 5 --max-time 10 https://api.github.com/repos/$GITHUB_REPO/releases 2>/dev/null | grep '"tag_name":' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true)
fi
# 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
if [[ -n "$STABLE_VERSION" ]] && [[ "$STABLE_VERSION" != "$CURRENT_VERSION" ]]; then
echo "${menu_option}) Update to $STABLE_VERSION (stable)"
((menu_option++))
fi
if [[ -n "$RC_VERSION" ]] && [[ "$RC_VERSION" != "$STABLE_VERSION" ]] && [[ "$RC_VERSION" != "$CURRENT_VERSION" ]]; then
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)
if [[ "$IN_DOCKER" == "true" ]]; then
# In Docker, always auto-select
print_info "Docker environment detected. Auto-selecting update option."
if [[ "$UPDATE_CHANNEL" == "rc" ]] && [[ -n "$RC_VERSION" ]] && [[ "$RC_VERSION" != "$STABLE_VERSION" ]]; then
choice=2 # RC version
else
choice=1 # Stable version
fi
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."
if [[ "$UPDATE_CHANNEL" == "rc" ]] && [[ -n "$RC_VERSION" ]] && [[ "$RC_VERSION" != "$STABLE_VERSION" ]]; then
choice=2 # RC version
else
choice=1 # Stable version
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
# 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"
# Check if auto-updates should be offered to the user
# Offer if: not already forced by flag, not in Docker, and either:
# 1. Timer doesn't exist (new feature), OR
# 2. Timer exists but autoUpdateEnabled is false (misconfigured)
if [[ "$AUTO_UPDATE_CHOICE_EXPLICIT" != "true" ]] && [[ "$ENABLE_AUTO_UPDATES" != "true" ]] && [[ "$IN_DOCKER" != "true" ]]; then
local should_ask_about_updates=false
local prompt_reason=""
if ! update_timer_exists; then
# Timer doesn't exist - new feature
should_ask_about_updates=true
prompt_reason="new"
elif [[ -f "$CONFIG_DIR/system.json" ]]; then
# Timer exists, check if it's properly configured
if grep -q '"autoUpdateEnabled":\s*false' "$CONFIG_DIR/system.json" 2>/dev/null; then
should_ask_about_updates=true
prompt_reason="disabled"
fi
fi
if [[ "$should_ask_about_updates" == "true" ]]; then
echo
if [[ "$prompt_reason" == "disabled" ]]; then
echo -e "${YELLOW}Auto-updates are currently disabled.${NC}"
echo "Would you like to enable automatic updates?"
else
echo -e "${YELLOW}New feature: Automatic updates!${NC}"
fi
echo "Pulse can automatically install stable updates daily (between 2-6 AM)"
echo "This keeps your installation secure and up-to-date."
safe_read_with_default "Enable auto-updates? [Y/n]: " enable_updates "y"
# Default to yes for this prompt since they're already updating
if [[ ! "$enable_updates" =~ ^[Nn]$ ]]; then
ENABLE_AUTO_UPDATES=true
fi
fi
fi
if ! run_upgrade_readiness_preflight "$CURRENT_VERSION" "$LATEST_RELEASE"; then
exit 1
fi
backup_existing
stop_pulse_for_update
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
# 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)
# Check if auto-updates should be offered to the user
# Offer if: not already forced by flag, not in Docker, and either:
# 1. Timer doesn't exist (new feature), OR
# 2. Timer exists but autoUpdateEnabled is false (misconfigured)
if [[ "$AUTO_UPDATE_CHOICE_EXPLICIT" != "true" ]] && [[ "$ENABLE_AUTO_UPDATES" != "true" ]] && [[ "$IN_DOCKER" != "true" ]]; then
local should_ask_about_updates=false
local prompt_reason=""
if ! update_timer_exists; then
# Timer doesn't exist - new feature
should_ask_about_updates=true
prompt_reason="new"
elif [[ -f "$CONFIG_DIR/system.json" ]]; then
# Timer exists, check if it's properly configured
if grep -q '"autoUpdateEnabled":\s*false' "$CONFIG_DIR/system.json" 2>/dev/null; then
should_ask_about_updates=true
prompt_reason="disabled"
fi
fi
if [[ "$should_ask_about_updates" == "true" ]]; then
echo
if [[ "$prompt_reason" == "disabled" ]]; then
echo -e "${YELLOW}Auto-updates are currently disabled.${NC}"
echo "Would you like to enable automatic updates?"
else
echo -e "${YELLOW}New feature: Automatic updates!${NC}"
fi
echo "Pulse can automatically install stable updates daily (between 2-6 AM)"
echo "This keeps your installation secure and up-to-date."
safe_read_with_default "Enable auto-updates? [Y/n]: " enable_updates "y"
# Default to yes for this prompt
if [[ ! "$enable_updates" =~ ^[Nn]$ ]]; then
ENABLE_AUTO_UPDATES=true
fi
fi
fi
backup_existing
stop_pulse_for_update
create_user
download_pulse
setup_directories
setup_update_command
install_systemd_service
# 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)
# Stop and disable service
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
systemctl disable "$SERVICE_NAME" 2>/dev/null || true
# 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
# 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
mv "$tmp_file" "$auth_file"
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
systemctl stop "$unit" >/dev/null 2>&1 || true
systemctl disable "$unit" >/dev/null 2>&1 || true
done
rm -f "$SENSOR_PROXY_BINARY_PATH"
rm -f /usr/local/bin/pulse-sensor-cleanup.sh
rm -f "${SENSOR_PROXY_SYSTEMD_DIR}/pulse-sensor-proxy.service"
rm -f "${SENSOR_PROXY_SYSTEMD_DIR}/pulse-sensor-proxy-selfheal.service"
rm -f "${SENSOR_PROXY_SYSTEMD_DIR}/pulse-sensor-proxy-selfheal.timer"
rm -f "${SENSOR_PROXY_SYSTEMD_DIR}/pulse-sensor-cleanup.service"
rm -f "${SENSOR_PROXY_SYSTEMD_DIR}/pulse-sensor-cleanup.path"
rm -rf "$SENSOR_PROXY_RUNTIME_DIR"
rm -rf "$SENSOR_PROXY_INSTALL_ROOT"
rm -rf "$SENSOR_PROXY_WORK_DIR"
rm -rf "$SENSOR_PROXY_CONFIG_DIR"
rm -rf "$SENSOR_PROXY_LOG_DIR"
remove_local_sensor_proxy_managed_keys
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"
}
# 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)
# Stop and disable service
if systemctl is-active --quiet "$service_name" 2>/dev/null; then
echo "Stopping $service_name..."
systemctl stop "$service_name"
fi
if systemctl is-enabled --quiet "$service_name" 2>/dev/null; then
echo "Disabling $service_name..."
systemctl disable "$service_name"
fi
# Stop and disable auto-update timer if it exists
if update_timer_enabled; then
echo "Disabling auto-update timer..."
systemctl disable --now "$UPDATE_TIMER_UNIT"
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
# Reload systemd
systemctl daemon-reload
echo
echo -e "\033[0;32m✓ Pulse has been completely uninstalled\033[0m"
exit 0
}
# Reset function
reset_pulse() {
check_root
print_header
echo -e "\033[0;33mResetting Pulse configuration...\033[0m"
echo
# Detect service name
local service_name
service_name=$(detect_service_name)
# Stop service
if systemctl is-active --quiet "$service_name" 2>/dev/null; then
echo "Stopping $service_name..."
systemctl stop "$service_name"
fi
# Remove config but keep binary
echo "Removing configuration and data..."
rm -rf "$CONFIG_DIR"/*
# Restart service
echo "Starting $service_name with fresh configuration..."
systemctl start "$service_name"
echo
echo -e "\033[0;32m✓ Pulse has been reset to fresh configuration\033[0m"
echo "Access Pulse at: http://$(hostname -I | awk '{print $1}'):$(current_frontend_port)"
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