mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
d8806e3c20
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
890 lines
38 KiB
Bash
Executable File
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
|