diff --git a/docs/TEMPERATURE_MONITORING.md b/docs/TEMPERATURE_MONITORING.md index c7435ce4d..4f727e2e0 100644 --- a/docs/TEMPERATURE_MONITORING.md +++ b/docs/TEMPERATURE_MONITORING.md @@ -108,14 +108,22 @@ If you still have the old sensor proxy installed from prior releases, remove it ```bash curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \ - sudo bash -s -- --uninstall --purge + sudo bash -s -- --uninstall --purge --local-only ``` +`--local-only` avoids cluster SSH entirely; run the command once on every +Proxmox node that carried the proxy. For one cluster-wide pass instead, omit +`--local-only`. Remote nodes must already have trusted host keys in root's +normal OpenSSH user/system known_hosts files, or supply a separately +provisioned file with `--ssh-known-hosts /path/to/known_hosts`. The helper never +accepts or enrolls an unknown host key, and a missing or changed key makes the +remote portion fail after local cleanup completes. + If you also want to remove the old `pulse-monitor@pam` API user and tokens before re-adding the node, include `--remove-proxmox-access`: ```bash curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \ - sudo bash -s -- --uninstall --purge --remove-proxmox-access + sudo bash -s -- --uninstall --purge --remove-proxmox-access --local-only ``` Reinstalling or upgrading the Pulse container does **not** remove the sensor proxy from the host — they are separate installations. If you skip this cleanup, the selfheal timer will keep running and may generate recurring `TASK ERROR` entries in the Proxmox task log. diff --git a/docs/UPGRADE_v5.md b/docs/UPGRADE_v5.md index f133a3d8d..b3bc1a7f7 100644 --- a/docs/UPGRADE_v5.md +++ b/docs/UPGRADE_v5.md @@ -61,11 +61,16 @@ The `pulse-sensor-proxy` from v4 is no longer needed — temperature monitoring ```bash curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \ - sudo bash -s -- --uninstall --purge + sudo bash -s -- --uninstall --purge --local-only ``` If you deleted the old node from Pulse and want the cleanup to also remove the old `pulse-monitor@pam` API user and tokens before reinstalling, add `--remove-proxmox-access`. +Run the local-only command on every Proxmox node that carried the proxy. The +optional cluster-wide mode uses strict OpenSSH host-key verification and +requires already-provisioned user/system known_hosts trust (or an explicit +`--ssh-known-hosts /path/to/known_hosts` file); it never accepts unknown keys. + See the [Legacy Cleanup](TEMPERATURE_MONITORING.md#legacy-cleanup-if-upgrading) section in the temperature monitoring docs for the full cleanup details. Skipping this step will leave a selfheal timer running on the host that generates recurring `TASK ERROR` entries in the Proxmox task log. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index d7b11cef1..139819b7f 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -102,6 +102,7 @@ TLS floor in the dynamic config. 49. `install.sh` 50. `scripts/install.ps1` 51. `scripts/install.sh` +52. `scripts/uninstall-sensor-proxy.sh` 52. `scripts/install-mcp.sh` 53. `scripts/install-mcp.ps1` 55. `scripts/pulse-auto-update.sh` @@ -535,7 +536,7 @@ upgrade, update, release, or artifact-selection behavior. published, `docs/MSP.md` and its shipped frontend copy must fail closed and describe evaluation onboarding as request-assisted rather than directing operators to execute mutable source as root. -3. Add or change root server installer, shell installer, Docker bootstrap installer, Windows installer, container-agent installer, repo-root compose defaults, or auto-update script behavior through `install.sh`, `scripts/install.sh`, `scripts/install-docker.sh`, `scripts/install.ps1`, `scripts/install-container-agent.sh`, `docker-compose.yml`, and `scripts/pulse-auto-update.sh` +3. Add or change root server installer, shell installer, Docker bootstrap installer, Windows installer, container-agent installer, legacy sensor-proxy cleanup, repo-root compose defaults, or auto-update script behavior through `install.sh`, `scripts/install.sh`, `scripts/install-docker.sh`, `scripts/install.ps1`, `scripts/install-container-agent.sh`, `scripts/uninstall-sensor-proxy.sh`, `docker-compose.yml`, and `scripts/pulse-auto-update.sh` Canonical server deployment paths also stamp the privacy-bounded outbound telemetry deployment label without changing runtime behavior: the image defaults to `container_other`, repo-root Compose overrides it to @@ -1186,12 +1187,14 @@ upgrade, update, release, or artifact-selection behavior. 1. Leaving deployment bootstrap, installer, or update-runtime files unowned under broad monitoring or generic API ownership 2. Duplicating deployment-type update planning, installer release resolution, or updater handoff behavior outside the canonical update engine and installer scripts 3. Treating update transport as payload-only contract work when it also defines live deployment and upgrade behavior +4. Disabling SSH host-key verification, enrolling unknown keys, or hiding remote trust failures in unattended legacy cleanup ## Completion Obligations 1. Update this contract when canonical deployment or installer entry points move 2. Keep deployment runtime and shared API proof routing aligned in `registry.json` 3. Preserve explicit coverage for installer parity, update planning, and deployment bootstrap behavior when these surfaces change. Shell installer update recovery changes must keep `scripts/installtests/install_sh_test.go` covering both persisted `connection.env` recovery and legacy running-process/service recovery across Linux and FreeBSD/rc.d, including single-dash v5 agent flags, non-procfs process inspection, and the rule that upgraded service args use `--token-file` instead of raw `--token`. + Legacy sensor-proxy cleanup changes must keep `scripts/installtests/uninstall_sensor_proxy_test.go` covering strict host-key options, explicit known_hosts isolation, missing or mismatched trust failure, and the SSH-free local-only path, while `scripts/release_control/ssh_host_key_policy_test.py` continues to ban verification bypasses repo-wide. 4. Keep stable and prerelease packet lineage explicit when `docs/releases/` or `VERSION` changes: preserve already-shipped RC packets under dedicated historical filenames before reusing canonical stable names, keep @@ -2308,8 +2311,18 @@ in root's `authorized_keys` — through one installer-owned 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. +to. The standalone helper's documented run-on-each-node path uses +`--local-only` and never invokes SSH. Its optional cluster-wide path must use +`StrictHostKeyChecking=yes` with OpenSSH's already-provisioned trust sources or +an explicit non-empty `--ssh-known-hosts` file isolated from global trust; it +must set `UpdateHostKeys=no` rather than mutate trust during uninstall, never +enroll an unknown key, and missing, unreadable, empty, or mismatched +trust must make the remote portion fail after local cleanup completes. +`scripts/installtests/root_install_sh_test.go` is the owned proof surface for +the root installer's local sensor-proxy cleanup; +`scripts/installtests/uninstall_sensor_proxy_test.go` and +`scripts/release_control/ssh_host_key_policy_test.py` own the standalone +helper's trust and behavior proof. 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/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 17291ede7..bfbf1041f 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4209,6 +4209,7 @@ "scripts/trigger-release-dry-run.sh", "scripts/trigger-release.sh", "scripts/trigger-stable-patch.sh", + "scripts/uninstall-sensor-proxy.sh", "scripts/validate-release.sh", "tests/integration/playwright.config.ts", "tests/integration/QUICK_START.md", @@ -4458,6 +4459,20 @@ "tests/integration/tests/09-cloud-billing-lifecycle.spec.ts" ] }, + { + "id": "legacy-sensor-proxy-cleanup", + "label": "legacy sensor-proxy cleanup trust proof", + "match_prefixes": [], + "match_files": [ + "scripts/uninstall-sensor-proxy.sh" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "scripts/installtests/uninstall_sensor_proxy_test.go", + "scripts/release_control/ssh_host_key_policy_test.py" + ] + }, { "id": "deployment-script-runtime", "label": "deployment script runtime proof", diff --git a/frontend-modern/public/docs/TEMPERATURE_MONITORING.md b/frontend-modern/public/docs/TEMPERATURE_MONITORING.md index c7435ce4d..4f727e2e0 100644 --- a/frontend-modern/public/docs/TEMPERATURE_MONITORING.md +++ b/frontend-modern/public/docs/TEMPERATURE_MONITORING.md @@ -108,14 +108,22 @@ If you still have the old sensor proxy installed from prior releases, remove it ```bash curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \ - sudo bash -s -- --uninstall --purge + sudo bash -s -- --uninstall --purge --local-only ``` +`--local-only` avoids cluster SSH entirely; run the command once on every +Proxmox node that carried the proxy. For one cluster-wide pass instead, omit +`--local-only`. Remote nodes must already have trusted host keys in root's +normal OpenSSH user/system known_hosts files, or supply a separately +provisioned file with `--ssh-known-hosts /path/to/known_hosts`. The helper never +accepts or enrolls an unknown host key, and a missing or changed key makes the +remote portion fail after local cleanup completes. + If you also want to remove the old `pulse-monitor@pam` API user and tokens before re-adding the node, include `--remove-proxmox-access`: ```bash curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \ - sudo bash -s -- --uninstall --purge --remove-proxmox-access + sudo bash -s -- --uninstall --purge --remove-proxmox-access --local-only ``` Reinstalling or upgrading the Pulse container does **not** remove the sensor proxy from the host — they are separate installations. If you skip this cleanup, the selfheal timer will keep running and may generate recurring `TASK ERROR` entries in the Proxmox task log. diff --git a/frontend-modern/public/docs/UPGRADE_v5.md b/frontend-modern/public/docs/UPGRADE_v5.md index f133a3d8d..b3bc1a7f7 100644 --- a/frontend-modern/public/docs/UPGRADE_v5.md +++ b/frontend-modern/public/docs/UPGRADE_v5.md @@ -61,11 +61,16 @@ The `pulse-sensor-proxy` from v4 is no longer needed — temperature monitoring ```bash curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \ - sudo bash -s -- --uninstall --purge + sudo bash -s -- --uninstall --purge --local-only ``` If you deleted the old node from Pulse and want the cleanup to also remove the old `pulse-monitor@pam` API user and tokens before reinstalling, add `--remove-proxmox-access`. +Run the local-only command on every Proxmox node that carried the proxy. The +optional cluster-wide mode uses strict OpenSSH host-key verification and +requires already-provisioned user/system known_hosts trust (or an explicit +`--ssh-known-hosts /path/to/known_hosts` file); it never accepts unknown keys. + See the [Legacy Cleanup](TEMPERATURE_MONITORING.md#legacy-cleanup-if-upgrading) section in the temperature monitoring docs for the full cleanup details. Skipping this step will leave a selfheal timer running on the host that generates recurring `TASK ERROR` entries in the Proxmox task log. diff --git a/install.sh b/install.sh index 930374bc9..cef87d6f2 100755 --- a/install.sh +++ b/install.sh @@ -5380,7 +5380,7 @@ cleanup_local_sensor_proxy() { 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" + echo " curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | bash -s -- --purge --remove-proxmox-access --local-only" } # Uninstall function diff --git a/scripts/installtests/root_install_sh_test.go b/scripts/installtests/root_install_sh_test.go index 819afbbdd..8c6aec813 100644 --- a/scripts/installtests/root_install_sh_test.go +++ b/scripts/installtests/root_install_sh_test.go @@ -912,6 +912,9 @@ func TestRootInstallUninstallCleansLegacySensorProxy(t *testing.T) { if !strings.Contains(string(out), "uninstall-sensor-proxy.sh") { t.Fatalf("expected pointer to standalone cluster cleanup script, got:\n%s", out) } + if !strings.Contains(string(out), "--local-only") { + t.Fatalf("expected standalone cleanup pointer to avoid unprovisioned cluster SSH, got:\n%s", out) + } // Presence-gated: a host with no sensor-proxy footprint is a silent no-op. empty := t.TempDir() diff --git a/scripts/installtests/uninstall_sensor_proxy_test.go b/scripts/installtests/uninstall_sensor_proxy_test.go index 6a67274fa..0e42e2265 100644 --- a/scripts/installtests/uninstall_sensor_proxy_test.go +++ b/scripts/installtests/uninstall_sensor_proxy_test.go @@ -18,6 +18,11 @@ func TestUninstallSensorProxyScriptContract(t *testing.T) { script := string(content) required := []string{ `--remove-proxmox-access`, + `--local-only`, + `--ssh-known-hosts`, + `StrictHostKeyChecking=yes`, + `UpdateHostKeys=no`, + `GlobalKnownHostsFile=none`, `pulse-sensor-proxy-selfheal.timer`, `pulse-sensor-cleanup.path`, `remove_managed_keys_from_authorized_keys_file()`, @@ -36,6 +41,229 @@ func TestUninstallSensorProxyScriptContract(t *testing.T) { } } +func TestUninstallSensorProxyRemoteCleanupUsesProvisionedHostKeys(t *testing.T) { + tmpDir := t.TempDir() + binDir := filepath.Join(tmpDir, "bin") + argsLog := filepath.Join(tmpDir, "ssh-args") + knownHosts := filepath.Join(tmpDir, "known_hosts") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin dir: %v", err) + } + sshStub := `#!/bin/sh +printf '%s\n' "$@" >"$SSH_ARGS_LOG" +exit "${SSH_EXIT_STATUS:-0}" +` + if err := os.WriteFile(filepath.Join(binDir, "ssh"), []byte(sshStub), 0o755); err != nil { + t.Fatalf("write ssh stub: %v", err) + } + if err := os.WriteFile(knownHosts, []byte("node ssh-ed25519 AAAAtest\n"), 0o600); err != nil { + t.Fatalf("write known_hosts: %v", err) + } + resolvedKnownHosts, err := filepath.EvalSymlinks(knownHosts) + if err != nil { + t.Fatalf("resolve known_hosts: %v", err) + } + + command := `source "$1" +SSH_KNOWN_HOSTS_PATH="$2" +cleanup_remote_authorized_keys 192.0.2.10 +` + cmd := exec.Command("bash", "-c", command, "bash", repoFile("scripts", "uninstall-sensor-proxy.sh"), knownHosts) + cmd.Env = append(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "SSH_ARGS_LOG="+argsLog, + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("remote cleanup with provisioned trust failed: %v\n%s", err, out) + } + + args, err := os.ReadFile(argsLog) + if err != nil { + t.Fatalf("read ssh args: %v", err) + } + got := string(args) + for _, option := range []string{ + "StrictHostKeyChecking=yes", + "UpdateHostKeys=no", + "BatchMode=yes", + "ConnectTimeout=5", + "UserKnownHostsFile=" + resolvedKnownHosts, + "GlobalKnownHostsFile=none", + "root@192.0.2.10", + } { + if !strings.Contains(got, option) { + t.Fatalf("ssh invocation missing %q:\n%s", option, got) + } + } +} + +func TestUninstallSensorProxyRemoteCleanupUsesStrictConfiguredTrustByDefault(t *testing.T) { + tmpDir := t.TempDir() + binDir := filepath.Join(tmpDir, "bin") + argsLog := filepath.Join(tmpDir, "ssh-args") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin dir: %v", err) + } + sshStub := `#!/bin/sh +printf '%s\n' "$@" >"$SSH_ARGS_LOG" +exit 0 +` + if err := os.WriteFile(filepath.Join(binDir, "ssh"), []byte(sshStub), 0o755); err != nil { + t.Fatalf("write ssh stub: %v", err) + } + + command := `source "$1" +SSH_KNOWN_HOSTS_PATH="" +cleanup_remote_authorized_keys 192.0.2.20 +` + cmd := exec.Command("bash", "-c", command, "bash", repoFile("scripts", "uninstall-sensor-proxy.sh")) + cmd.Env = append(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "SSH_ARGS_LOG="+argsLog, + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("remote cleanup with configured OpenSSH trust failed: %v\n%s", err, out) + } + + args, err := os.ReadFile(argsLog) + if err != nil { + t.Fatalf("read ssh args: %v", err) + } + got := string(args) + if !strings.Contains(got, "StrictHostKeyChecking=yes") { + t.Fatalf("default ssh invocation did not require configured trust:\n%s", got) + } + if !strings.Contains(got, "UpdateHostKeys=no") { + t.Fatalf("default ssh invocation could mutate host trust:\n%s", got) + } + for _, isolatedOption := range []string{"UserKnownHostsFile=", "GlobalKnownHostsFile="} { + if strings.Contains(got, isolatedOption) { + t.Fatalf("default ssh invocation unexpectedly replaced configured OpenSSH trust with %q:\n%s", isolatedOption, got) + } + } +} + +func TestUninstallSensorProxyRemoteCleanupFailsWithoutProvisionedTrust(t *testing.T) { + tmpDir := t.TempDir() + binDir := filepath.Join(tmpDir, "bin") + argsLog := filepath.Join(tmpDir, "ssh-args") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin dir: %v", err) + } + sshStub := `#!/bin/sh +printf '%s\n' "$@" >"$SSH_ARGS_LOG" +exit 0 +` + if err := os.WriteFile(filepath.Join(binDir, "ssh"), []byte(sshStub), 0o755); err != nil { + t.Fatalf("write ssh stub: %v", err) + } + + missingKnownHosts := filepath.Join(tmpDir, "missing-known-hosts") + command := `source "$1" +SSH_KNOWN_HOSTS_PATH="$2" +cleanup_remote_authorized_keys 192.0.2.11 +` + cmd := exec.Command("bash", "-c", command, "bash", repoFile("scripts", "uninstall-sensor-proxy.sh"), missingKnownHosts) + cmd.Env = append(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "SSH_ARGS_LOG="+argsLog, + ) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("remote cleanup unexpectedly accepted missing trust material:\n%s", out) + } + if !strings.Contains(string(out), "known_hosts file is missing, unreadable, or empty") { + t.Fatalf("missing trust failure was not actionable:\n%s", out) + } + if _, statErr := os.Stat(argsLog); !os.IsNotExist(statErr) { + t.Fatalf("ssh ran before provisioned trust was validated, stat err=%v", statErr) + } +} + +func TestUninstallSensorProxyRemoteCleanupRejectsHostKeyMismatch(t *testing.T) { + tmpDir := t.TempDir() + binDir := filepath.Join(tmpDir, "bin") + knownHosts := filepath.Join(tmpDir, "known_hosts") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin dir: %v", err) + } + if err := os.WriteFile(filepath.Join(binDir, "ssh"), []byte("#!/bin/sh\nexit 255\n"), 0o755); err != nil { + t.Fatalf("write ssh stub: %v", err) + } + if err := os.WriteFile(knownHosts, []byte("node ssh-ed25519 AAAAold\n"), 0o600); err != nil { + t.Fatalf("write known_hosts: %v", err) + } + + command := `source "$1" +SSH_KNOWN_HOSTS_PATH="$2" +cleanup_remote_authorized_keys 192.0.2.12 +` + cmd := exec.Command("bash", "-c", command, "bash", repoFile("scripts", "uninstall-sensor-proxy.sh"), knownHosts) + cmd.Env = append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("remote cleanup unexpectedly ignored SSH host-key rejection:\n%s", out) + } + if !strings.Contains(string(out), "Unable to verify 192.0.2.12's SSH host key") { + t.Fatalf("host-key rejection was not actionable:\n%s", out) + } +} + +func TestUninstallSensorProxyLocalOnlyDoesNotUseSSH(t *testing.T) { + marker := filepath.Join(t.TempDir(), "cleanup-marker") + command := `source "$1" +LOCAL_ONLY=true +cleanup_local_authorized_keys() { printf 'local\n' >"$LOCAL_CLEANUP_MARKER"; } +cleanup_remote_authorized_keys() { return 99; } +cleanup_cluster_authorized_keys +` + cmd := exec.Command("bash", "-c", command, "bash", repoFile("scripts", "uninstall-sensor-proxy.sh")) + cmd.Env = append(os.Environ(), "LOCAL_CLEANUP_MARKER="+marker) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("local-only cleanup failed: %v\n%s", err, out) + } + content, err := os.ReadFile(marker) + if err != nil { + t.Fatalf("local-only cleanup did not run local key removal: %v", err) + } + if string(content) != "local\n" { + t.Fatalf("unexpected local-only marker: %q", content) + } +} + +func TestUninstallSensorProxyReportsRemoteTrustFailureAfterLocalCleanup(t *testing.T) { + marker := filepath.Join(t.TempDir(), "cleanup-marker") + command := `source "$1" +disable_legacy_units() { :; } +cleanup_cluster_authorized_keys() { return 23; } +cleanup_stale_sensor_proxy_mounts() { printf 'mounts\n' >>"$LOCAL_CLEANUP_MARKER"; } +remove_legacy_files() { printf 'files\n' >>"$LOCAL_CLEANUP_MARKER"; } +remove_proxmox_access() { printf 'access\n' >>"$LOCAL_CLEANUP_MARKER"; } +systemctl_if_available() { :; } +main --quiet +` + cmd := exec.Command("bash", "-c", command, "bash", repoFile("scripts", "uninstall-sensor-proxy.sh")) + cmd.Env = append(os.Environ(), "LOCAL_CLEANUP_MARKER="+marker) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("main unexpectedly hid remote trust failure:\n%s", out) + } + exitErr, ok := err.(*exec.ExitError) + if !ok || exitErr.ExitCode() != 23 { + t.Fatalf("main returned %v, want remote cleanup status 23:\n%s", err, out) + } + if !strings.Contains(string(out), "Local cleanup completed") { + t.Fatalf("remote trust failure did not explain local cleanup state:\n%s", out) + } + content, readErr := os.ReadFile(marker) + if readErr != nil { + t.Fatalf("read local cleanup marker: %v", readErr) + } + if string(content) != "mounts\nfiles\naccess\n" { + t.Fatalf("local cleanup did not complete before remote failure was returned: %q", content) + } +} + func TestUninstallSensorProxyScriptRemovesTempFootprintAndManagedKeys(t *testing.T) { tmpDir := t.TempDir() binDir := filepath.Join(tmpDir, "bin") diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 767d780dc..200714094 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -843,6 +843,32 @@ class CanonicalCompletionGuardTest(unittest.TestCase): ], ) + def test_sensor_proxy_uninstall_change_uses_trust_policy(self): + required = infer_impacted_subsystems(["scripts/uninstall-sensor-proxy.sh"]) + self.assertEqual(set(required), {"deployment-installability"}) + + installability = required["deployment-installability"] + self.assertEqual( + installability["touched_runtime_files"], + ["scripts/uninstall-sensor-proxy.sh"], + ) + self.assertEqual( + installability["verification_requirements"], + [ + { + "id": "legacy-sensor-proxy-cleanup", + "label": "legacy sensor-proxy cleanup trust proof", + "touched_runtime_files": ["scripts/uninstall-sensor-proxy.sh"], + "allow_same_subsystem_tests": False, + "test_prefixes": [], + "exact_files": [ + "scripts/installtests/uninstall_sensor_proxy_test.go", + "scripts/release_control/ssh_host_key_policy_test.py", + ], + } + ], + ) + def test_docker_entrypoint_change_uses_container_entrypoint_policy(self): required = infer_impacted_subsystems(["docker-entrypoint.sh"]) self.assertEqual(set(required), {"monitoring"}) diff --git a/scripts/release_control/ssh_host_key_policy_test.py b/scripts/release_control/ssh_host_key_policy_test.py index bac980fce..4e43c99be 100644 --- a/scripts/release_control/ssh_host_key_policy_test.py +++ b/scripts/release_control/ssh_host_key_policy_test.py @@ -60,6 +60,17 @@ class SSHHostKeyPolicyTest(unittest.TestCase): self.assertIn("UpdateHostKeys=yes", script) self.assertIn("UserKnownHostsFile=$SSH_KNOWN_HOSTS_FILE", script) + def test_sensor_proxy_uninstall_fails_closed_or_stays_local(self) -> None: + script = (REPO_ROOT / "scripts" / "uninstall-sensor-proxy.sh").read_text(encoding="utf-8") + + self.assertNotIn("StrictHostKeyChecking=no", script) + self.assertIn("StrictHostKeyChecking=yes", script) + self.assertIn("UpdateHostKeys=no", script) + self.assertIn('UserKnownHostsFile=${known_hosts_path}', script) + self.assertIn("GlobalKnownHostsFile=none", script) + self.assertIn("--ssh-known-hosts", script) + self.assertIn("--local-only", script) + if __name__ == "__main__": unittest.main() diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 87db454f5..8fd7874f3 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -3621,6 +3621,29 @@ class SubsystemLookupTest(unittest.TestCase): ], ) + def test_lookup_paths_assigns_sensor_proxy_uninstall_to_trust_proof(self) -> None: + result = lookup_paths(["scripts/uninstall-sensor-proxy.sh"]) + self.assertEqual(result["unowned_runtime_files"], []) + self.assertEqual( + {item["subsystem"] for item in result["impacted_subsystems"]}, + {"deployment-installability"}, + ) + file_entry = result["files"][0] + self.assertEqual(file_entry["classification"], "runtime") + match = file_entry["matches"][0] + self.assertEqual(match["lane_context"]["lane_id"], "L1") + self.assertEqual( + match["verification_requirement"]["id"], + "legacy-sensor-proxy-cleanup", + ) + self.assertEqual( + match["verification_requirement"]["exact_files"], + [ + "scripts/installtests/uninstall_sensor_proxy_test.go", + "scripts/release_control/ssh_host_key_policy_test.py", + ], + ) + def test_lookup_paths_assigns_control_plane_rollout_command_to_deployment_installability(self) -> None: result = lookup_paths(["cmd/pulse-control-plane/main.go"]) self.assertEqual(result["unowned_runtime_files"], []) diff --git a/scripts/uninstall-sensor-proxy.sh b/scripts/uninstall-sensor-proxy.sh index 17556daae..c28f7d4a8 100755 --- a/scripts/uninstall-sensor-proxy.sh +++ b/scripts/uninstall-sensor-proxy.sh @@ -15,10 +15,12 @@ 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 @@ -46,8 +48,14 @@ 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 } @@ -190,17 +198,36 @@ PY 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 ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=5 root@"$host" "$remote_cmd" >/dev/null 2>&1; then + 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 remove legacy Pulse SSH key entries from ${host}; clean up /root/.ssh/authorized_keys manually if needed" + 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 } @@ -209,6 +236,11 @@ cleanup_cluster_authorized_keys() { 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 @@ -410,6 +442,8 @@ PY } main() { + local cluster_key_cleanup_status=0 + while [[ $# -gt 0 ]]; do case "$1" in --uninstall) @@ -423,6 +457,28 @@ main() { 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 @@ -441,11 +497,15 @@ main() { print_info "Starting legacy pulse-sensor-proxy cleanup" disable_legacy_units - cleanup_cluster_authorized_keys || true + cleanup_cluster_authorized_keys || cluster_key_cleanup_status=$? cleanup_stale_sensor_proxy_mounts remove_legacy_files remove_proxmox_access systemctl_if_available daemon-reload + 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" }