Wire legacy sensor-proxy cleanup into install.sh --uninstall (#34)

A Proxmox host upgraded from v5 may still carry the legacy pulse-sensor-proxy
footprint (binary, systemd units, runtime/state dirs, dedicated service user,
and managed SSH keys in root's authorized_keys). install.sh --uninstall removed
everything for the Pulse server itself but left that legacy footprint behind,
so a 'complete uninstall' was not complete -- most notably it left SSH key
entries in /root/.ssh/authorized_keys.

uninstall_pulse now calls cleanup_local_sensor_proxy, which removes the LOCAL
footprint only: stop/disable the units, remove the binary/units/runtime/state
dirs, strip the '# pulse-managed-key' / '# pulse-proxy-key' entries, and remove
the service user/group. It is presence-gated (silent no-op when no proxy was
installed). The aggressive cluster-wide authorized_keys removal and
pulse-monitor@pam API-user deletion stay behind the explicit standalone
scripts/uninstall-sensor-proxy.sh, which we print a pointer to.

Functional + contract tests in scripts/installtests/root_install_sh_test.go;
deployment-installability contract documents the new uninstall removal scope.
This commit is contained in:
rcourtman 2026-06-09 15:25:08 +01:00
parent 1a42011724
commit 2ff2b18c74
3 changed files with 254 additions and 1 deletions

View file

@ -1114,6 +1114,18 @@ installs too: the root `install.sh`, generated update helper, and
identity across install, update, reset, uninstall, and timer/service wiring so
stable and preview Pulse runtimes can coexist on one host without drifting back
onto the default `pulse.service` paths.
That same server-installer uninstall must also leave no legacy companion
footprint behind on the host: `install.sh --uninstall` removes the local
`pulse-sensor-proxy` artifacts a v5-era Proxmox host may still carry — the
binary, its systemd units, runtime/state directories, the dedicated service
user/group, and the managed `# pulse-managed-key` / `# pulse-proxy-key` entries
in root's `authorized_keys` — through one installer-owned
`cleanup_local_sensor_proxy` helper that is presence-gated (a silent no-op when
no proxy was installed). The aggressive cluster-wide authorized_keys removal and
`pulse-monitor@pam` API-user deletion stay behind the explicit standalone
`scripts/uninstall-sensor-proxy.sh`, which the installer only prints a pointer
to. `scripts/installtests/root_install_sh_test.go` is the owned proof surface
for that local sensor-proxy cleanup.
That same server-installer boundary also owns release trust fail-closed: the
root `install.sh`, its generated update helper, and
`scripts/pulse-auto-update.sh` must verify downloaded release tarballs and

View file

@ -4737,6 +4737,93 @@ main() {
fi
}
# Legacy pulse-sensor-proxy footprint (v4/v5). install.sh never installs the
# sensor-proxy in v6 — the unified agent replaced it — but a Proxmox host that
# was upgraded from v5 may still carry it. These paths mirror the LOCAL subset
# of scripts/uninstall-sensor-proxy.sh so a full `--uninstall` on such a host
# leaves nothing behind: binary, units, runtime/state, service user, and —
# security-relevant — the managed SSH keys in root's authorized_keys. The
# cluster-wide key removal and Proxmox API-user deletion stay behind the
# explicit standalone script (we print a pointer to it).
SENSOR_PROXY_BINARY_PATH="${PULSE_SENSOR_PROXY_BINARY_PATH:-/usr/local/bin/pulse-sensor-proxy}"
SENSOR_PROXY_INSTALL_ROOT="${PULSE_SENSOR_PROXY_INSTALL_ROOT:-/opt/pulse/sensor-proxy}"
SENSOR_PROXY_RUNTIME_DIR="${PULSE_SENSOR_PROXY_RUNTIME_DIR:-/run/pulse-sensor-proxy}"
SENSOR_PROXY_WORK_DIR="${PULSE_SENSOR_PROXY_WORK_DIR:-/var/lib/pulse-sensor-proxy}"
SENSOR_PROXY_CONFIG_DIR="${PULSE_SENSOR_PROXY_CONFIG_DIR:-/etc/pulse-sensor-proxy}"
SENSOR_PROXY_LOG_DIR="${PULSE_SENSOR_PROXY_LOG_DIR:-/var/log/pulse/sensor-proxy}"
SENSOR_PROXY_SERVICE_USER="${PULSE_SENSOR_PROXY_SERVICE_USER:-pulse-sensor-proxy}"
SENSOR_PROXY_AUTHORIZED_KEYS_PATH="${PULSE_SENSOR_PROXY_AUTHORIZED_KEYS_PATH:-/root/.ssh/authorized_keys}"
SENSOR_PROXY_SYSTEMD_DIR="${PULSE_SENSOR_PROXY_SYSTEMD_DIR:-/etc/systemd/system}"
local_sensor_proxy_present() {
[[ -e "$SENSOR_PROXY_BINARY_PATH" ]] && return 0
[[ -e "${SENSOR_PROXY_SYSTEMD_DIR}/pulse-sensor-proxy.service" ]] && return 0
[[ -d "$SENSOR_PROXY_INSTALL_ROOT" ]] && return 0
[[ -d "$SENSOR_PROXY_WORK_DIR" ]] && return 0
[[ -d "$SENSOR_PROXY_CONFIG_DIR" ]] && return 0
return 1
}
remove_local_sensor_proxy_managed_keys() {
local auth_file="$SENSOR_PROXY_AUTHORIZED_KEYS_PATH"
local tmp_file=""
[[ -f "$auth_file" ]] || return 0
tmp_file=$(mktemp) || return 0
grep -v -E '# pulse-(managed|proxy)-key$' "$auth_file" >"$tmp_file" 2>/dev/null || true
if cmp -s "$tmp_file" "$auth_file"; then
rm -f "$tmp_file"
return 0
fi
chmod --reference="$auth_file" "$tmp_file" 2>/dev/null || chmod 600 "$tmp_file" 2>/dev/null || true
chown --reference="$auth_file" "$tmp_file" 2>/dev/null || true
mv "$tmp_file" "$auth_file"
echo "Removed legacy pulse-sensor-proxy SSH key entries from $auth_file"
}
cleanup_local_sensor_proxy() {
local_sensor_proxy_present || return 0
echo "Removing legacy pulse-sensor-proxy footprint from this host..."
local unit=""
for unit in \
pulse-sensor-proxy.service \
pulse-sensor-proxy-selfheal.timer \
pulse-sensor-proxy-selfheal.service \
pulse-sensor-cleanup.path \
pulse-sensor-cleanup.service; do
systemctl stop "$unit" >/dev/null 2>&1 || true
systemctl disable "$unit" >/dev/null 2>&1 || true
done
rm -f "$SENSOR_PROXY_BINARY_PATH"
rm -f /usr/local/bin/pulse-sensor-cleanup.sh
rm -f "${SENSOR_PROXY_SYSTEMD_DIR}/pulse-sensor-proxy.service"
rm -f "${SENSOR_PROXY_SYSTEMD_DIR}/pulse-sensor-proxy-selfheal.service"
rm -f "${SENSOR_PROXY_SYSTEMD_DIR}/pulse-sensor-proxy-selfheal.timer"
rm -f "${SENSOR_PROXY_SYSTEMD_DIR}/pulse-sensor-cleanup.service"
rm -f "${SENSOR_PROXY_SYSTEMD_DIR}/pulse-sensor-cleanup.path"
rm -rf "$SENSOR_PROXY_RUNTIME_DIR"
rm -rf "$SENSOR_PROXY_INSTALL_ROOT"
rm -rf "$SENSOR_PROXY_WORK_DIR"
rm -rf "$SENSOR_PROXY_CONFIG_DIR"
rm -rf "$SENSOR_PROXY_LOG_DIR"
remove_local_sensor_proxy_managed_keys
if id -u "$SENSOR_PROXY_SERVICE_USER" >/dev/null 2>&1; then
userdel --remove "$SENSOR_PROXY_SERVICE_USER" >/dev/null 2>&1 || userdel "$SENSOR_PROXY_SERVICE_USER" >/dev/null 2>&1 || true
fi
if getent group "$SENSOR_PROXY_SERVICE_USER" >/dev/null 2>&1; then
groupdel "$SENSOR_PROXY_SERVICE_USER" >/dev/null 2>&1 || true
fi
echo "Removed legacy pulse-sensor-proxy footprint from this host."
echo "If this host belongs to a Proxmox cluster, run the full cleanup on the"
echo "other nodes (and to remove the pulse-monitor@pam API user):"
echo " curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | bash -s -- --purge --remove-proxmox-access"
}
# Uninstall function
uninstall_pulse() {
check_root
@ -4787,7 +4874,11 @@ uninstall_pulse() {
echo "Removing pulse user..."
userdel pulse 2>/dev/null || true
fi
# Remove any leftover legacy pulse-sensor-proxy footprint on this host so a
# full uninstall on a v5-upgraded Proxmox host leaves nothing behind.
cleanup_local_sensor_proxy
# Reload systemd
systemctl daemon-reload

View file

@ -573,6 +573,156 @@ func TestOperatorInstallDocsAvoidUnverifiedBootstrapAndFloatingImageTags(t *test
// proxies the top-level GitHub install.sh asset, which is the SERVER installer
// (not the agent installer) and rejects the wizard's --url/--token-file flags.
// The Docker image deploys these sidecars; deploy_agent_scripts must too.
// TestRootInstallUninstallCleansLegacySensorProxy guards #34: `install.sh
// --uninstall` on a Proxmox host that was upgraded from v5 must remove the
// leftover pulse-sensor-proxy footprint locally — binary, units, runtime/state,
// service user, and (security-relevant) the managed SSH keys in root's
// authorized_keys — so a "complete uninstall" leaves nothing behind. Cluster-
// wide key removal and Proxmox API-user deletion stay behind the explicit
// standalone scripts/uninstall-sensor-proxy.sh, which we only point users to.
func TestRootInstallUninstallCleansLegacySensorProxy(t *testing.T) {
tmp := t.TempDir()
binPath := filepath.Join(tmp, "bin", "pulse-sensor-proxy")
systemdDir := filepath.Join(tmp, "systemd")
unitPath := filepath.Join(systemdDir, "pulse-sensor-proxy.service")
authKeys := filepath.Join(tmp, "authorized_keys")
marker := filepath.Join(tmp, "calls.log")
for _, dir := range []string{filepath.Dir(binPath), systemdDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
if err := os.WriteFile(binPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write binary: %v", err)
}
if err := os.WriteFile(unitPath, []byte("[Unit]\n"), 0o644); err != nil {
t.Fatalf("write unit: %v", err)
}
authContent := "ssh-ed25519 AAAA keepme@admin\n" +
"ssh-ed25519 BBBB # pulse-managed-key\n" +
"ssh-ed25519 CCCC # pulse-proxy-key\n" +
"ssh-rsa DDDD keep-this-too\n"
if err := os.WriteFile(authKeys, []byte(authContent), 0o600); err != nil {
t.Fatalf("write authorized_keys: %v", err)
}
env := `
set -uo pipefail
SENSOR_PROXY_BINARY_PATH="` + binPath + `"
SENSOR_PROXY_SYSTEMD_DIR="` + systemdDir + `"
SENSOR_PROXY_INSTALL_ROOT="` + filepath.Join(tmp, "sensor-proxy") + `"
SENSOR_PROXY_RUNTIME_DIR="` + filepath.Join(tmp, "run") + `"
SENSOR_PROXY_WORK_DIR="` + filepath.Join(tmp, "work") + `"
SENSOR_PROXY_CONFIG_DIR="` + filepath.Join(tmp, "config") + `"
SENSOR_PROXY_LOG_DIR="` + filepath.Join(tmp, "log") + `"
SENSOR_PROXY_SERVICE_USER="pulse-sensor-proxy-test"
SENSOR_PROXY_AUTHORIZED_KEYS_PATH="` + authKeys + `"
systemctl() { return 0; }
userdel() { echo "userdel $*" >>"` + marker + `"; return 0; }
groupdel() { echo "groupdel $*" >>"` + marker + `"; return 0; }
id() { return 0; }
getent() { return 0; }
`
funcs := extractRootInstallShellFunction(t, "local_sensor_proxy_present") + "\n" +
extractRootInstallShellFunction(t, "remove_local_sensor_proxy_managed_keys") + "\n" +
extractRootInstallShellFunction(t, "cleanup_local_sensor_proxy")
out, err := exec.Command("bash", "-c", env+funcs+"\ncleanup_local_sensor_proxy\n").CombinedOutput()
if err != nil {
t.Fatalf("cleanup_local_sensor_proxy failed: %v\n%s", err, out)
}
if _, statErr := os.Stat(binPath); !os.IsNotExist(statErr) {
t.Fatalf("expected sensor-proxy binary removed, stat err = %v", statErr)
}
if _, statErr := os.Stat(unitPath); !os.IsNotExist(statErr) {
t.Fatalf("expected sensor-proxy unit removed, stat err = %v", statErr)
}
keysAfter, err := os.ReadFile(authKeys)
if err != nil {
t.Fatalf("read authorized_keys after cleanup: %v", err)
}
keysText := string(keysAfter)
if strings.Contains(keysText, "pulse-managed-key") || strings.Contains(keysText, "pulse-proxy-key") {
t.Fatalf("expected managed/proxy SSH keys stripped, got:\n%s", keysText)
}
for _, keep := range []string{"keepme@admin", "keep-this-too"} {
if !strings.Contains(keysText, keep) {
t.Fatalf("expected unrelated SSH key %q preserved, got:\n%s", keep, keysText)
}
}
markerBytes, err := os.ReadFile(marker)
if err != nil {
t.Fatalf("expected userdel/groupdel to run: %v", err)
}
if !strings.Contains(string(markerBytes), "userdel") {
t.Fatalf("expected service user removal, got marker:\n%s", markerBytes)
}
if !strings.Contains(string(out), "uninstall-sensor-proxy.sh") {
t.Fatalf("expected pointer to standalone cluster cleanup script, got:\n%s", out)
}
// Presence-gated: a host with no sensor-proxy footprint is a silent no-op.
empty := t.TempDir()
noopEnv := `
set -uo pipefail
SENSOR_PROXY_BINARY_PATH="` + filepath.Join(empty, "pulse-sensor-proxy") + `"
SENSOR_PROXY_SYSTEMD_DIR="` + empty + `"
SENSOR_PROXY_INSTALL_ROOT="` + filepath.Join(empty, "sensor-proxy") + `"
SENSOR_PROXY_RUNTIME_DIR="` + filepath.Join(empty, "run") + `"
SENSOR_PROXY_WORK_DIR="` + filepath.Join(empty, "work") + `"
SENSOR_PROXY_CONFIG_DIR="` + filepath.Join(empty, "config") + `"
SENSOR_PROXY_LOG_DIR="` + filepath.Join(empty, "log") + `"
SENSOR_PROXY_SERVICE_USER="pulse-sensor-proxy-test"
SENSOR_PROXY_AUTHORIZED_KEYS_PATH="` + filepath.Join(empty, "authorized_keys") + `"
systemctl() { return 0; }
userdel() { return 0; }
groupdel() { return 0; }
id() { return 0; }
getent() { return 0; }
`
noopOut, err := exec.Command("bash", "-c", noopEnv+funcs+"\ncleanup_local_sensor_proxy\n").CombinedOutput()
if err != nil {
t.Fatalf("cleanup_local_sensor_proxy no-op path failed: %v\n%s", err, noopOut)
}
if strings.TrimSpace(string(noopOut)) != "" {
t.Fatalf("expected silent no-op when no footprint present, got:\n%s", noopOut)
}
}
// TestRootInstallUninstallWiresSensorProxyCleanup pins that uninstall_pulse
// actually invokes the local sensor-proxy cleanup (the functional test above
// only exercises the helper in isolation).
func TestRootInstallUninstallWiresSensorProxyCleanup(t *testing.T) {
content, err := os.ReadFile(filepath.Join("..", "..", "install.sh"))
if err != nil {
t.Fatalf("read root install.sh: %v", err)
}
uninstall := extractRootInstallShellFunction(t, "uninstall_pulse")
if !strings.Contains(uninstall, "cleanup_local_sensor_proxy") {
t.Fatalf("uninstall_pulse does not invoke cleanup_local_sensor_proxy:\n%s", uninstall)
}
script := string(content)
for _, needle := range []string{
`cleanup_local_sensor_proxy() {`,
`local_sensor_proxy_present() {`,
`remove_local_sensor_proxy_managed_keys() {`,
`# pulse-(managed|proxy)-key$`,
} {
if !strings.Contains(script, needle) {
t.Fatalf("install.sh missing sensor-proxy cleanup contract: %s", needle)
}
}
}
func TestRootInstallDeployAgentScriptsDeploysSignatureSidecars(t *testing.T) {
extractDir := t.TempDir()
installDir := t.TempDir()