mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 13:39:47 +00:00
8d8a1790ad
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
912 lines
39 KiB
Bash
Executable File
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
|