diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index f8bf55a8d..8f232e4da 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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 diff --git a/install.sh b/install.sh index 9bbbfcf27..79403f5a2 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/scripts/installtests/root_install_sh_test.go b/scripts/installtests/root_install_sh_test.go index 2cf557a46..8e8da4b82 100644 --- a/scripts/installtests/root_install_sh_test.go +++ b/scripts/installtests/root_install_sh_test.go @@ -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()