Pulse/scripts/uninstall-sensor-proxy.sh
pulse-triage[bot] 99d7b5e671 Stop legacy sensor cleanup on unconfirmed service or container state
Require stopped-state readback before each LXC mutation, reject failed pct set fallback, preserve snapshot entries and only-prior-active restoration, and return incomplete cleanup without removing recovery files. Add isolated adverse lifecycle and completion controls.

Change-source: pulse-maintainer
2026-10-07 22:07:07 +01:00

602 lines
20 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# Removes the legacy pulse-sensor-proxy footprint from a Proxmox host.
# Intended for users upgrading from older Pulse releases to the unified agent.
set -euo pipefail
BINARY_PATH="${PULSE_SENSOR_PROXY_BINARY_PATH:-/usr/local/bin/pulse-sensor-proxy}"
INSTALL_ROOT="${PULSE_SENSOR_PROXY_INSTALL_ROOT:-/opt/pulse/sensor-proxy}"
SERVICE_PATH="${PULSE_SENSOR_PROXY_SERVICE_PATH:-/etc/systemd/system/pulse-sensor-proxy.service}"
RUNTIME_DIR="${PULSE_SENSOR_PROXY_RUNTIME_DIR:-/run/pulse-sensor-proxy}"
SOCKET_PATH="${PULSE_SENSOR_PROXY_SOCKET_PATH:-${RUNTIME_DIR}/pulse-sensor-proxy.sock}"
WORK_DIR="${PULSE_SENSOR_PROXY_WORK_DIR:-/var/lib/pulse-sensor-proxy}"
CONFIG_DIR="${PULSE_SENSOR_PROXY_CONFIG_DIR:-/etc/pulse-sensor-proxy}"
LOG_DIR="${PULSE_SENSOR_PROXY_LOG_DIR:-/var/log/pulse/sensor-proxy}"
SERVICE_USER="${PULSE_SENSOR_PROXY_SERVICE_USER:-pulse-sensor-proxy}"
AUTHORIZED_KEYS_PATH="${PULSE_SENSOR_PROXY_AUTHORIZED_KEYS_PATH:-/root/.ssh/authorized_keys}"
SSH_KNOWN_HOSTS_PATH="${PULSE_SENSOR_PROXY_SSH_KNOWN_HOSTS_PATH:-}"
QUIET=false
PURGE=false
REMOVE_PROXMOX_ACCESS=false
LOCAL_ONLY=false
print_info() {
if [[ "$QUIET" != "true" ]]; then
printf '[INFO] %s\n' "$1"
fi
}
print_warn() {
printf '[WARN] %s\n' "$1" >&2
}
print_success() {
if [[ "$QUIET" != "true" ]]; then
printf '[ OK ] %s\n' "$1"
fi
}
usage() {
cat <<'EOF'
Usage: uninstall-sensor-proxy.sh [options]
Removes the legacy pulse-sensor-proxy footprint from a Proxmox host.
Options:
--uninstall Accepted for compatibility with old instructions.
--purge Remove persisted proxy state, config, logs, and service user/group.
--remove-proxmox-access Remove pulse-monitor@pam API tokens/user after cleanup.
--local-only Clean only this host; run once on every cluster node.
--ssh-known-hosts PATH Use only this provisioned known_hosts file for remote cluster cleanup.
--quiet Reduce informational output.
--help Show this help text.
Remote cluster cleanup never enrolls unknown SSH host keys. Without
--ssh-known-hosts, OpenSSH's configured user and system known_hosts files are
used. Unknown or changed host keys fail the remote cleanup.
EOF
}
resolve_path() {
local path="$1"
local resolved=""
if command -v readlink >/dev/null 2>&1; then
resolved=$(readlink -f "$path" 2>/dev/null || true)
if [[ -n "$resolved" ]]; then
printf '%s\n' "$resolved"
return 0
fi
fi
if command -v python3 >/dev/null 2>&1; then
python3 - "$path" <<'PY'
import os
import sys
print(os.path.realpath(sys.argv[1]))
PY
return 0
fi
printf '%s\n' "$path"
}
systemctl_if_available() {
if ! command -v systemctl >/dev/null 2>&1; then
print_warn "systemctl is unavailable; legacy service cleanup is unconfirmed"
return 1
fi
systemctl "$@" >/dev/null 2>&1
}
disable_legacy_units() {
local unit=""
local load_state=""
local active_state=""
local result=0
local units=(
pulse-sensor-proxy.service
pulse-sensor-proxy-selfheal.timer
pulse-sensor-proxy-selfheal.service
pulse-sensor-cleanup.path
pulse-sensor-cleanup.service
)
if ! command -v systemctl >/dev/null 2>&1; then
print_warn "systemctl is unavailable; legacy service cleanup is unconfirmed"
return 1
fi
for unit in "${units[@]}"; do
if ! load_state=$(systemctl show "$unit" --property=LoadState --value 2>/dev/null); then
print_warn "Cannot inspect legacy unit ${unit}; cleanup is incomplete"
result=1
continue
fi
[[ "$load_state" == "not-found" ]] && continue
if [[ -z "$load_state" ]] || ! systemctl_if_available stop "$unit"; then
print_warn "Cannot stop legacy unit ${unit}; cleanup is incomplete"
result=1
continue
fi
if ! active_state=$(systemctl show "$unit" --property=ActiveState --value 2>/dev/null) ||
[[ "$active_state" != "inactive" && "$active_state" != "failed" ]]; then
print_warn "Legacy unit ${unit} is not confirmed stopped; cleanup is incomplete"
result=1
continue
fi
if ! systemctl_if_available disable "$unit"; then
print_warn "Cannot disable legacy unit ${unit}; cleanup is incomplete"
result=1
fi
done
systemctl_if_available daemon-reload || result=1
return "$result"
}
remove_managed_keys_from_authorized_keys_file() {
local file="$1"
local tmp_file=""
if [[ ! -f "$file" ]]; then
return 0
fi
tmp_file=$(mktemp)
grep -v -E '# pulse-(managed|proxy)-key$' "$file" >"$tmp_file" 2>/dev/null || true
if cmp -s "$tmp_file" "$file"; then
rm -f "$tmp_file"
return 0
fi
chmod --reference="$file" "$tmp_file" 2>/dev/null || chmod 600 "$tmp_file" 2>/dev/null || true
chown --reference="$file" "$tmp_file" 2>/dev/null || true
mv "$tmp_file" "$file"
}
cleanup_local_authorized_keys() {
local auth_file=""
auth_file=$(resolve_path "$AUTHORIZED_KEYS_PATH")
remove_managed_keys_from_authorized_keys_file "$auth_file"
print_success "Removed legacy Pulse SSH key entries from ${auth_file}"
}
is_local_host() {
local target="$1"
local local_ip=""
local local_ips=""
local local_hostnames=""
local_ips="$(hostname -I 2>/dev/null || true)"
for local_ip in $local_ips; do
if [[ "$target" == "$local_ip" ]]; then
return 0
fi
done
local_hostnames="$(hostname 2>/dev/null || true) $(hostname -f 2>/dev/null || true)"
case " ${local_hostnames} " in
*" ${target} "*) return 0 ;;
esac
[[ "$target" == "127.0.0.1" || "$target" == "localhost" ]]
}
discover_cluster_node_addresses() {
local node=""
local node_ip=""
if command -v pvesh >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then
while IFS= read -r node; do
[[ -z "$node" ]] && continue
node_ip=$(getent ahostsv4 "$node" 2>/dev/null | awk 'NR==1 {print $1}') || true
if [[ -z "$node_ip" ]]; then
node_ip=$(getent hosts "$node" 2>/dev/null | awk 'NR==1 {print $1}') || true
fi
[[ -n "$node_ip" ]] && printf '%s\n' "$node_ip"
done < <(
pvesh get /nodes --output-format json 2>/dev/null | python3 - <<'PY'
import json
import sys
try:
data = json.load(sys.stdin)
except Exception:
sys.exit(0)
if isinstance(data, list):
for item in data:
node = item.get("node")
if node:
print(node)
PY
)
return 0
fi
if command -v pvecm >/dev/null 2>&1; then
pvecm status 2>/dev/null | awk '
/0x[0-9a-f]+/ {
for (i = 1; i <= NF; i++) {
if ($i ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) print $i
}
}
'
fi
}
cleanup_remote_authorized_keys() {
local host="$1"
local known_hosts_path=""
local ssh_options=(
-o StrictHostKeyChecking=yes
-o UpdateHostKeys=no
-o BatchMode=yes
-o ConnectTimeout=5
)
local remote_cmd='set -eu
auth="/root/.ssh/authorized_keys"
if [ -f "$auth" ]; then
sed -i -e "/# pulse-managed-key$/d" -e "/# pulse-proxy-key$/d" "$auth"
fi'
if [[ -n "$SSH_KNOWN_HOSTS_PATH" ]]; then
known_hosts_path=$(resolve_path "$SSH_KNOWN_HOSTS_PATH")
if [[ ! -f "$known_hosts_path" || ! -r "$known_hosts_path" || ! -s "$known_hosts_path" ]]; then
print_warn "Provisioned SSH known_hosts file is missing, unreadable, or empty: ${known_hosts_path}"
return 1
fi
ssh_options+=(
-o "UserKnownHostsFile=${known_hosts_path}"
-o GlobalKnownHostsFile=none
)
fi
if ssh "${ssh_options[@]}" root@"$host" "$remote_cmd" >/dev/null 2>&1; then
print_success "Removed legacy Pulse SSH key entries from ${host}"
return 0
fi
print_warn "Unable to verify ${host}'s SSH host key or remove its legacy Pulse SSH key entries; provision trusted known_hosts data or clean up /root/.ssh/authorized_keys locally"
return 1
}
cleanup_cluster_authorized_keys() {
local host=""
local saw_nodes=false
local status=0
if [[ "$LOCAL_ONLY" == "true" ]]; then
cleanup_local_authorized_keys
return 0
fi
while IFS= read -r host; do
[[ -z "$host" ]] && continue
saw_nodes=true
if is_local_host "$host"; then
cleanup_local_authorized_keys
else
cleanup_remote_authorized_keys "$host" || status=1
fi
done < <(discover_cluster_node_addresses | sort -u)
if [[ "$saw_nodes" != "true" ]]; then
cleanup_local_authorized_keys
fi
return "$status"
}
# Only current (pre-snapshot) mount entries are eligible for cleanup.
sensor_proxy_mount_keys() {
awk '
/^\[/ { exit }
/^mp[0-9]+:.*pulse-sensor-proxy/ { sub(/:.*/, ""); print }
/^lxc\.mount\.entry:.*pulse-sensor-proxy/ { print "lxc.mount.entry" }
' "$1"
}
cleanup_sensor_proxy_lines_in_conf() {
local conf="$1"
local tmp_file=""
tmp_file=$(mktemp) || return 1
if ! awk '
/^\[/ { snapshot = 1 }
!snapshot && ($0 ~ /^mp[0-9]+:.*pulse-sensor-proxy/ || $0 ~ /^lxc\.mount\.entry:.*pulse-sensor-proxy/) { next }
{ print }
' "$conf" >"$tmp_file" ||
! chmod --reference="$conf" "$tmp_file" ||
! chown --reference="$conf" "$tmp_file" ||
! mv "$tmp_file" "$conf"; then
rm -f "$tmp_file"
return 1
fi
}
sensor_proxy_container_status() {
local status=""
status=$(pct status "$1" 2>/dev/null) || return 1
case "$status" in
"status: running") printf 'running\n' ;;
"status: stopped") printf 'stopped\n' ;;
*) return 1 ;;
esac
}
cleanup_sensor_proxy_container() {
local ctid="$1"
local conf="$2"
local keys=""
local key=""
local status=""
local was_running=false
local result=0
keys=$(sensor_proxy_mount_keys "$conf") || return 1
[[ -z "$keys" ]] && return 0
print_info "Cleaning stale pulse-sensor-proxy mount entries from container ${ctid}"
if ! status=$(sensor_proxy_container_status "$ctid"); then
print_warn "Container ${ctid} status is unknown; no configuration edits or restart attempted"
return 1
fi
if [[ "$status" == "running" ]]; then
was_running=true
if ! timeout --kill-after=1s 30 pct stop "$ctid" >/dev/null 2>&1; then
print_warn "Container ${ctid} stop failed; no configuration edits or restart attempted; check its state locally"
return 1
fi
fi
# A successful stop command is not a stopped-state observation. Recheck
# before every mutation, including the direct legacy mount-line removal.
if ! status=$(sensor_proxy_container_status "$ctid") || [[ "$status" != "stopped" ]]; then
print_warn "Container ${ctid} is not confirmed stopped; no configuration edits or restart attempted"
return 1
fi
while IFS= read -r key; do
[[ "$key" == "lxc.mount.entry" ]] && continue
if ! status=$(sensor_proxy_container_status "$ctid") || [[ "$status" != "stopped" ]]; then
print_warn "Container ${ctid} stopped state was lost; no further edits or restart attempted"
return 1
fi
if ! timeout --kill-after=1s 15 pct set "$ctid" -delete "$key" >/dev/null 2>&1; then
print_warn "Container ${ctid} mount ${key} removal failed; no direct configuration fallback attempted"
result=1
break
fi
done <<<"$keys"
if (( result == 0 )); then
if ! status=$(sensor_proxy_container_status "$ctid") || [[ "$status" != "stopped" ]]; then
print_warn "Container ${ctid} stopped state was lost; no further edits or restart attempted"
return 1
fi
if ! cleanup_sensor_proxy_lines_in_conf "$conf"; then
print_warn "Container ${ctid} configuration cleanup failed"
result=1
elif ! keys=$(sensor_proxy_mount_keys "$conf") || [[ -n "$keys" ]]; then
print_warn "Container ${ctid} still has legacy mount entries; cleanup is incomplete"
result=1
fi
fi
if [[ "$was_running" == "true" ]]; then
if ! status=$(sensor_proxy_container_status "$ctid") || [[ "$status" != "stopped" ]]; then
print_warn "Container ${ctid} state is unconfirmed for restoration; no restart attempted"
return 1
fi
if ! timeout --kill-after=1s 30 pct start "$ctid" >/dev/null 2>&1 ||
! status=$(sensor_proxy_container_status "$ctid") || [[ "$status" != "running" ]]; then
print_warn "Container ${ctid} was running before cleanup but its running state was not restored; check it locally"
result=1
fi
fi
return "$result"
}
cleanup_stale_sensor_proxy_mounts() {
local ctid=""
local conf=""
local containers=""
local result=0
if ! command -v pct >/dev/null 2>&1; then
print_warn "pct is unavailable; LXC mount cleanup is unconfirmed"
return 1
fi
if ! containers=$(pct list 2>/dev/null) || [[ "$containers" != VMID* ]]; then
print_warn "Cannot list local containers; LXC mount cleanup is incomplete"
return 1
fi
while IFS= read -r ctid; do
[[ -z "$ctid" ]] && continue
if [[ ! "$ctid" =~ ^[0-9]+$ ]]; then
print_warn "Invalid container identity; LXC mount cleanup is incomplete"
result=1
continue
fi
conf="/etc/pve/lxc/${ctid}.conf"
[[ -f "$conf" ]] || continue
cleanup_sensor_proxy_container "$ctid" "$conf" || result=1
done < <(printf '%s\n' "$containers" | tail -n +2 | awk '{print $1}')
return "$result"
}
remove_path_if_present() {
local path="$1"
if [[ -e "$path" || -L "$path" ]]; then
rm -rf "$path"
print_success "Removed ${path}"
fi
}
remove_legacy_files() {
local path=""
local files=(
"$BINARY_PATH"
/usr/local/bin/pulse-sensor-cleanup.sh
"${INSTALL_ROOT}/bin/pulse-sensor-proxy"
"${INSTALL_ROOT}/bin/pulse-sensor-cleanup.sh"
"${INSTALL_ROOT}/bin/pulse-sensor-proxy-selfheal.sh"
"${INSTALL_ROOT}/bin/pulse-sensor-wrapper.sh"
"${INSTALL_ROOT}/install-sensor-proxy.sh"
"$SERVICE_PATH"
/etc/systemd/system/pulse-sensor-proxy-selfheal.service
/etc/systemd/system/pulse-sensor-proxy-selfheal.timer
/etc/systemd/system/pulse-sensor-cleanup.service
/etc/systemd/system/pulse-sensor-cleanup.path
"$SOCKET_PATH"
"$RUNTIME_DIR"
)
for path in "${files[@]}"; do
remove_path_if_present "$path"
done
if [[ "$PURGE" == "true" ]]; then
remove_path_if_present "$INSTALL_ROOT"
remove_path_if_present "$WORK_DIR"
remove_path_if_present "$CONFIG_DIR"
remove_path_if_present "$LOG_DIR"
if id -u "$SERVICE_USER" >/dev/null 2>&1; then
userdel --remove "$SERVICE_USER" >/dev/null 2>&1 || userdel "$SERVICE_USER" >/dev/null 2>&1 || print_warn "Failed to remove service user ${SERVICE_USER}"
fi
if getent group "$SERVICE_USER" >/dev/null 2>&1; then
groupdel "$SERVICE_USER" >/dev/null 2>&1 || print_warn "Failed to remove service group ${SERVICE_USER}"
fi
else
print_info "Preserving ${WORK_DIR}, ${CONFIG_DIR}, and ${LOG_DIR} (use --purge to remove them)"
fi
}
remove_proxmox_access() {
local token_ids=""
local token_id=""
if [[ "$REMOVE_PROXMOX_ACCESS" != "true" ]]; then
return 0
fi
if ! command -v pveum >/dev/null 2>&1; then
print_warn "pveum is not available; skipping pulse-monitor@pam cleanup"
return 0
fi
if command -v python3 >/dev/null 2>&1; then
token_ids=$(pveum user token list pulse-monitor@pam --output-format json 2>/dev/null | python3 - <<'PY'
import json
import sys
try:
data = json.load(sys.stdin)
except Exception:
sys.exit(0)
if isinstance(data, list):
for item in data:
token_id = item.get("tokenid")
if token_id:
print(token_id)
PY
) || true
fi
if [[ -z "$token_ids" ]]; then
token_ids=$(pveum user token list pulse-monitor@pam 2>/dev/null | awk '
{
line=$0
gsub(/\r/, "", line)
gsub(/│/, "|", line)
if (index(line, "|") == 0) next
n = split(line, parts, "|")
if (n < 3) next
name = parts[2]
gsub(/^[[:space:]]+|[[:space:]]+$/, "", name)
if (name != "" && name != "tokenid") print name
}') || true
fi
while IFS= read -r token_id; do
[[ -z "$token_id" ]] && continue
pveum user token remove pulse-monitor@pam "$token_id" >/dev/null 2>&1 || print_warn "Failed to remove pulse-monitor@pam token ${token_id}"
done <<<"$token_ids"
pveum user delete pulse-monitor@pam >/dev/null 2>&1 || print_warn "pulse-monitor@pam was not removed; it may already be absent or still in use"
}
main() {
local cluster_key_cleanup_status=0
local container_cleanup_status=0
while [[ $# -gt 0 ]]; do
case "$1" in
--uninstall)
shift
;;
--purge)
PURGE=true
shift
;;
--remove-proxmox-access)
REMOVE_PROXMOX_ACCESS=true
shift
;;
--local-only)
LOCAL_ONLY=true
shift
;;
--ssh-known-hosts)
if [[ $# -lt 2 || -z "$2" ]]; then
printf '%s\n' '--ssh-known-hosts requires a non-empty path' >&2
usage >&2
exit 1
fi
SSH_KNOWN_HOSTS_PATH="$2"
shift 2
;;
--ssh-known-hosts=*)
SSH_KNOWN_HOSTS_PATH="${1#*=}"
if [[ -z "$SSH_KNOWN_HOSTS_PATH" ]]; then
printf '%s\n' '--ssh-known-hosts requires a non-empty path' >&2
usage >&2
exit 1
fi
shift
;;
--quiet)
QUIET=true
shift
;;
--help|-h)
usage
exit 0
;;
*)
printf 'Unknown option: %s\n' "$1" >&2
usage >&2
exit 1
;;
esac
done
print_info "Starting legacy pulse-sensor-proxy cleanup"
if ! disable_legacy_units; then
print_warn "Legacy service cleanup is incomplete; leaving keys, containers, files and API access unchanged"
return 1
fi
cleanup_cluster_authorized_keys || cluster_key_cleanup_status=$?
cleanup_stale_sensor_proxy_mounts || container_cleanup_status=$?
if (( container_cleanup_status != 0 )); then
print_warn "LXC cleanup is incomplete; leaving legacy files and API access unchanged; check affected containers locally"
return "$container_cleanup_status"
fi
remove_legacy_files
remove_proxmox_access
if ! systemctl_if_available daemon-reload; then
print_warn "Cannot reload service configuration; cleanup is incomplete"
return 1
fi
if (( cluster_key_cleanup_status != 0 )); then
print_warn "Local cleanup completed, but one or more remote cluster nodes still require trusted, local cleanup"
return "$cluster_key_cleanup_status"
fi
print_success "Legacy pulse-sensor-proxy cleanup complete"
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi