#!/bin/bash # Pulse Automatic Update Script # This script checks for and installs stable Pulse updates # It is designed to be run by systemd timer set -euo pipefail # Configuration GITHUB_REPO="${GITHUB_REPO:-rcourtman/Pulse}" SERVICE_NAME="${PULSE_SERVICE_NAME:-pulse}" INSTALL_DIR="${PULSE_INSTALL_DIR:-/opt/pulse}" CONFIG_DIR="${PULSE_CONFIG_DIR:-/etc/pulse}" UPDATE_TIMER_UNIT="${PULSE_UPDATE_TIMER_UNIT:-${SERVICE_NAME}-update.timer}" LOG_TAG="${PULSE_AUTO_UPDATE_LOG_TAG:-${SERVICE_NAME}-auto-update}" MAX_LOG_SIZE=10485760 # 10MB INSTALL_SIGNATURE_IDENTITY="pulse-installer" INSTALL_SIGNATURE_NAMESPACE="pulse-install" PINNED_RELEASE_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer" # Logging function log() { local level=$1 shift local logger_status=0 output_status=0 # Conditional callers disable errexit throughout this function. Try both # sinks, but never let successful stdout hide a failed journal write (or # vice versa). The installer collector owns draining and the first error. logger -t "$LOG_TAG" -p "user.$level" "$@" || logger_status=$? echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $@" || output_status=$? if [[ "$logger_status" != 0 ]]; then return "$logger_status" fi return "$output_status" } release_signature_key_available() { [[ -n "${PINNED_RELEASE_SSH_PUBLIC_KEY:-}" ]] } require_release_signature_verifier() { if ! release_signature_key_available; then log error "Pinned release signature key is not configured" return 1 fi if ! command -v ssh-keygen >/dev/null 2>&1; then log error "ssh-keygen is required to verify signed Pulse release assets" return 1 fi } verify_release_signature() { local target_path="$1" local signature_path="$2" local context="${3:-downloaded file}" local allowed_signers="" if ! require_release_signature_verifier; then return 1 fi allowed_signers=$(mktemp /tmp/pulse-release-signers.XXXXXX) printf '%s %s\n' "$INSTALL_SIGNATURE_IDENTITY" "$PINNED_RELEASE_SSH_PUBLIC_KEY" > "$allowed_signers" if ! ssh-keygen -Y verify \ -f "$allowed_signers" \ -I "$INSTALL_SIGNATURE_IDENTITY" \ -n "$INSTALL_SIGNATURE_NAMESPACE" \ -s "$signature_path" < "$target_path" >/dev/null 2>&1; then rm -f "$allowed_signers" log error "Cryptographic signature verification failed for ${context}" return 1 fi rm -f "$allowed_signers" return 0 } # Check if auto-updates are enabled check_auto_updates_enabled() { # Consent must come from one complete object, not matching text in a # truncated file, nested setting or quoted string. jq is already an # installer dependency; without it, leave manual updates available but # refuse to guess whether an unattended update was authorised. if [[ -e "$CONFIG_DIR/system.json" || -L "$CONFIG_DIR/system.json" ]]; then local settings="" enabled="" channel="" if [[ ! -f "$CONFIG_DIR/system.json" ]] || ! command -v jq >/dev/null 2>&1; then log error "Cannot verify auto-update configuration; a regular system.json and jq are required" exit 1 fi if ! settings=$(jq -er -s ' if length != 1 or (.[0] | type) != "object" then error("expected one configuration object") else .[0] end | (.updateChannel | if . == null then "" else . end) as $channel | if ((.autoUpdateEnabled // false) | type) != "boolean" or ($channel | type) != "string" then error("invalid update settings") else [(.autoUpdateEnabled // false), ($channel | gsub("^\\s+|\\s+$"; "") | ascii_downcase)] | @tsv end ' "$CONFIG_DIR/system.json" 2>/dev/null); then log error "Cannot parse auto-update configuration; no unattended update attempted" exit 1 fi IFS=$'\t' read -r enabled channel <<< "$settings" if [[ "$enabled" != "true" ]]; then log info "Auto-updates disabled in configuration" exit 0 fi if [[ -n "$channel" && "$channel" != "stable" ]]; then log info "Non-stable channel detected; unattended auto-updates run only on stable" exit 0 fi fi # Also check if timer is enabled (belt and suspenders) if ! systemctl is-enabled --quiet "$UPDATE_TIMER_UNIT" 2>/dev/null; then log info "Auto-update timer is disabled" exit 0 fi } # Read version identity from the executable, never installation metadata. This # runs both before discovery and after installer success: a VERSION sidecar can # survive a failed replacement and cannot prove the new binary is usable. get_current_version() { local binary="" version_output="" first_line="" if [[ -e "$INSTALL_DIR/bin/pulse" || -L "$INSTALL_DIR/bin/pulse" ]]; then binary="$INSTALL_DIR/bin/pulse" elif [[ -e "$INSTALL_DIR/pulse" || -L "$INSTALL_DIR/pulse" ]]; then binary="$INSTALL_DIR/pulse" fi if [[ -z "$binary" || ! -f "$binary" || ! -x "$binary" ]] || ! version_output=$(timeout --kill-after=1 5 "$binary" --version 2>/dev/null); then echo unknown return 0 fi first_line=${version_output%%$'\n'*} if [[ "$first_line" =~ ^Pulse\ v?([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?)$ ]]; then printf 'v%s\n' "${BASH_REMATCH[1]}" else echo unknown fi } # The unattended updater installs public community builds. The separately # compiled Pulse Pro binary (--version reports "Pulse Pro vX.Y.Z") must never # be replaced by one: that silently strips Audit, RBAC, Reporting, and SSO. # Pro installs update in-app through the license server download broker, or # manually via https://pulserelay.pro/download.html + install.sh --archive. installed_binary_is_pulse_pro() { local binary="" version_output="" first_line="" # 0 = Pro (leave it alone), 1 = verified community, 2 = unknown (stop). # A VERSION file cannot identify the edition, and a broken primary binary # must not fall back to an unrelated legacy executable. if [[ -e "$INSTALL_DIR/bin/pulse" || -L "$INSTALL_DIR/bin/pulse" ]]; then binary="$INSTALL_DIR/bin/pulse" elif [[ -e "$INSTALL_DIR/pulse" || -L "$INSTALL_DIR/pulse" ]]; then binary="$INSTALL_DIR/pulse" else return 2 fi if [[ ! -f "$binary" || ! -x "$binary" ]] || ! version_output=$(timeout --kill-after=1 5 "$binary" --version 2>/dev/null); then return 2 fi first_line=${version_output%%$'\n'*} if [[ "$first_line" == 'Pulse Pro '* ]]; then return 0 fi if [[ "$first_line" =~ ^Pulse\ v?[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then return 1 fi return 2 } # Determine whether a tag is a semver pre-release. # # Per semver 2.0.0, any identifier after the patch version introduced by a # hyphen (e.g. `v6.0.0-rc.2`, `v5.1.28-beta.1`, `v5.1.28-nightly`) is a # pre-release identifier. The unattended updater must refuse these on the # stable channel regardless of what GitHub's `/releases/latest` endpoint # returns, because that endpoint has, in the wild, briefly surfaced tags # that were later corrected to prerelease=true (see the 2026-04-16 incident # that bumped demo.pulserelay.pro from 5.1.27 to 6.0.0-rc.2). # # Returns 0 if the tag looks like a prerelease, 1 otherwise. Empty or # obviously-malformed input is treated as prerelease (fail-closed). is_prerelease_tag() { local tag="${1:-}" if [[ -z "$tag" ]]; then return 0 fi # Must look like semver: v?MAJOR.MINOR.PATCH with optional suffix. if [[ ! "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]]; then return 0 fi # Any hyphen after the patch component is a prerelease identifier. if [[ "$tag" == *-* ]]; then return 0 fi return 1 } # Select the highest stable version among a list of tags fed on stdin, # one per line. Tags are filtered through is_prerelease_tag (fail-closed), # which also drops non-semver tags like helm-chart-*. pick_highest_stable_tag() { local best="" local tag="" while IFS= read -r tag; do [[ -z "$tag" ]] && continue if is_prerelease_tag "$tag"; then continue fi if [[ -z "$best" ]] || version_greater_than "$tag" "$best"; then best="$tag" fi done echo "$best" } # Get latest stable release from GitHub. Maturity and tag must come from the # same complete release object, independent of JSON whitespace or field order. get_latest_stable_version() { local endpoint="" release_json="" stable_tags="" latest_version="" if ! command -v jq >/dev/null 2>&1; then log error "jq is required to verify release metadata; no unattended update attempted" echo "" return 0 fi # Prefer the highest stable in the list, not GitHub's creation ordering. # If that read is unusable, /latest is a separate metadata-confirmed path. # A redirect alone cannot prove draft/prerelease status and is not used. for endpoint in 'releases?per_page=30' 'releases/latest'; do if ! release_json=$(curl --disable --fail --silent --show-error \ --connect-timeout 5 --max-time 20 --proto '=https' \ "https://api.github.com/repos/$GITHUB_REPO/$endpoint"); then continue fi # Slurp before selecting: truncated or concatenated JSON must not # leak a previously parsed tag. Ignore only ineligible release objects; # do not borrow missing flags from siblings, nested data or body text. if ! stable_tags=$(jq -er -s --arg endpoint "$endpoint" ' if length != 1 then error("expected one release document") else .[0] end | if $endpoint == "releases/latest" then if type == "object" then [.] else error("expected a release object") end else if type == "array" then . else error("expected a release list") end end | if all(.[]; type == "object") then . else error("invalid release entry") end | .[] | select(.draft == false and .prerelease == false) | select((.tag_name | type) == "string") | .tag_name | select(test("\\Av?[0-9]+\\.[0-9]+\\.[0-9]+\\z")) ' <<< "$release_json" 2>/dev/null); then continue fi latest_version=$(printf '%s\n' "$stable_tags" | pick_highest_stable_tag) if [[ -n "$latest_version" ]]; then echo "$latest_version" return 0 fi done echo "" } # Compare versions (returns 0 if v1 > v2, 1 if v1 <= v2) version_greater_than() { local v1="${1#v}" # Remove 'v' prefix local v2="${2#v}" # Don't update if current version is unknown if [[ "$2" == "unknown" ]]; then return 1 fi # Split versions into parts IFS='.' read -ra V1_PARTS <<< "${v1%%-*}" # Remove pre-release suffix IFS='.' read -ra V2_PARTS <<< "${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 0 elif [[ "$p1" -lt "$p2" ]]; then return 1 fi done # Versions are equal in major.minor.patch # Now check pre-release suffixes local has_suffix1=false local has_suffix2=false [[ "$v1" == *-* ]] && has_suffix1=true [[ "$v2" == *-* ]] && has_suffix2=true # Stable (no suffix) > pre-release (has suffix) if [[ "$has_suffix1" == "false" ]] && [[ "$has_suffix2" == "true" ]]; then return 0 # v1 (stable) > v2 (pre-release) elif [[ "$has_suffix1" == "true" ]] && [[ "$has_suffix2" == "false" ]]; then return 1 # v1 (pre-release) < v2 (stable) elif [[ "$has_suffix1" == "true" ]] && [[ "$has_suffix2" == "true" ]]; then # Both are pre-releases, compare suffixes lexicographically local suffix1="${v1#*-}" local suffix2="${v2#*-}" if [[ "$suffix1" > "$suffix2" ]]; then return 0 fi fi return 1 # v1 <= v2 } # Read the unit-file inventory for exact unit names. systemd exits 1 and prints # nothing at all, on stdout or stderr, when none of the named unit files exist. # That is an empty inventory. A timeout, a signal, another failed status, or # exit 1 with output or a message remains a failure. Capture both streams in # the same read: a later silent sample must # never discard an earlier diagnostic. unit_file_inventory() { local out="" rc=0 # Keep trailing newlines: an emitted blank line is not a silent exit 1. out=$(LC_ALL=C timeout -k 1 5 systemctl list-unit-files --no-legend --no-pager --full -- "$@" 2>&1; rc=$?; printf '.'; exit "$rc") || rc=$? out="${out%.}" if (( rc == 1 )) && [[ -z "$out" ]]; then rc=0 fi printf '%s' "$out" return "$rc" } # Detect service name (could be pulse or pulse-backend) detect_service_name() { if [[ -n "${PULSE_SERVICE_NAME:-}" ]]; then echo "$SERVICE_NAME" return fi if ! command -v systemctl >/dev/null 2>&1; then echo "pulse" return fi local unit_files="" unit="" state="" preset="" extra="" local seen_backend=false seen_pulse=false # Keep the standalone helper aligned with the server installer's bounded # discovery. Its explicit installer handoff must not carry a short-read # default past the installer's own discovery gate. if ! unit_files=$(unit_file_inventory pulse-backend.service pulse.service); then log error "Cannot identify the Pulse systemd service: unit inventory failed. Reconcile service state before retrying." >&2 return 1 fi while read -r unit state preset extra; do [[ -n "$unit" ]] || continue if [[ "$unit" == pulse-backend.service && "$seen_backend" == false ]]; then seen_backend=true elif [[ "$unit" == pulse.service && "$seen_pulse" == false ]]; then seen_pulse=true else log error "Cannot identify the Pulse systemd service: unexpected unit inventory. No default service was selected." >&2 return 1 fi case "$state" in enabled|enabled-runtime|disabled|static|indirect|linked|linked-runtime|alias|generated|transient|masked|masked-runtime) ;; *) log error "Cannot identify the Pulse systemd service: unknown unit state. No default service was selected." >&2 return 1 ;; esac if [[ -n "$extra" ]]; then log error "Cannot identify the Pulse systemd service: malformed unit inventory. No default service was selected." >&2 return 1 fi done <<< "$unit_files" if [[ "$seen_backend" == true ]]; then echo "pulse-backend" else echo "pulse" # Only a successful absent inventory permits the default. fi } resolve_install_script_url() { local target_version=$1 printf 'https://github.com/%s/releases/download/%s/install.sh\n' "$GITHUB_REPO" "$target_version" } # Small signed installer assets must not consume the systemd transaction's # entire ten-minute budget before installation/recovery can even start. Ignore # ambient curl configuration and refuse a redirect that downgrades HTTPS. # Signature verification remains mandatory after both complete transfers. download_update_asset() { curl --disable -fsSL --connect-timeout 5 --max-time 60 \ --proto '=https' --proto-redir '=https' "$1" -o "$2" } # Read a complete, bounded manager observation. An is-active failure conflates # an inactive unit with an unavailable manager; neither missing text nor a # plausible partial result grants an update, restart or rollback. read_update_service_state() { local service_name=$1 observation="" line="" load="" state="" local seen_load=false seen_state=false read_completed=false # Keep diagnostics in the same observation and preserve trailing blank # records. Command substitution otherwise discards them, turning an # incomplete manager reply into consent for update/restart/rollback. # The completion marker also requires the last property to end normally. if ! observation=$(LC_ALL=C timeout -k 1 5 systemctl show "$service_name" --no-pager --property=LoadState --property=ActiveState 2>&1 && printf '%s' '__pulse_state_read_completed__'); then log error "Cannot read Pulse service state ($service_name); reconcile service state before retrying" >&2 return 1 fi while IFS= read -r line; do [[ "$read_completed" == false ]] || return 1 case "$line" in LoadState=*) [[ "$seen_load" == false ]] || { log error "Duplicate Pulse service load state ($service_name)" >&2; return 1; } seen_load=true; load=${line#LoadState=} ;; ActiveState=*) [[ "$seen_state" == false ]] || { log error "Duplicate Pulse service active state ($service_name)" >&2; return 1; } seen_state=true; state=${line#ActiveState=} ;; __pulse_state_read_completed__) read_completed=true ;; *) log error "Incomplete Pulse service observation ($service_name); no service action is safe" >&2 return 1 ;; esac done <<< "$observation" if [[ "$seen_load" != true || "$seen_state" != true || "$read_completed" != true || ( "$load" != loaded && "$load" != masked ) ]]; then log error "Pulse service is unavailable ($service_name); no service action is safe" >&2 return 1 fi case "$state" in active|inactive|failed|activating|deactivating|reloading|refreshing|maintenance) printf '%s\n' "$state" ;; *) log error "Unknown Pulse service state ($service_name); no service action is safe" >&2; return 1 ;; esac } # Active alone can describe an old executable left running after replacement. # Read one complete tuple so a missing/recycled process is not update evidence. read_update_service_main_pid() { local service_name=$1 observation="" line="" load="" state="" pid="" local seen_load=false seen_state=false seen_pid=false read_completed=false # Both sides of the executable comparison require a complete read, just # like update admission. Never hide a diagnostic behind a plausible PID. if ! observation=$(LC_ALL=C timeout -k 1 5 systemctl show "$service_name" --no-pager --property=LoadState --property=ActiveState --property=MainPID 2>&1 && printf '%s' '__pulse_state_read_completed__'); then log error "Cannot read running Pulse identity ($service_name); reconcile service state before recovery" >&2 return 1 fi while IFS= read -r line; do [[ "$read_completed" == false ]] || return 1 case "$line" in LoadState=*) [[ "$seen_load" == false ]] || return 1 seen_load=true; load=${line#LoadState=} ;; ActiveState=*) [[ "$seen_state" == false ]] || return 1 seen_state=true; state=${line#ActiveState=} ;; MainPID=*) [[ "$seen_pid" == false ]] || return 1 seen_pid=true; pid=${line#MainPID=} ;; __pulse_state_read_completed__) read_completed=true ;; *) return 1 ;; esac done <<< "$observation" if [[ "$seen_load" != true || "$seen_state" != true || "$seen_pid" != true || "$read_completed" != true || ( "$load" != loaded && "$load" != masked ) || "$state" != active || ! "$pid" =~ ^[1-9][0-9]*$ ]]; then log error "Incomplete running Pulse identity ($service_name); reconcile service state before recovery" >&2 return 1 fi printf '%s\n' "$pid" } # Never restart/roll back merely because process readback is unavailable or # differs. Keep the backup and current service for explicit reconciliation. # Byte comparison admits an equivalent executable at a custom/symlinked path, # but not a VERSION sidecar or an old process whose on-disk path was replaced. verify_update_running_executable() { local service_name=$1 binary="" before_pid="" after_pid="" if [[ -e "$INSTALL_DIR/bin/pulse" || -L "$INSTALL_DIR/bin/pulse" ]]; then binary="$INSTALL_DIR/bin/pulse" else binary="$INSTALL_DIR/pulse" fi before_pid=$(read_update_service_main_pid "$service_name") || return 2 if [[ ! -f "$binary" || ! -x "$binary" ]] || ! timeout -k 1 5 cmp -s -- "$binary" "/proc/$before_pid/exe"; then log error "Cannot confirm running Pulse matches the installed executable ($service_name)" >&2 return 2 fi after_pid=$(read_update_service_main_pid "$service_name") || return 2 if [[ "$before_pid" != "$after_pid" ]]; then log error "Running Pulse identity changed during verification ($service_name)" >&2 return 2 fi return 0 } # Only a fresh, settled observation permits an explicit start. Return 2 for # uncertainty so callers do not turn an unavailable read into rollback consent. start_update_service_if_stopped() { local service_name=$1 state="" state=$(read_update_service_state "$service_name") || return 2 case "$state" in active) return 0 ;; inactive|failed) timeout -k 1 5 systemctl start "$service_name" 2>/dev/null ;; *) log error "Pulse service state is not settled ($service_name); refusing start"; return 2 ;; esac } stop_update_service_for_rollback() { local service_name=$1 state="" state=$(read_update_service_state "$service_name") || return 1 case "$state" in inactive) return 0 ;; active|failed) ;; *) log error "Pulse service state is not settled ($service_name); refusing rollback"; return 1 ;; esac if ! timeout -k 1 5 systemctl stop "$service_name" 2>/dev/null; then return 1 fi state=$(read_update_service_state "$service_name") || return 1 [[ "$state" == inactive ]] } wait_for_service_active() { local service_name=$1 local timeout_seconds="${2:-20}" local elapsed=0 state="" while (( elapsed < timeout_seconds )); do state=$(read_update_service_state "$service_name") || return 2 if [[ "$state" == active ]]; then return 0 fi sleep 1 ((elapsed += 1)) done return 1 } # Guarantee the Pulse service is left running after an update attempt if (and # only if) it was running beforehand. Invoked from perform_update's RETURN trap # after pre-install failures or a verified update/recovery (#1630: # the install-failed rollback branch restored the backup but never restarted # the service). This matters doubly because the generated pulse-update.service # uses ExecCondition=systemctl is-active pulse.service: once Pulse is down, # every subsequent timer run is skipped and the install stays down until # someone intervenes. Incomplete recovery deliberately suppresses this backstop: # activating a partial restore would be unsafe. Always returns 0 in the trap. ensure_service_restarted() { local service_name=$1 local service_was_active=$2 if [[ "$service_was_active" != "true" ]]; then return 0 fi if start_update_service_if_stopped "$service_name" && wait_for_service_active "$service_name" 20; then log info "Pulse service is running again" else log error "Pulse service restart not confirmed ($service_name); reconcile service state before manual recovery" fi return 0 } perform_update() { local new_version=$1 local service_name="" # A local declaration hides command-substitution failure. Refuse before # prior-active observation, backup, installer download or restart traps. service_name=$(detect_service_name) || return 1 # Never cross the stable channel even if the caller selected a preview. if is_prerelease_tag "$new_version"; then log error "Refusing to install prerelease version $new_version via unattended updater" return 1 fi local installer_tmp="" local signature_tmp="" local backup_dir="" local retain_backup="false" local restart_allowed="true" local -a file_paths=("$INSTALL_DIR/bin/pulse" "$INSTALL_DIR/pulse" "$INSTALL_DIR/VERSION") local -a backup_names=(pulse-bin pulse-legacy VERSION) local -a file_present=(false false false) local -a restore_files=("" "" "") local i # Capture whether Pulse was running before the update so we can guarantee it # comes back up afterwards (#1323: auto-update could leave it stopped on # unprivileged LXC where the installer's restart silently fails). local service_was_active="false" service_state="" service_result=0 service_state=$(read_update_service_state "$service_name") || return 1 case "$service_state" in active) service_was_active="true" ;; inactive|failed) ;; *) log error "Pulse service state is not settled ($service_name); refusing update"; return 1 ;; esac # Keep the prior-active restart backstop (#1630), except when incomplete # recovery requires manual intervention. The trap disarms itself on the # first RETURN: a RETURN # trap is not scoped to the function that set it, so leaving it installed # makes it re-run when any later function returns — with these locals gone # that aborted the updater under set -u after a successful update (#2128). trap 'trap - RETURN; rm -f -- "${installer_tmp:-}" "${signature_tmp:-}" "${restore_files[@]}"; if [[ -n "$backup_dir" && "$retain_backup" == "false" ]]; then rm -rf -- "$backup_dir"; fi; if [[ "$restart_allowed" == "true" ]]; then ensure_service_restarted "$service_name" "$service_was_active"; fi' RETURN log info "Starting update to $new_version" # main calls this function in an if condition, which disables errexit # throughout it. Check every prerequisite explicitly before the installer # can mutate anything. A private, unique directory also avoids collisions # with another attempt or a pre-created path in the shared /tmp directory. if [[ ! -f "${file_paths[0]}" && ! -f "${file_paths[1]}" ]]; then log error "No installed Pulse binary to back up; refusing update" return 1 fi if ! backup_dir=$(mktemp -d /tmp/pulse-backup.XXXXXX); then log error "Could not create rollback backup; refusing update" return 1 fi log info "Creating backup in $backup_dir" for i in "${!file_paths[@]}"; do if [[ -e "${file_paths[i]}" || -L "${file_paths[i]}" ]]; then if [[ ! -f "${file_paths[i]}" || -L "${file_paths[i]}" ]]; then log error "Unsupported rollback source ${file_paths[i]}; refusing update" return 1 fi if ! cp -a -- "${file_paths[i]}" "$backup_dir/${backup_names[i]}" || ! cmp -s -- "${file_paths[i]}" "$backup_dir/${backup_names[i]}"; then log error "Could not verify rollback backup for ${file_paths[i]}; refusing update" return 1 fi file_present[i]=true fi done # Download update using install script (safest method) log info "Downloading and installing update" local install_script_url install_script_url=$(resolve_install_script_url "$new_version") local install_signature_url="${install_script_url}.sshsig" # Run install script with specific version local marker_file="$INSTALL_DIR/BUILD_FROM_SOURCE" local -a installer_args=(--version "$new_version") if [[ -f "$marker_file" ]]; then local branch branch=$(tr -d '\r\n' <"$marker_file" 2>/dev/null || true) if [[ -n "$branch" ]]; then installer_args=(--source "$branch" "${installer_args[@]}") fi fi if ! installer_tmp=$(mktemp /tmp/pulse-update-installer.XXXXXX) || ! signature_tmp=$(mktemp /tmp/pulse-update-installer.sig.XXXXXX); then log error "Could not create installer verification files; refusing update" return 1 fi if ! download_update_asset "$install_script_url" "$installer_tmp"; then log error "Failed to download installer from $install_script_url" return 1 fi if ! download_update_asset "$install_signature_url" "$signature_tmp"; then log error "Failed to download installer signature from $install_signature_url" return 1 fi if ! verify_release_signature "$installer_tmp" "$signature_tmp" "downloaded Pulse installer"; then return 1 fi log info "Installer signature verified" local update_accepted="false" local -a installer_pipeline_status=() if env \ "PULSE_SERVICE_NAME=$service_name" \ "PULSE_INSTALL_DIR=$INSTALL_DIR" \ "PULSE_CONFIG_DIR=$CONFIG_DIR" \ bash "$installer_tmp" "${installer_args[@]}" 2>&1 | ( collector_status=0 line="" # Drain even after a failed log write: closing this pipe early can # interrupt an installer that is still replacing files. Retain the # first failure, not just the last successful write, and include a # final line without a newline in the completion evidence. while IFS= read -r line || [[ -n "$line" ]]; do if log info "installer: $line"; then : else log_status=$? if [[ "$collector_status" == 0 ]]; then collector_status=$log_status fi fi done exit "$collector_status" ); then # Capture both observed exits before any command overwrites PIPESTATUS. # Completion of this pipeline still requires binary/service verification. installer_pipeline_status=("${PIPESTATUS[@]}") log info "Installer completed; verifying update (installer exit: ${installer_pipeline_status[0]}; log collector exit: ${installer_pipeline_status[1]})" # Verify new version local installed_version installed_version=$(get_current_version) if [[ "$installed_version" != "unknown" && "${installed_version#v}" == "${new_version#v}" ]]; then log info "Version verified: $installed_version" # If Pulse was running before the update, make sure it is running # again. On unprivileged LXC the installer's restart can silently # fail, leaving Pulse stopped (#1323). if [[ "$service_was_active" == "true" ]]; then service_result=0 wait_for_service_active "$service_name" 20 || service_result=$? if [[ "$service_result" == 1 ]]; then log warn "Pulse service is not active after update, attempting one explicit start" start_update_service_if_stopped "$service_name" || service_result=$? fi if [[ "$service_result" != 2 ]]; then service_result=0 wait_for_service_active "$service_name" 20 || service_result=$? fi if [[ "$service_result" == 2 ]]; then retain_backup="true" restart_allowed="false" log error "Update service state is uncertain; backup retained at $backup_dir; reconcile service state before recovery" return 1 elif [[ "$service_result" != 0 ]]; then log error "Pulse service did not come back up after update" else if ! verify_update_running_executable "$service_name"; then retain_backup="true" restart_allowed="false" log error "Running Pulse executable is unverified; backup retained at $backup_dir; reconcile service state before recovery" return 1 fi update_accepted="true" fi else # Inactive/failed prior state removes the restart obligation, # not the obligation to observe the installed unit safely. service_state=$(read_update_service_state "$service_name") || service_state="unknown" case "$service_state" in active) if ! verify_update_running_executable "$service_name"; then retain_backup="true" restart_allowed="false" log error "Running Pulse executable is unverified; backup retained at $backup_dir; reconcile service state before recovery" return 1 fi update_accepted="true" ;; inactive|failed) update_accepted="true" ;; *) retain_backup="true" restart_allowed="false" log error "Update service state is uncertain; backup retained at $backup_dir; reconcile service state before recovery" return 1 ;; esac fi else log error "Version mismatch after update. Expected: $new_version, Got: $installed_version" fi else installer_pipeline_status=("${PIPESTATUS[@]}") # A collector failure is not evidence that the installer itself failed. # These are exit observations, not a diagnosis of a host interruption. log error "Installer pipeline failed (installer exit: ${installer_pipeline_status[0]}; log collector exit: ${installer_pipeline_status[1]})" fi if [[ "$update_accepted" == "true" ]]; then log info "Update successfully installed and verified" return 0 fi # One rollback path for installer, version and liveness failures. Keep the # backup and suppress the RETURN-trap restart until every file is restored; # starting a partially restored executable would hide the recovery failure. retain_backup="true" restart_allowed="false" log info "Restoring from backup" if ! stop_update_service_for_rollback "$service_name"; then log error "Could not confirm Pulse stopped for rollback; backup retained at $backup_dir; manual recovery required" return 1 fi # Stage and compare all saved files before replacing any destination. Each # replacement is a same-directory rename, not a truncating copy into a # running executable or a symlink created by the failed installer. for i in "${!file_paths[@]}"; do if [[ "${file_present[i]}" == "true" ]]; then if ! restore_files[i]=$(mktemp "${file_paths[i]}.rollback.XXXXXX") || ! cp -a -- "$backup_dir/${backup_names[i]}" "${restore_files[i]}" || ! cmp -s -- "$backup_dir/${backup_names[i]}" "${restore_files[i]}"; then log error "Could not stage rollback for ${file_paths[i]}; backup retained at $backup_dir; manual recovery required" return 1 fi fi done for i in "${!file_paths[@]}"; do if [[ "${file_present[i]}" == "true" ]]; then if [[ -d "${file_paths[i]}" ]] || ! mv -f -- "${restore_files[i]}" "${file_paths[i]}" || [[ ! -f "${file_paths[i]}" || -L "${file_paths[i]}" ]] || ! cmp -s -- "$backup_dir/${backup_names[i]}" "${file_paths[i]}"; then log error "Could not restore ${file_paths[i]}; backup retained at $backup_dir; manual recovery required" return 1 fi elif ! rm -f -- "${file_paths[i]}"; then log error "Could not restore absence of ${file_paths[i]}; backup retained at $backup_dir; manual recovery required" return 1 fi done if [[ "$service_was_active" == "true" ]]; then if ! start_update_service_if_stopped "$service_name" || ! wait_for_service_active "$service_name" 20; then log error "Restored Pulse could not be started; backup retained at $backup_dir; manual recovery required" return 1 fi if ! verify_update_running_executable "$service_name"; then log error "Restored running Pulse executable is unverified; backup retained at $backup_dir; manual recovery required" return 1 fi fi retain_backup="false" log info "Previous installation restored" return 1 } # Main update check main() { log info "Starting Pulse auto-update check" # Check if auto-updates are enabled check_auto_updates_enabled # Check if we're in Docker (updates not supported) if [[ -f /.dockerenv ]] || grep -q docker /proc/1/cgroup 2>/dev/null; then log info "Docker environment detected, skipping auto-update" exit 0 fi # Never replace a Pro or unidentified binary with a public community build. if installed_binary_is_pulse_pro; then log info "Pulse Pro binary detected; unattended community updates are disabled. Pro installs update in-app (license server download broker) or via https://pulserelay.pro/download.html" exit 0 else local edition_status=$? if [[ "$edition_status" != 1 ]]; then log error "Cannot verify installed Pulse edition; no unattended update attempted. Use the matching signed archive for manual recovery." exit 1 fi fi # Get current version local current_version=$(get_current_version) log info "Current version: $current_version" if [[ "$current_version" == "unknown" ]]; then log error "Could not determine current version, skipping update" exit 1 fi # Get latest stable version local latest_version=$(get_latest_stable_version) if [[ -z "$latest_version" ]]; then log error "Could not determine latest version from GitHub" exit 1 fi log info "Latest stable version: $latest_version" # Compare versions if version_greater_than "$latest_version" "$current_version"; then log info "New version available: $latest_version (current: $current_version)" # Perform update if perform_update "$latest_version"; then log info "Update completed successfully to $latest_version" # Send notification if webhooks are configured if [[ -f "$CONFIG_DIR/webhooks.enc" ]] || [[ -f "$CONFIG_DIR/webhooks.json" ]]; then # Create a simple notification via the Pulse API curl -s -X POST "http://localhost:7655/api/internal/notification" \ -H "Content-Type: application/json" \ -d "{\"type\":\"update\",\"message\":\"Pulse automatically updated from $current_version to $latest_version\"}" \ 2>/dev/null || true fi else log error "Update failed" exit 1 fi else log info "Already running latest version" fi log info "Auto-update check completed" } # Run main function (skipped when the script is sourced, e.g. by tests). if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@" fi