Files
pulse/scripts/pulse-auto-update.sh
T
rcourtman d8806e3c20 Treat no matching unit files as an empty service inventory
The 2026-10-10 server service discovery change lists the two historical
unit names with one pattern read and refuses to continue when that read
exits non-zero. systemctl list-unit-files exits 1, printing nothing on
stdout or stderr, when none of the named unit files exist. That is every
fresh host, so install.sh refused with "Cannot identify the Pulse systemd
service: unit inventory failed" before it did any work. The first beta's
install.sh smoke test caught it on a clean Debian 12 container; no
release carries it, and v6.5.0 listed the whole inventory and grepped it.

The tests could not see it. Their systemctl double reports "no units" as
exit 0, and test_partial_failed_inventory_is_not_absence asserted that an
empty inventory with exit 1 must be refused, which is the defect itself.

Add one inventory read shared by the installer and the unattended
updater, and use it for the auto-update mask check, which had the same
hazard. It accepts exactly the silent exit 1 as an empty inventory, and
only after a second read confirms there is still no output and no
message. A timeout, a signal, any other status, or an exit 1 that carries
output or a message stays a failed inventory, so a real failure still
cannot pass as absence.

Make the doubles model systemd: a failure now prints its message, silent
exit 1 is absence, and a new test runs real systemctl --root over an
empty unit root for both scripts. Run against the unfixed scripts, the
four new fresh-host tests fail; the rest of the suite is unchanged.

The registered runtime proofs for install.sh and the unattended updater,
which drive this suite, now require the fresh-host controls to have run
and passed, so they cannot be skipped or deleted unnoticed.

Refs: #2785
2026-10-12 09:26:34 +01:00

890 lines
38 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"
}
# 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
if ! observation=$(LC_ALL=C timeout -k 1 5 systemctl show "$service_name" --no-pager --property=LoadState --property=ActiveState 2>/dev/null); 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
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=} ;;
*)
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 || ( "$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
if ! observation=$(LC_ALL=C timeout -k 1 5 systemctl show "$service_name" --no-pager --property=LoadState --property=ActiveState --property=MainPID 2>/dev/null); 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
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=} ;;
*) return 1 ;;
esac
done <<< "$observation"
if [[ "$seen_load" != true || "$seen_state" != true || "$seen_pid" != 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 ! curl -fsSL "$install_script_url" -o "$installer_tmp"; then
log error "Failed to download installer from $install_script_url"
return 1
fi
if ! curl -fsSL "$install_signature_url" -o "$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