Files
pulse/scripts/pulse-auto-update.sh
T
pulse-triage[bot] 8d8a1790ad Refuse incomplete updater manager observations
Keep stderr and trailing blank records in service-state and running-PID admission. Incomplete reads must not authorise installation, restart, rollback or update success. Require production caller and real-process controls for both read boundaries; preserve existing recovery, trust and deadlines.

Change-source: pulse-maintainer
2026-10-14 11:58:09 +01:00

912 lines
39 KiB
Bash
Executable File

#!/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