mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
fix(security): verify SSH hosts during proxy cleanup
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"], [])
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user