fix(security): verify SSH hosts during proxy cleanup

This commit is contained in:
rcourtman
2026-08-08 05:18:55 +01:00
parent 8e8edbf7de
commit e434466269
13 changed files with 418 additions and 13 deletions
+10 -2
View File
@@ -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.
+6 -1
View File
@@ -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.
@@ -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
@@ -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",
@@ -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.
+6 -1
View File
@@ -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.
+1 -1
View File
@@ -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
@@ -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()
@@ -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")
@@ -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"})
@@ -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()
@@ -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"], [])
+63 -3
View File
@@ -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"
}