#!/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 INSTALL_DIR="/opt/pulse" CONFIG_DIR="/etc/pulse" # All config and data goes here for manual installs SERVICE_NAME="pulse" GITHUB_REPO="rcourtman/Pulse" BUILD_FROM_SOURCE=false SKIP_DOWNLOAD=false IN_CONTAINER=false IN_DOCKER=false ENABLE_AUTO_UPDATES=false FORCE_VERSION="" FORCE_CHANNEL="" SOURCE_BRANCH="main" CURRENT_INSTALL_CTID="" CONTAINER_CREATED_FOR_CLEANUP=false BUILD_FROM_SOURCE_MARKER="$INSTALL_DIR/BUILD_FROM_SOURCE" DETECTED_CTID="" # 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="" 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 } 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 ! command -v systemctl >/dev/null 2>&1; then echo "pulse" 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 } # 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 /usr/local/bin/pulse* >/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 find /usr/local/bin/ -name 'pulse*' -exec chcon -h -t bin_t {} \; 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 print_error "Docker environment detected" echo "Please use the Docker image directly: docker run -d -p 7655:7655 rcourtman/pulse:latest" echo "See: https://github.com/rcourtman/Pulse/blob/main/docs/DOCKER.md" exit 1 fi } # 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 } 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 # 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 # 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 [[ "$BUILD_FROM_SOURCE" == "true" ]]; then echo print_info "Skipping auto-update configuration: source builds don't support automatic release updates." else 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 [[ "$BUILD_FROM_SOURCE" == "true" ]]; then echo print_info "Skipping auto-update configuration: source builds don't support automatic release updates." else # 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. 192.168.1.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., 192.168.1.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 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 # 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="https://github.com/rcourtman/Pulse/releases/latest/download/install.sh" 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 # Run installation with visible progress local install_cmd="bash /tmp/install.sh --in-container" if [[ -n "$auto_updates_flag" ]]; then install_cmd="$install_cmd $auto_updates_flag" fi if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then install_cmd="$install_cmd --source '$SOURCE_BRANCH'" fi if [[ "$frontend_port" != "7655" ]]; then install_cmd="FRONTEND_PORT=$frontend_port $install_cmd" fi # 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 'bash /tmp/install.sh' manually:" print_info " pct enter $CTID" 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_info " bash /tmp/install.sh" 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:" echo " pct exec $CTID -- cat /etc/pulse/.bootstrap_token # Get bootstrap token" echo echo " Common commands:" echo " pct enter $CTID # Enter container" echo " pct exec $CTID -- update # Update Pulse" echo CONTAINER_CREATED_FOR_CLEANUP=false CURRENT_INSTALL_CTID="" trap - INT TERM exit 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 bootstrap_token="" if command -v pct >/dev/null 2>&1 && [[ -n "$ctid" ]]; then for attempt in $(seq 1 30); do bootstrap_token=$(pct exec "$ctid" -- bash -lc "if [ -f /etc/pulse/.bootstrap_token ]; then cat /etc/pulse/.bootstrap_token; fi" 2>/dev/null | tr -d '\r\n') if [[ -n "$bootstrap_token" ]]; then print_info "Discovered bootstrap token from container after ${attempt}s" break fi sleep 1 done 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_headers=(-H "Content-Type: application/json") if [[ -n "$bootstrap_token" ]]; then setup_headers+=(-H "X-Setup-Token: $bootstrap_token") fi local setup_response if ! setup_response=$(curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/setup-script-url" "${setup_headers[@]}" -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 setup_token=$(python3 - "$setup_response" <<'PY' import json, sys try: data = json.loads(sys.argv[1]) except Exception: print("") sys.exit(0) print(data.get("setupToken", "")) PY ) || setup_token="" if [[ -z "$setup_token" ]]; 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@pam --comment "Pulse monitoring service" >/dev/null 2>&1 || true pveum aclmod / -user pulse-monitor@pam -role PVEAuditor >/dev/null 2>&1 || true if [[ "$backup_perms" == "true" ]]; then pveum aclmod /storage -user pulse-monitor@pam -role PVEDatastoreAdmin >/dev/null 2>&1 || true fi 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_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 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_vm_monitor" == true ]]; then extra_privs+=("VM.Monitor") elif [[ "$has_guest_audit" == true ]]; then extra_privs+=("VM.GuestAgent.Audit") fi if [[ ${#extra_privs[@]} -gt 0 ]]; then local priv_string="${extra_privs[*]}" pveum role delete PulseMonitor >/dev/null 2>&1 || true if pveum role add PulseMonitor -privs "$priv_string" >/dev/null 2>&1; then pveum aclmod / -user pulse-monitor@pam -role PulseMonitor >/dev/null 2>&1 || true fi fi local pulse_host_slug pulse_host_slug=$(python3 - <<'PY' "$pulse_url" import sys, urllib.parse parsed = urllib.parse.urlparse(sys.argv[1]) host = (parsed.hostname or "pulse").replace(".", "-") print(host) PY ) local token_name="pulse-${pulse_host_slug}-$(date +%s)" local token_output="" set +e token_output=$(pveum user token add pulse-monitor@pam "$token_name" --privsep 0 2>&1) local token_status=$? set -e 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=$(awk -F'│' '/[[:space:]]value[[:space:]]/{col=$3; gsub(/^[[:space:]]+|[[:space:]]+$/, "", col); print col}' <<<"$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@pam!${token_name}" 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, auth_token = sys.argv[1:] print(json.dumps({ "type": "pve", "host": host, "tokenId": token_id, "tokenValue": token_value, "serverName": server_name, "authToken": auth_token })) 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 register_status=$(python3 - "$register_response" <<'PY' import json, sys try: data = json.loads(sys.argv[1]) except Exception: print("") sys.exit(0) print(data.get("status", "")) PY ) || register_status="" if [[ "$register_status" != "success" ]]; 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="$server_name" AUTO_NODE_REGISTER_ERROR="" print_success "Registered ${server_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 } 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 detected_service=$(detect_service_name) SERVICE_NAME="$detected_service" 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 apt-get install -y -qq curl wget jq >/dev/null 2>&1 || { # If jq fails to install, just install the essentials apt-get install -y -qq curl wget >/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 } backup_existing() { if [[ -d "$CONFIG_DIR" ]]; then print_info "Backing up existing configuration..." cp -a "$CONFIG_DIR" "${CONFIG_DIR}.backup.$(date +%Y%m%d-%H%M%S)" fi } 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 print_info "Downloading Pulse..." # Check for forced version first if [[ -n "${FORCE_VERSION}" ]]; then LATEST_RELEASE="${FORCE_VERSION}" print_info "Installing specific version: $LATEST_RELEASE" # Verify the version exists (with timeout) 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 else # UPDATE_CHANNEL should already be set by main(), but set default if not if [[ -z "${UPDATE_CHANNEL:-}" ]]; then UPDATE_CHANNEL="stable" # Allow override via command line if [[ -n "${FORCE_CHANNEL}" ]]; then UPDATE_CHANNEL="${FORCE_CHANNEL}" print_info "Using $UPDATE_CHANNEL channel from command line" elif [[ -f "$CONFIG_DIR/system.json" ]]; then CONFIGURED_CHANNEL=$(cat "$CONFIG_DIR/system.json" 2>/dev/null | grep -o '"updateChannel"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/' || true) if [[ "$CONFIGURED_CHANNEL" == "rc" ]]; then UPDATE_CHANNEL="rc" print_info "RC channel detected in configuration" fi fi fi # Get appropriate release based on channel (with timeout) # Both stable and RC channels now use /releases endpoint to handle draft releases 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 # RC channel: Get latest release (including pre-releases, 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 # Fallback without jq: grep for first non-draft tag_name LATEST_RELEASE=$(echo "$RELEASES_JSON" | grep -v '"draft": true' | grep '"tag_name":' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true) fi else # Stable channel: Get latest non-draft, non-prerelease if command -v jq >/dev/null 2>&1; then LATEST_RELEASE=$(echo "$RELEASES_JSON" | jq -r '[.[] | select(.draft == false and .prerelease == false)][0].tag_name' 2>/dev/null || true) else # Fallback without jq: filter out both draft and prerelease LATEST_RELEASE=$(echo "$RELEASES_JSON" | awk '/"draft": true/,/"tag_name":/ {next} /"prerelease": true/,/"tag_name":/ {next} /"tag_name":/ {print; exit}' | sed -E 's/.*"([^"]+)".*/\1/' || true) fi fi fi # Fallback: Try direct GitHub redirect if API fails 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 [[ -n "$redirect_version" ]]; then LATEST_RELEASE="$redirect_version" fi fi # Final fallback: Use a known good version if [[ -z "$LATEST_RELEASE" ]]; then print_warn "Could not determine latest release from GitHub, using fallback version" LATEST_RELEASE="v4.5.1" # Known stable version as fallback fi print_info "Latest version: $LATEST_RELEASE" 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 rm -f "$BUILD_FROM_SOURCE_MARKER" # Detect architecture ARCH=$(uname -m) case $ARCH in x86_64) PULSE_ARCH="amd64" ;; aarch64) PULSE_ARCH="arm64" ;; armv7l) PULSE_ARCH="armv7" ;; *) print_error "Unsupported architecture: $ARCH" exit 1 ;; esac print_info "Detected architecture: $ARCH ($PULSE_ARCH)" # Download architecture-specific release ARCHIVE_NAME="pulse-${LATEST_RELEASE}-linux-${PULSE_ARCH}.tar.gz" DOWNLOAD_URL="https://github.com/$GITHUB_REPO/releases/download/$LATEST_RELEASE/${ARCHIVE_NAME}" CHECKSUMS_URL="https://github.com/$GITHUB_REPO/releases/download/$LATEST_RELEASE/checksums.txt" print_info "Downloading from: $DOWNLOAD_URL" # Detect and stop existing service BEFORE downloading (to free 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 # Give the process time to fully stop and release the binary fi cd /tmp if ! command -v sha256sum >/dev/null 2>&1; then print_error "sha256sum is required but not installed" exit 1 fi # Download with timeout (60 seconds should be enough for ~5MB file) ARCHIVE_PATH="/tmp/$ARCHIVE_NAME" 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" exit 1 fi # Download and verify checksum (try new format first, fall back to old) EXPECTED_CHECKSUM="" # Try checksums.txt first (v4.29.0+) if wget -q --timeout=60 --tries=2 -O "/tmp/checksums.txt" "$CHECKSUMS_URL" 2>/dev/null; then EXPECTED_CHECKSUM=$(grep -w "${ARCHIVE_NAME}" /tmp/checksums.txt 2>/dev/null | awk '{print $1}') rm -f /tmp/checksums.txt fi # Fall back to individual .sha256 file (v4.28.0 and earlier) if [ -z "$EXPECTED_CHECKSUM" ]; then CHECKSUM_URL="${DOWNLOAD_URL}.sha256" if wget -q --timeout=60 --tries=2 -O "${ARCHIVE_PATH}.sha256" "$CHECKSUM_URL" 2>/dev/null; then EXPECTED_CHECKSUM=$(awk '{print $1}' "${ARCHIVE_PATH}.sha256") rm -f "${ARCHIVE_PATH}.sha256" fi fi # If we still don't have a checksum, fail if [ -z "$EXPECTED_CHECKSUM" ]; then print_error "Failed to download checksum for Pulse release" print_info "Refusing to install without checksum verification" exit 1 fi # Verify the downloaded archive ACTUAL_CHECKSUM=$(sha256sum "${ARCHIVE_PATH}" | awk '{print $1}') if [ "$ACTUAL_CHECKSUM" != "$EXPECTED_CHECKSUM" ]; then print_error "Checksum verification failed for downloaded Pulse release" print_error "Expected: $EXPECTED_CHECKSUM" print_error "Got: $ACTUAL_CHECKSUM" exit 1 fi # Extract to temporary directory first TEMP_EXTRACT="/tmp/pulse-extract-$$" mkdir -p "$TEMP_EXTRACT" tar -xzf "$ARCHIVE_PATH" -C "$TEMP_EXTRACT" # Ensure install directory and bin subdirectory exist mkdir -p "$INSTALL_DIR/bin" # Copy Pulse binary to the correct location (/opt/pulse/bin/pulse) # First, backup the old binary if it exists if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then mv "$INSTALL_DIR/bin/pulse" "$INSTALL_DIR/bin/pulse.old" 2>/dev/null || true fi if [[ -f "$TEMP_EXTRACT/bin/pulse" ]]; then if ! cp "$TEMP_EXTRACT/bin/pulse" "$INSTALL_DIR/bin/pulse"; then print_error "Failed to copy new binary to $INSTALL_DIR/bin/pulse" # Try to restore old binary if [[ -f "$INSTALL_DIR/bin/pulse.old" ]]; then mv "$INSTALL_DIR/bin/pulse.old" "$INSTALL_DIR/bin/pulse" fi exit 1 fi elif [[ -f "$TEMP_EXTRACT/pulse" ]]; then # Fallback for old archives (pre-v4.3.1) if ! cp "$TEMP_EXTRACT/pulse" "$INSTALL_DIR/bin/pulse"; then print_error "Failed to copy new binary to $INSTALL_DIR/bin/pulse" # Try to restore old binary if [[ -f "$INSTALL_DIR/bin/pulse.old" ]]; then mv "$INSTALL_DIR/bin/pulse.old" "$INSTALL_DIR/bin/pulse" fi exit 1 fi else print_error "Pulse binary not found in archive" # Try to restore old binary if [[ -f "$INSTALL_DIR/bin/pulse.old" ]]; then mv "$INSTALL_DIR/bin/pulse.old" "$INSTALL_DIR/bin/pulse" fi exit 1 fi # Verify the new binary was copied and is executable if [[ ! -f "$INSTALL_DIR/bin/pulse" ]]; then print_error "Binary installation failed - file not found after copy" # Try to restore old binary if [[ -f "$INSTALL_DIR/bin/pulse.old" ]]; then mv "$INSTALL_DIR/bin/pulse.old" "$INSTALL_DIR/bin/pulse" fi exit 1 fi # Install Docker agent binary for distribution if [[ -f "$TEMP_EXTRACT/bin/pulse-docker-agent" ]]; then cp -f "$TEMP_EXTRACT/bin/pulse-docker-agent" "$INSTALL_DIR/pulse-docker-agent" cp -f "$TEMP_EXTRACT/bin/pulse-docker-agent" "$INSTALL_DIR/bin/pulse-docker-agent" chmod +x "$INSTALL_DIR/pulse-docker-agent" "$INSTALL_DIR/bin/pulse-docker-agent" chown pulse:pulse "$INSTALL_DIR/pulse-docker-agent" "$INSTALL_DIR/bin/pulse-docker-agent" ln -sf "$INSTALL_DIR/bin/pulse-docker-agent" /usr/local/bin/pulse-docker-agent print_success "Docker agent binary installed" else print_warn "Docker agent binary not found in archive; skipping installation" fi # Install host agent binary for distribution if [[ -f "$TEMP_EXTRACT/bin/pulse-host-agent" ]]; then cp -f "$TEMP_EXTRACT/bin/pulse-host-agent" "$INSTALL_DIR/bin/pulse-host-agent" chmod +x "$INSTALL_DIR/bin/pulse-host-agent" chown pulse:pulse "$INSTALL_DIR/bin/pulse-host-agent" print_success "Host agent binary installed" else print_warn "Host agent binary not found in archive; skipping installation" fi install_additional_agent_binaries "$LATEST_RELEASE" "$TEMP_EXTRACT" # Install all agent scripts deploy_agent_scripts "$TEMP_EXTRACT" chmod +x "$INSTALL_DIR/bin/pulse" chown -R pulse:pulse "$INSTALL_DIR" # Clean up old binary backup if everything succeeded rm -f "$INSTALL_DIR/bin/pulse.old" # Create symlink in /usr/local/bin for PATH convenience ln -sf "$INSTALL_DIR/bin/pulse" /usr/local/bin/pulse print_success "Pulse binary installed to $INSTALL_DIR/bin/pulse" print_success "Symlink created at /usr/local/bin/pulse" # Copy VERSION file if present if [[ -f "$TEMP_EXTRACT/VERSION" ]]; then cp "$TEMP_EXTRACT/VERSION" "$INSTALL_DIR/VERSION" chown pulse:pulse "$INSTALL_DIR/VERSION" fi # Verify the installed version matches what we expected 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" != "$LATEST_RELEASE" ]]; then print_warn "Version verification issue: Expected $LATEST_RELEASE but binary reports $INSTALLED_VERSION" print_info "This can happen if the binary wasn't properly replaced. Trying to fix..." # Force remove and recopy rm -f "$INSTALL_DIR/bin/pulse" if [[ -f "$ARCHIVE_PATH" ]]; then # Re-extract and try again TEMP_EXTRACT2="/tmp/pulse-extract2-$$" mkdir -p "$TEMP_EXTRACT2" tar -xzf "$ARCHIVE_PATH" -C "$TEMP_EXTRACT2" if [[ -f "$TEMP_EXTRACT2/bin/pulse" ]]; then cp -f "$TEMP_EXTRACT2/bin/pulse" "$INSTALL_DIR/bin/pulse" elif [[ -f "$TEMP_EXTRACT2/pulse" ]]; then cp -f "$TEMP_EXTRACT2/pulse" "$INSTALL_DIR/bin/pulse" fi if [[ -f "$TEMP_EXTRACT2/bin/pulse-docker-agent" ]]; then cp -f "$TEMP_EXTRACT2/bin/pulse-docker-agent" "$INSTALL_DIR/pulse-docker-agent" cp -f "$TEMP_EXTRACT2/bin/pulse-docker-agent" "$INSTALL_DIR/bin/pulse-docker-agent" chmod +x "$INSTALL_DIR/pulse-docker-agent" "$INSTALL_DIR/bin/pulse-docker-agent" ln -sf "$INSTALL_DIR/bin/pulse-docker-agent" /usr/local/bin/pulse-docker-agent fi install_additional_agent_binaries "$LATEST_RELEASE" "$TEMP_EXTRACT2" deploy_agent_scripts "$TEMP_EXTRACT2" chmod +x "$INSTALL_DIR/bin/pulse" chown -R pulse:pulse "$INSTALL_DIR" rm -rf "$TEMP_EXTRACT2" # Check version again 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" == "$LATEST_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 fi else print_success "Version verified: $INSTALLED_VERSION" fi # Restore SELinux contexts for installed binaries (Fedora, RHEL, etc.) restore_selinux_contexts # Cleanup rm -rf "$TEMP_EXTRACT" "$ARCHIVE_PATH" fi # End of SKIP_DOWNLOAD check } copy_host_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-host-agent-*; do [[ -e "$agent_file" ]] || continue local base base=$(basename "$agent_file") if [[ "$base" == "pulse-host-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 } 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:-}" if [[ -z "$version" ]]; then return fi local docker_targets=("linux-amd64" "linux-arm64" "linux-armv7") local host_targets=("linux-amd64" "linux-arm64" "linux-armv7" "linux-armv6" "linux-386" "darwin-amd64" "darwin-arm64" "windows-amd64" "windows-arm64" "windows-386") 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 copy_host_agent_binaries_from_dir "$source_dir" || true copy_unified_agent_binaries_from_dir "$source_dir" || true local docker_missing_targets=() for target in "${docker_targets[@]}"; do if [[ ! -f "$INSTALL_DIR/bin/pulse-docker-agent-$target" ]]; then docker_missing_targets+=("$target") fi done local host_missing_targets=() for target in "${host_targets[@]}"; do if [[ "$target" == windows-* ]]; then if [[ ! -e "$INSTALL_DIR/bin/pulse-host-agent-$target" && ! -e "$INSTALL_DIR/bin/pulse-host-agent-$target.exe" ]]; then host_missing_targets+=("$target") fi else if [[ ! -e "$INSTALL_DIR/bin/pulse-host-agent-$target" ]]; then host_missing_targets+=("$target") fi fi done 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 [[ ${#docker_missing_targets[@]} -eq 0 ]] && [[ ${#host_missing_targets[@]} -eq 0 ]] && [[ ${#unified_missing_targets[@]} -eq 0 ]]; then return fi local universal_url="https://github.com/$GITHUB_REPO/releases/download/$version/pulse-${version}.tar.gz" local universal_tar="/tmp/pulse-universal-${version}.tar.gz" print_info "Downloading universal agent bundle for cross-architecture support..." if command -v curl >/dev/null 2>&1; then if ! curl -fsSL --connect-timeout 10 --max-time 300 -o "$universal_tar" "$universal_url"; then print_warn "Failed to download universal agent bundle" rm -f "$universal_tar" return fi elif command -v wget >/dev/null 2>&1; then if ! wget -q --timeout=300 -O "$universal_tar" "$universal_url"; then print_warn "Failed to download universal agent bundle" rm -f "$universal_tar" return fi else print_warn "Cannot download universal agent bundle (curl or wget not available)" return fi local temp_dir temp_dir=$(mktemp -d -t pulse-universal-XXXXXX) if ! tar -xzf "$universal_tar" -C "$temp_dir"; then print_warn "Failed to extract universal agent bundle" rm -f "$universal_tar" rm -rf "$temp_dir" return fi local docker_installed=0 local host_installed=0 # Install Docker agent binaries for agent_file in "$temp_dir"/bin/pulse-docker-agent-linux-*; do if [[ -f "$agent_file" ]]; then local base base=$(basename "$agent_file") cp -f "$agent_file" "$INSTALL_DIR/bin/$base" cp -f "$agent_file" "$INSTALL_DIR/$base" chmod +x "$INSTALL_DIR/bin/$base" "$INSTALL_DIR/$base" chown pulse:pulse "$INSTALL_DIR/bin/$base" "$INSTALL_DIR/$base" docker_installed=1 fi done # Install host agent binaries (preserve symlinks for Windows targets) if copy_host_agent_binaries_from_dir "$temp_dir"; then host_installed=1 fi # Install unified agent binaries (preserve symlinks for Windows targets) local unified_installed=0 if copy_unified_agent_binaries_from_dir "$temp_dir"; then unified_installed=1 fi if [[ $docker_installed -eq 1 ]]; then print_success "Additional Docker agent binaries installed" fi if [[ $host_installed -eq 1 ]]; then print_success "Additional host agent binaries installed" fi if [[ $unified_installed -eq 1 ]]; then print_success "Unified agent binaries installed" fi if [[ $docker_installed -eq 0 ]] && [[ $host_installed -eq 0 ]] && [[ $unified_installed -eq 0 ]]; then print_warn "No agent binaries found in universal bundle" fi rm -f "$universal_tar" rm -rf "$temp_dir" } 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-docker-agent.sh" "install-container-agent.sh" "install-host-agent.ps1" "uninstall-host-agent.sh" "uninstall-host-agent.ps1" "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" 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_agent_binaries_from_source() { local agent_version="$1" if ! command -v go >/dev/null 2>&1; then print_warn "Go not available for cross-compiling additional agent binaries" return fi local targets=( "linux-amd64:amd64:" "linux-arm64:arm64:" "linux-armv7:arm:7" ) for entry in "${targets[@]}"; do IFS=':' read -r suffix goarch goarm <<< "$entry" local output="/tmp/pulse-docker-agent-$suffix-$$" # Skip if already exists if [[ -f "$INSTALL_DIR/bin/pulse-docker-agent-$suffix" ]]; then continue fi print_info "Cross-compiling Docker agent for $suffix" local env_cmd=(env GOOS=linux GOARCH="$goarch") if [[ -n "$goarm" ]]; then env_cmd+=(GOARM="$goarm") fi if ! "${env_cmd[@]}" go build -ldflags="-X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=${agent_version}" -o "$output" ./cmd/pulse-docker-agent >/dev/null 2>&1; then print_warn "Failed to build Docker agent for $suffix" rm -f "$output" continue fi cp -f "$output" "$INSTALL_DIR/bin/pulse-docker-agent-$suffix" cp -f "$output" "$INSTALL_DIR/pulse-docker-agent-$suffix" chmod +x "$INSTALL_DIR/bin/pulse-docker-agent-$suffix" "$INSTALL_DIR/pulse-docker-agent-$suffix" chown pulse:pulse "$INSTALL_DIR/bin/pulse-docker-agent-$suffix" "$INSTALL_DIR/pulse-docker-agent-$suffix" rm -f "$output" done } build_from_source() { local branch="${1:-main}" local original_dir original_dir=$(pwd) local temp_build="" local GO_MIN_VERSION="1.24" local GO_INSTALLED=false local arch="" local go_arch="" local agent_version="" 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/go1.24.7.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 "go1.24.7.linux-${go_arch}.tar.gz"; then print_error "Failed to extract Go toolchain" rm -f "go1.24.7.linux-${go_arch}.tar.gz" cd "$original_dir" >/dev/null 2>&1 || true return 1 fi rm -f "go1.24.7.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" agent_version=$(git describe --tags --always --dirty 2>/dev/null || echo "dev") if ! go build -ldflags="-X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=${agent_version}" -o pulse-docker-agent ./cmd/pulse-docker-agent >/dev/null 2>&1; then print_error "Failed to build Docker agent binary" cd "$original_dir" >/dev/null 2>&1 || true rm -rf "$temp_build" return 1 fi cp -f pulse-docker-agent "$INSTALL_DIR/pulse-docker-agent" cp -f pulse-docker-agent "$INSTALL_DIR/bin/pulse-docker-agent" chmod +x "$INSTALL_DIR/pulse-docker-agent" "$INSTALL_DIR/bin/pulse-docker-agent" ln -sf "$INSTALL_DIR/bin/pulse-docker-agent" /usr/local/bin/pulse-docker-agent rm -f pulse-docker-agent build_agent_binaries_from_source "$agent_version" for script_name in install-docker-agent.sh install-docker.sh; 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 ln -sf "$INSTALL_DIR/bin/pulse" /usr/local/bin/pulse 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=7 #PULSE_MOCK_VMS_PER_NODE=5 #PULSE_MOCK_LXCS_PER_NODE=8 #PULSE_MOCK_RANDOM_METRICS=true #PULSE_MOCK_STOPPED_PERCENT=20 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 cat > /bin/update <<'EOF' #!/usr/bin/env bash # Pulse update command # This script re-runs the Pulse installer to update to the latest version set -e INSTALL_ROOT="/opt/pulse" MARKER_FILE="${INSTALL_ROOT}/BUILD_FROM_SOURCE" extra_args=() 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 fi echo "Updating Pulse..." if [[ ${#extra_args[@]} -gt 0 ]]; then curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash -s -- "${extra_args[@]}" else curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash fi echo "" echo "Update complete! Pulse will restart automatically." EOF chmod +x /bin/update # 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 } download_auto_update_script() { local url="https://github.com/$GITHUB_REPO/releases/latest/download/pulse-auto-update.sh" local dest="/usr/local/bin/pulse-auto-update.sh" local attempts=0 local max_attempts=3 local connect_timeout=15 local max_time=60 while (( attempts < max_attempts )); do ((attempts++)) local curl_status=0 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 chmod +x "$dest" return 0 else curl_status=$? fi else if curl -fsSL --connect-timeout "$connect_timeout" --max-time "$max_time" -o "$dest" "$url"; then chmod +x "$dest" return 0 else curl_status=$? 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 } setup_auto_updates() { print_info "Setting up automatic updates..." # Copy auto-update script if it exists in the release if [[ -f "$INSTALL_DIR/scripts/pulse-auto-update.sh" ]]; then cp "$INSTALL_DIR/scripts/pulse-auto-update.sh" /usr/local/bin/pulse-auto-update.sh chmod +x /usr/local/bin/pulse-auto-update.sh else print_info "Downloading auto-update script..." if ! download_auto_update_script; then print_warn "Could not download the auto-update helper after multiple attempts." print_warn "Continuing without automatic updates. Re-run install.sh with --enable-auto-updates once connectivity is stable." ENABLE_AUTO_UPDATES=false return 0 fi fi # Install systemd timer and service cat > /etc/systemd/system/pulse-update.service << 'EOF' [Unit] Description=Automatic Pulse update check and install Documentation=https://github.com/rcourtman/Pulse After=network-online.target Wants=network-online.target [Service] Type=oneshot User=root Group=root ExecStart=/usr/local/bin/pulse-auto-update.sh Restart=no TimeoutStartSec=600 StandardOutput=journal StandardError=journal SyslogIdentifier=pulse-update PrivateTmp=yes ProtectHome=yes ProtectSystem=strict ReadWritePaths=/opt/pulse /etc/pulse /tmp PrivateNetwork=no Nice=10 [Install] WantedBy=multi-user.target EOF cat > /etc/systemd/system/pulse-update.timer << 'EOF' [Unit] Description=Daily check for Pulse updates Documentation=https://github.com/rcourtman/Pulse After=network-online.target Wants=network-online.target [Timer] OnCalendar=daily OnCalendar=02:00 RandomizedDelaySec=4h Persistent=true AccuracySec=1h [Install] WantedBy=timers.target EOF # Reload systemd daemon safe_systemctl daemon-reload # Enable timer but don't start it yet safe_systemctl enable pulse-update.timer || 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 '.autoUpdateEnabled = true' "$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 fi else # Create new file with auto-updates enabled echo '{"autoUpdateEnabled":true,"pollingInterval":5}' > "$CONFIG_DIR/system.json" fi chown pulse:pulse "$CONFIG_DIR/system.json" 2>/dev/null || true # Start the timer safe_systemctl start pulse-update.timer || true print_success "Automatic updates enabled (daily check with 2-6 hour random delay)" } install_systemd_service() { print_info "Installing systemd service..." # Use existing service name if found, otherwise use default EXISTING_SERVICE=$(detect_service_name) 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" 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 [Install] WantedBy=multi-user.target EOF # Reload systemd daemon safe_systemctl daemon-reload } start_pulse() { print_info "Starting Pulse..." # 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)" print_info "The service will start automatically when the container starts" return 0 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 } 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="${FRONTEND_PORT:-7655}" if [[ -z "${FRONTEND_PORT:-}" ]] && [[ -f "/etc/systemd/system/$SERVICE_NAME.service" ]]; then # Try to extract port from service file PORT=$(grep -oP 'FRONTEND_PORT=\K\d+' "/etc/systemd/system/$SERVICE_NAME.service" 2>/dev/null || echo "7655") fi 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: curl -sSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash" echo " Reset: curl -sSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash -s -- --reset" echo " Uninstall: curl -sSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash -s -- --uninstall" # Show auto-update status if timer exists if systemctl list-unit-files --no-legend | grep -q "^pulse-update.timer"; then echo echo -e "${YELLOW}Auto-updates:${NC}" if systemctl is-enabled --quiet pulse-update.timer 2>/dev/null; then echo -e " Status: ${GREEN}Enabled${NC} (daily check between 2-6 AM)" echo " Disable: systemctl disable --now pulse-update.timer" else echo -e " Status: ${YELLOW}Disabled${NC}" echo " Enable: systemctl enable --now pulse-update.timer" 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 BOOTSTRAP_TOKEN=$(cat "$TOKEN_FILE" 2>/dev/null | tr -d '\n') if [[ -n "$BOOTSTRAP_TOKEN" ]]; then echo echo -e "${YELLOW}╔═══════════════════════════════════════════════════════════════════════╗${NC}" echo -e "${YELLOW}║ BOOTSTRAP TOKEN REQUIRED FOR FIRST-TIME SETUP ║${NC}" echo -e "${YELLOW}╠═══════════════════════════════════════════════════════════════════════╣${NC}" printf "${YELLOW}║${NC} Token: ${GREEN}%-61s${YELLOW}║${NC}\n" "$BOOTSTRAP_TOKEN" printf "${YELLOW}║${NC} File: %-61s${YELLOW}║${NC}\n" "$TOKEN_FILE" echo -e "${YELLOW}╠═══════════════════════════════════════════════════════════════════════╣${NC}" echo -e "${YELLOW}║${NC} Copy this token and paste it 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 } # 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 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 [[ "$ENABLE_AUTO_UPDATES" != "true" ]] && [[ "$IN_DOCKER" != "true" ]]; then local should_ask_about_updates=false local prompt_reason="" if ! systemctl list-unit-files --no-legend 2>/dev/null | grep -q "^pulse-update.timer"; 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) backup_existing systemctl stop $SERVICE_NAME || true create_user download_pulse setup_update_command # Setup auto-updates if requested if [[ "$ENABLE_AUTO_UPDATES" == "true" ]]; then setup_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="" if command -v timeout >/dev/null 2>&1; then STABLE_VERSION=$(timeout 10 curl -s --connect-timeout 5 --max-time 10 https://api.github.com/repos/$GITHUB_REPO/releases/latest 2>/dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' || true) else STABLE_VERSION=$(curl -s --connect-timeout 5 --max-time 10 https://api.github.com/repos/$GITHUB_REPO/releases/latest 2>/dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' || true) fi # If rate limited or failed, try direct GitHub latest URL if [[ -z "$STABLE_VERSION" ]] || [[ "$STABLE_VERSION" == *"rate limit"* ]]; then # Use the GitHub latest release redirect to get version local redirect_version="" redirect_version=$(get_latest_release_from_redirect 2>/dev/null || true) if [[ -n "$redirect_version" ]]; then STABLE_VERSION="$redirect_version" fi fi # 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}" elif [[ -f "$CONFIG_DIR/system.json" ]]; then CONFIGURED_CHANNEL=$(cat "$CONFIG_DIR/system.json" 2>/dev/null | grep -o '"updateChannel"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/' || 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 (release candidate)" ((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 [[ "$ENABLE_AUTO_UPDATES" != "true" ]] && [[ "$IN_DOCKER" != "true" ]]; then local should_ask_about_updates=false local prompt_reason="" if ! systemctl list-unit-files --no-legend 2>/dev/null | grep -q "^pulse-update.timer"; 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 backup_existing systemctl stop $SERVICE_NAME || true create_user download_pulse setup_update_command # Setup auto-updates if requested during update if [[ "$ENABLE_AUTO_UPDATES" == "true" ]]; then setup_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 [[ "$ENABLE_AUTO_UPDATES" != "true" ]] && [[ "$IN_DOCKER" != "true" ]]; then local should_ask_about_updates=false local prompt_reason="" if ! systemctl list-unit-files --no-legend 2>/dev/null | grep -q "^pulse-update.timer"; 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 systemctl stop $SERVICE_NAME || true create_user download_pulse setup_directories setup_update_command install_systemd_service # Setup auto-updates if requested during reinstall if [[ "$ENABLE_AUTO_UPDATES" == "true" ]]; then setup_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 rm -f /etc/systemd/system/pulse.service rm -f /etc/systemd/system/pulse-backend.service # Reload systemd daemon safe_systemctl daemon-reload # Remove installation directory rm -rf "$INSTALL_DIR" # Remove symlink rm -f /usr/local/bin/pulse # 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 rm -f /opt/pulse.log 2>/dev/null || true 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 [[ "$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 if [[ "$ENABLE_AUTO_UPDATES" == "true" ]]; then setup_auto_updates fi start_pulse create_marker_file print_completion fi } # Uninstall function uninstall_pulse() { check_root print_header echo -e "\033[0;33mUninstalling Pulse...\033[0m" echo # Detect service name local 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 systemctl is-enabled --quiet pulse-update.timer 2>/dev/null; then echo "Disabling auto-update timer..." systemctl disable --now pulse-update.timer fi # Remove files echo "Removing Pulse files..." rm -rf /opt/pulse rm -rf /etc/pulse rm -f /etc/systemd/system/pulse.service rm -f /etc/systemd/system/pulse-backend.service rm -f /etc/systemd/system/pulse-update.service rm -f /etc/systemd/system/pulse-update.timer rm -f /usr/local/bin/pulse rm -f /usr/local/bin/pulse-auto-update.sh # Don't remove update commands - might be from community scripts # rm -f /usr/local/bin/update # 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 # 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=$(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 /etc/pulse/* # 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}'):7655" exit 0 } # 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) FORCE_VERSION="$2" shift 2 ;; --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 shift ;; --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 RC/pre-release version" echo " --stable Install latest stable version (default)" echo " --version VERSION Install specific version (e.g., v4.4.0-rc.1)" echo " --source [BRANCH] Build and install from source (default: main)" echo " --enable-auto-updates Enable automatic stable updates (via systemd timer)" 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 auto_detect_container_environment # Export for use in download_pulse function export FORCE_VERSION FORCE_CHANNEL # Run main function main