#!/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 logger -t "$LOG_TAG" -p "user.$level" "$@" echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $@" } 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() { # Check system.json for autoUpdateEnabled flag (note: no 's' - matches Go struct) if [[ -f "$CONFIG_DIR/system.json" ]]; then local enabled=$(cat "$CONFIG_DIR/system.json" 2>/dev/null | grep -o '"autoUpdateEnabled"[[:space:]]*:[[:space:]]*true' || true) local channel=$(cat "$CONFIG_DIR/system.json" 2>/dev/null | grep -o '"updateChannel"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/' || true) if [[ -z "$enabled" ]]; then log info "Auto-updates disabled in configuration" exit 0 fi if [[ "$channel" == "rc" ]]; then log info "Prerelease 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 } # Get current version get_current_version() { local version="" # Try to get version from binary if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then version=$("$INSTALL_DIR/bin/pulse" --version 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.]+)?' | head -1 || true) elif [[ -f "$INSTALL_DIR/pulse" ]]; then version=$("$INSTALL_DIR/pulse" --version 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.]+)?' | head -1 || true) fi # Fallback to VERSION file if [[ -z "$version" ]] && [[ -f "$INSTALL_DIR/VERSION" ]]; then version=$(cat "$INSTALL_DIR/VERSION" 2>/dev/null | tr -d '\n' || true) fi echo "${version:-unknown}" } # 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="" if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then binary="$INSTALL_DIR/bin/pulse" elif [[ -f "$INSTALL_DIR/pulse" ]]; then binary="$INSTALL_DIR/pulse" else return 1 fi "$binary" --version 2>/dev/null | head -1 | grep -q '^Pulse Pro ' } # 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 get_latest_stable_version() { local latest_version="" local release_json="" local is_prerelease_flag="" # Primary: highest stable version across the release list. GitHub's # /releases/latest points at the most recently *created* stable release, # and this repo interleaves v5-line maintenance releases with v6 releases # (v5.1.36 shipped the day before v6.0.5) — so "latest" can be an older # version line, which would strand v6 installs until the next v6 release. # # Each tag is only a candidate when its own release object says # draft=false and prerelease=false (field order per object is # tag_name → draft → prerelease, with body last, so a pending tag is # confirmed or discarded before the next object's tag_name resets it). # pick_highest_stable_tag then re-applies the fail-closed shape check. local releases_json="" releases_json=$(curl -s "https://api.github.com/repos/$GITHUB_REPO/releases?per_page=30" || true) if [[ -n "$releases_json" ]] && [[ "$releases_json" != *"rate limit"* ]]; then local line="" pending_tag="" stable_tags="" while IFS= read -r line; do if [[ "$line" == *'"tag_name":'* ]]; then pending_tag=$(printf '%s' "$line" | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/') fi if [[ "$line" == *'"draft": true'* ]] || [[ "$line" == *'"draft":true'* ]]; then pending_tag="" fi if [[ "$line" == *'"prerelease":'* ]]; then if [[ -n "$pending_tag" ]] && { [[ "$line" == *'"prerelease": false'* ]] || [[ "$line" == *'"prerelease":false'* ]]; }; then stable_tags+="$pending_tag"$'\n' fi pending_tag="" fi done <<< "$releases_json" latest_version=$(printf '%s' "$stable_tags" | pick_highest_stable_tag) if [[ -n "$latest_version" ]]; then echo "$latest_version" return 0 fi fi # Fallback: latest stable release (not pre-releases). `/releases/latest` # already skips prereleases on GitHub's side, but we still parse and # enforce the `prerelease` flag ourselves as a second line of defense. release_json=$(curl -s "https://api.github.com/repos/$GITHUB_REPO/releases/latest" || true) if [[ -n "$release_json" ]] && [[ "$release_json" != *"rate limit"* ]]; then latest_version=$(echo "$release_json" | \ grep '"tag_name":' | \ head -1 | \ sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/' || true) is_prerelease_flag=$(echo "$release_json" | \ grep '"prerelease":' | \ head -1 | \ sed -E 's/.*"prerelease":[[:space:]]*(true|false).*/\1/' || true) # Refuse if the API explicitly flags this release as a prerelease. if [[ "$is_prerelease_flag" == "true" ]]; then log error "GitHub /releases/latest returned a prerelease ($latest_version); refusing on stable channel" echo "" return 0 fi fi # Check if we got rate limited or failed if [[ -z "$latest_version" ]] || [[ "$latest_version" == *"rate limit"* ]]; then # Try direct GitHub latest URL as fallback latest_version=$(curl -sI "https://github.com/$GITHUB_REPO/releases/latest" | \ grep -i '^location:' | \ sed -E 's|.*tag/([^[:space:]]+).*|\1|' | \ tr -d '\r' || true) fi # Final belt-and-braces: never hand back a prerelease-shaped tag even # if an upstream path told us it was stable. The channel-pinning policy # lives in the Go server (EffectiveAutoUpdateEnabled gates this timer on # stable only); refusing prerelease tag shapes here ensures the unattended # script cannot cross the major-version boundary on a corrupted API reply. if [[ -n "$latest_version" ]] && is_prerelease_tag "$latest_version"; then log error "GitHub returned prerelease-shaped tag ($latest_version) as latest; refusing on stable channel" echo "" return 0 fi echo "${latest_version:-}" } # 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 } # Detect service name (could be pulse or pulse-backend) detect_service_name() { if [[ -n "${PULSE_SERVICE_NAME:-}" ]]; then echo "$SERVICE_NAME" elif systemctl list-unit-files --no-legend | grep -q "^pulse-backend.service"; then echo "pulse-backend" elif systemctl list-unit-files --no-legend | grep -q "^pulse.service"; then echo "pulse" else echo "pulse" # 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" } # Perform the update wait_for_service_active() { local service_name=$1 local timeout_seconds="${2:-20}" local elapsed=0 while (( elapsed < timeout_seconds )); do if systemctl is-active --quiet "$service_name" 2>/dev/null; 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 # so that no exit path — present or future — can leave Pulse stopped (#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. Always returns 0 (it runs under set -e in a trap). ensure_service_restarted() { local service_name=$1 local service_was_active=$2 if [[ "$service_was_active" != "true" ]]; then return 0 fi if systemctl is-active --quiet "$service_name" 2>/dev/null; then return 0 fi log warn "Pulse service is not active after update attempt; starting it" systemctl start "$service_name" 2>/dev/null || true if wait_for_service_active "$service_name" 20; then log info "Pulse service is running again" else log error "Pulse service could not be started after update attempt; manual intervention required (systemctl start $service_name)" fi return 0 } perform_update() { local new_version=$1 local service_name=$(detect_service_name) local installer_tmp="" local signature_tmp="" # 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" if systemctl is-active --quiet "$service_name" 2>/dev/null; then service_was_active="true" fi # Whatever way this function exits, never leave Pulse stopped when it was # running before the update (#1630). Extended below once the installer # tempfiles exist. trap 'ensure_service_restarted "$service_name" "$service_was_active"' RETURN # Refuse to install a prerelease via the unattended updater. The stable # channel must never cross onto a tag like v6.0.0-rc.2, even if every # caller above this point thought it was safe. if is_prerelease_tag "$new_version"; then log error "Refusing to install prerelease version $new_version via unattended updater" return 1 fi log info "Starting update to $new_version" # Create backup of current installation local backup_dir="/tmp/pulse-backup-$(date +%Y%m%d-%H%M%S)" log info "Creating backup in $backup_dir" mkdir -p "$backup_dir" # Backup binary if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then cp -a "$INSTALL_DIR/bin/pulse" "$backup_dir/" || true elif [[ -f "$INSTALL_DIR/pulse" ]]; then cp -a "$INSTALL_DIR/pulse" "$backup_dir/" || true fi # Backup VERSION file if [[ -f "$INSTALL_DIR/VERSION" ]]; then cp -a "$INSTALL_DIR/VERSION" "$backup_dir/" || true fi # 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 installer_tmp=$(mktemp /tmp/pulse-update-installer.XXXXXX) signature_tmp=$(mktemp /tmp/pulse-update-installer.sig.XXXXXX) trap 'rm -f "$installer_tmp" "$signature_tmp"; ensure_service_restarted "$service_name" "$service_was_active"' RETURN 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" if env \ "PULSE_SERVICE_NAME=$service_name" \ "PULSE_INSTALL_DIR=$INSTALL_DIR" \ "PULSE_CONFIG_DIR=$CONFIG_DIR" \ bash "$installer_tmp" "${installer_args[@]}" 2>&1 | \ while IFS= read -r line; do log info "installer: $line" done; then log info "Update successfully installed" # Verify new version local installed_version=$(get_current_version) if [[ "$installed_version" == "$new_version" ]]; 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 if ! wait_for_service_active "$service_name" 20; then log warn "Pulse service is not active after update, attempting one explicit start" systemctl start "$service_name" || true fi if ! wait_for_service_active "$service_name" 20; then log error "Pulse service did not come back up after update" log info "Restoring from backup" if [[ -f "$backup_dir/pulse" ]]; then if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then cp -f "$backup_dir/pulse" "$INSTALL_DIR/bin/pulse" else cp -f "$backup_dir/pulse" "$INSTALL_DIR/pulse" fi fi if [[ -f "$backup_dir/VERSION" ]]; then cp -f "$backup_dir/VERSION" "$INSTALL_DIR/VERSION" fi systemctl restart "$service_name" || true rm -rf "$backup_dir" return 1 fi fi # Clean up backup rm -rf "$backup_dir" return 0 else log error "Version mismatch after update. Expected: $new_version, Got: $installed_version" # Restore from backup log info "Restoring from backup" if [[ -f "$backup_dir/pulse" ]]; then if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then cp -f "$backup_dir/pulse" "$INSTALL_DIR/bin/pulse" else cp -f "$backup_dir/pulse" "$INSTALL_DIR/pulse" fi fi if [[ -f "$backup_dir/VERSION" ]]; then cp -f "$backup_dir/VERSION" "$INSTALL_DIR/VERSION" fi # Restart service with old version systemctl restart "$service_name" || true # Clean up backup rm -rf "$backup_dir" return 1 fi else log error "Update installation failed" # Restore from backup log info "Restoring from backup" if [[ -f "$backup_dir/pulse" ]]; then if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then cp -f "$backup_dir/pulse" "$INSTALL_DIR/bin/pulse" else cp -f "$backup_dir/pulse" "$INSTALL_DIR/pulse" fi fi if [[ -f "$backup_dir/VERSION" ]]; then cp -f "$backup_dir/VERSION" "$INSTALL_DIR/VERSION" fi # Restart the restored binary if Pulse was running before the update. # The installer stops the service before it can fail, so skipping this # left Pulse down indefinitely (#1630); the RETURN trap above is the # backstop if this path ever changes. if [[ "$service_was_active" == "true" ]]; then systemctl restart "$service_name" || true fi # Clean up backup rm -rf "$backup_dir" return 1 fi } # 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 the Pulse Pro 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 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