mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
Refuse Unraid uninstall while the rc.local go shell is live (#3191)
Uninstall on Unraid refuses while a live go shell exists, because that
shell may still hold later boot lines in memory. Stopping the watchdog
would release it to run them. Commit 5dad231852 added that refusal, but
its check looked only for `/boot/config/go` in a process named like a
shell.
Unraid's `rc.local` never runs that path. From unraid/webgui
`etc/rc.d/rc.local` ("Invoke the 'go' script"):
```
fromdos <$CONFIG/$GO >/var/tmp/$GO
chmod +x /var/tmp/$GO
/var/tmp/$GO
```
`$GO` is `go`, or `go.safemode` in safe mode. Because the copy is
executed directly, the kernel names the process after the script. On
pulse-dev (Linux 6.8) a shebang script run that way shows `comm=go` and
`args=/bin/bash /var/tmp/go`, so the old check never matched. Hosts
installed before #3162 still have that shell blocked on the old
foreground `bash <wrapper>` line. On those hosts uninstall treated it as
unrelated, stopped the watchdog, and let go run the rest of its script.
## Change
- `read_nas_agent_removal_processes`: on Unraid, a process named `go` or
`go.safemode`, or a shell, that has `/boot/config/go`, `/var/tmp/go` or
a `.safemode` form as a whole argument is an unknown boot owner. This
replaces the old substring match, so near-miss paths
(`/var/tmp/gopher.sh`, `/boot/config/go~`) no longer block removal. A Go
toolchain process named `go` still reads as `other`.
- `agent_nas_teardown.py`:
`test_live_unraid_boot_shell_is_unknown_not_a_signal_target` now also
covers the rc.local spellings (`go /bin/bash /var/tmp/go`, `go.safemode
/bin/bash /var/tmp/go.safemode`). Every model `ps` read also includes a
Go toolchain process, so every passing Unraid control shows that process
does not block removal.
- `TestInstallSHNASRemovalTreatsLiveUnraidGoShellAsUnknownOwner`: a role
table for Unraid and QNAP run through the real function.
- agent-lifecycle and deployment-installability contracts: "Unraid/QNAP
removal" sections updated.
## Verification (pulse-dev)
- `TestInstallSHNASBootOwnersQuiesceBeforeRecoveryErasure`, the new role
test and `TestInstallSHUnraidGoEntry*` pass.
- With `origin/main`'s `install.sh` restored, the new role test and the
rc.local subcases of the live-shell control fail.
## Not covered
A go file without a `#!` line still escapes. Bash runs it in a forked
copy of `rc.local`, which keeps rc.local's name and arguments. Stock
Unraid go files start with `#!/bin/bash`. The contracts state this
limit.
This commit is contained in:
@@ -139,7 +139,17 @@ with independent absence observations. Editor sessions, backup wrappers and
|
||||
co-installed names are not targets. Relative/foreign same-name processes or
|
||||
unattributable QNAP deferred autorun or live Unraid go shells refuse instead
|
||||
of signalling another boot owner. A live go shell may already hold later boot
|
||||
commands in memory; editing its file cannot establish quiescence. Failed/diagnostic/duplicate/empty reads and ignored/refused signals
|
||||
commands in memory; editing its file cannot establish quiescence. Unraid's
|
||||
`rc.local` executes the `fromdos` copy `/var/tmp/go` (`/var/tmp/go.safemode`
|
||||
in safe mode), so for a go file with a `#!` line the kernel names that shell
|
||||
`go` or `go.safemode`, not `bash`. A process with either name, or a shell,
|
||||
that has `/boot/config/go`, `/var/tmp/go` or their `.safemode` forms as a
|
||||
whole argument is that owner. A `go` process naming none of them, such as a
|
||||
Go toolchain, is not. A go file without a `#!` line runs in a forked copy of
|
||||
`rc.local` and is not recognised. Hosts installed before the detached go
|
||||
entry keep such a shell blocked on the old foreground wrapper line.
|
||||
`TestInstallSHNASRemovalTreatsLiveUnraidGoShellAsUnknownOwner` pins those
|
||||
roles on Unraid and QNAP. Failed/diagnostic/duplicate/empty reads and ignored/refused signals
|
||||
retain identity, credentials, trust and recoverable files. QNAP mount and
|
||||
unmount each have checked bounded results, including cleanup after partial mount.
|
||||
|
||||
|
||||
@@ -82,7 +82,17 @@ with independent absence observations. Editor sessions, backup wrappers and
|
||||
co-installed names are not targets. Relative/foreign same-name processes or
|
||||
unattributable QNAP deferred autorun or live Unraid go shells refuse instead
|
||||
of signalling another boot owner. A live go shell may already hold later boot
|
||||
commands in memory; editing its file cannot establish quiescence. Failed/diagnostic/duplicate/empty reads and ignored/refused signals
|
||||
commands in memory; editing its file cannot establish quiescence. Unraid's
|
||||
`rc.local` executes the `fromdos` copy `/var/tmp/go` (`/var/tmp/go.safemode`
|
||||
in safe mode), so for a go file with a `#!` line the kernel names that shell
|
||||
`go` or `go.safemode`, not `bash`. A process with either name, or a shell,
|
||||
that has `/boot/config/go`, `/var/tmp/go` or their `.safemode` forms as a
|
||||
whole argument is that owner. A `go` process naming none of them, such as a
|
||||
Go toolchain, is not. A go file without a `#!` line runs in a forked copy of
|
||||
`rc.local` and is not recognised. Hosts installed before the detached go
|
||||
entry keep such a shell blocked on the old foreground wrapper line.
|
||||
`TestInstallSHNASRemovalTreatsLiveUnraidGoShellAsUnknownOwner` pins those
|
||||
roles on Unraid and QNAP. Failed/diagnostic/duplicate/empty reads and ignored/refused signals
|
||||
retain identity, credentials, trust and recoverable files. QNAP mount and
|
||||
unmount each have checked bounded results, including cleanup after partial mount.
|
||||
|
||||
|
||||
+9
-3
@@ -4041,8 +4041,9 @@ nas_agent_boot_operation() {
|
||||
}
|
||||
|
||||
# Read complete kernel evidence before signalling; expose only numeric IDs and
|
||||
# roles. Relative/foreign wrappers and QNAP deferred autorun shells have unknown
|
||||
# ownership, so retain recovery instead of signalling an unrelated boot owner.
|
||||
# roles. Relative/foreign wrappers, QNAP deferred autorun shells and live Unraid
|
||||
# go shells have unknown ownership, so retain recovery instead of signalling an
|
||||
# unrelated boot owner.
|
||||
read_nas_agent_removal_processes() {
|
||||
local platform="$1" observation="" line="" pid="" parent="" comm="" args="" role=""
|
||||
local wrapper="$STATE_DIR/start-pulse-agent.sh" runtime="$INSTALL_DIR/$BINARY_NAME"
|
||||
@@ -4071,7 +4072,12 @@ read_nas_agent_removal_processes() {
|
||||
if [[ "$args" == "$wrapper" || "$args" == "$wrapper "* ]]; then role=watchdog; fi
|
||||
if [[ "$role" != watchdog && "$args" =~ start-pulse-agent\.sh([[:space:]]|$) ]]; then role=unknown; fi
|
||||
if [[ "$platform" == QNAP && "$args" == *'/nasconfig_tmp/autorun.sh'* ]]; then role=unknown; fi
|
||||
if [[ "$platform" == Unraid && "$args" == *'/boot/config/go'* ]]; then role=unknown; fi
|
||||
if [[ "$platform" == Unraid && "$args" =~ (^|[[:space:]])('/boot/config/go'|'/var/tmp/go')(\.safemode)?([[:space:]]|$) ]]; then role=unknown; fi
|
||||
;;
|
||||
# rc.local runs the fromdos copy /var/tmp/go (go.safemode in safe
|
||||
# mode) directly, so the kernel names that shell after the script.
|
||||
go|go.safemode)
|
||||
if [[ "$platform" == Unraid && "$args" =~ (^|[[:space:]])('/boot/config/go'|'/var/tmp/go')(\.safemode)?([[:space:]]|$) ]]; then role=unknown; fi
|
||||
;;
|
||||
esac
|
||||
rows+=("$pid $parent $role"); count=$((count + 1))
|
||||
|
||||
@@ -115,9 +115,13 @@ if role == 'ps':
|
||||
if fault == 'relative-wrapper': print('103 1 sh sh start-pulse-agent.sh')
|
||||
if fault == 'deferred-bootstrap': print('104 1 sh /bin/sh /tmp/nasconfig_tmp/autorun.sh')
|
||||
if fault == 'live-go': print('105 1 bash /bin/bash ' + str(root / 'go'))
|
||||
# rc.local executes its fromdos copy, so the kernel names the shell after it.
|
||||
if fault == 'live-tmp-go': print('105 1 go /bin/bash /var/tmp/go')
|
||||
if fault == 'live-tmp-go-safemode': print('105 1 go.safemode /bin/bash /var/tmp/go.safemode')
|
||||
print('200 1 vi vi ' + wrapper) # Editor is not a watchdog or signal target.
|
||||
print('201 1 sh /bin/sh ' + wrapper + '.bak')
|
||||
print('202 1 pulse-agent-prod /other/pulse-agent-prod')
|
||||
print('203 1 go /usr/local/go/bin/go build ./...') # A Go toolchain is not a go shell.
|
||||
sys.exit(0)
|
||||
if role == 'kill':
|
||||
assert len(args) == 2 and args[0] == '-TERM', args
|
||||
@@ -471,6 +475,7 @@ remove_safe_profile_state_dir() { rm -rf -- "$SAFE_PROFILE_STATE_DIR"; }
|
||||
self.assertEqual(r[0].returncode, 0, r[0].stderr)
|
||||
self.assertIn('100 1 watchdog\n', r[0].stdout); self.assertIn('101 100 collector\n', r[0].stdout)
|
||||
self.assertNotIn('/synthetic/private-only', r[0].stdout); self.assertIn('200 1 other\n', r[0].stdout)
|
||||
self.assertIn('203 1 other\n', r[0].stdout)
|
||||
r = self.exercise('QNAP', 'timeout-process'); self.retained(r); self.assertTrue(r[9])
|
||||
|
||||
def test_private_model_uses_selected_isolated_interpreter(self):
|
||||
@@ -558,8 +563,10 @@ remove_safe_profile_state_dir() { rm -rf -- "$SAFE_PROFILE_STATE_DIR"; }
|
||||
self.assertGreaterEqual(records[1]['control_seconds'], records[0]['control_seconds'])
|
||||
|
||||
def test_live_unraid_boot_shell_is_unknown_not_a_signal_target(self):
|
||||
r = self.exercise('Unraid', 'live-go'); events = self.retained(r)
|
||||
self.assertEqual(r[6], r[7]); self.assertFalse(any(e.startswith('signal:') for e in events))
|
||||
for fault in ('live-go', 'live-tmp-go', 'live-tmp-go-safemode'):
|
||||
with self.subTest(fault=fault):
|
||||
r = self.exercise('Unraid', fault); events = self.retained(r)
|
||||
self.assertEqual(r[6], r[7]); self.assertFalse(any(e.startswith('signal:') for e in events))
|
||||
|
||||
def test_recovered_ordinary_linux_state_is_not_qnap_platform_authority(self):
|
||||
r = self.exercise('QNAP', appliance=False)
|
||||
|
||||
@@ -8750,3 +8750,74 @@ func TestInstallSHNASBootOwnersQuiesceBeforeRecoveryErasure(t *testing.T) {
|
||||
"test_caller_has_no_blanket_nas_or_legacy_tree_deletion",
|
||||
}, 2*time.Minute)
|
||||
}
|
||||
|
||||
// Unraid's rc.local executes its fromdos copy /var/tmp/go (go.safemode in safe
|
||||
// mode), so the kernel names a live go shell after the script, not bash. On a
|
||||
// host installed before the detached go entry, that shell is still blocked on
|
||||
// the foreground wrapper line. Stopping the watchdog would release it to run
|
||||
// the rest of go from memory, so it must read as an unknown boot owner.
|
||||
func TestInstallSHNASRemovalTreatsLiveUnraidGoShellAsUnknownOwner(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix NAS process observation needs bash")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
stateDir := filepath.Join(dir, "plugins", "pulse-agent")
|
||||
rows := []struct {
|
||||
process, unraid, qnap string
|
||||
}{
|
||||
{"100 1 bash /bin/bash " + filepath.Join(stateDir, "start-pulse-agent.sh"), "watchdog", "watchdog"},
|
||||
{"105 1 go /bin/bash /var/tmp/go", "unknown", "other"},
|
||||
{"106 1 go.safemode /bin/bash /var/tmp/go.safemode", "unknown", "other"},
|
||||
{"107 1 bash /bin/bash /boot/config/go", "unknown", "other"},
|
||||
{"108 1 bash bash /var/tmp/go", "unknown", "other"},
|
||||
// A Go toolchain shares the name but runs no boot script, and the
|
||||
// script path must be a whole argument, not the start of another.
|
||||
{"109 1 go /usr/local/go/bin/go build ./...", "other", "other"},
|
||||
{"110 1 bash /bin/bash /var/tmp/gopher.sh", "other", "other"},
|
||||
{"111 1 bash /bin/bash /boot/config/go~", "other", "other"},
|
||||
}
|
||||
var listing strings.Builder
|
||||
for _, row := range rows {
|
||||
listing.WriteString(row.process + "\n")
|
||||
}
|
||||
listingPath := filepath.Join(dir, "ps-listing")
|
||||
if err := os.WriteFile(listingPath, []byte(listing.String()), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tools := filepath.Join(dir, "tools")
|
||||
if err := os.MkdirAll(tools, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tools, "ps"), []byte("#!/bin/sh\nexec cat \"$PS_LISTING\"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := "set -euo pipefail\n" +
|
||||
"timeout() { shift 3; \"$@\"; }\n" +
|
||||
extractInstallShellFunction(t, "read_nas_agent_removal_processes") + "\n" +
|
||||
"read_nas_agent_removal_processes \"$1\"\n"
|
||||
for _, platform := range []string{"Unraid", "QNAP"} {
|
||||
t.Run(platform, func(t *testing.T) {
|
||||
cmd := exec.Command("bash", "-c", script, "bash", platform)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"PATH="+tools+string(os.PathListSeparator)+os.Getenv("PATH"),
|
||||
"PS_LISTING="+listingPath,
|
||||
"STATE_DIR="+stateDir,
|
||||
"INSTALL_DIR="+filepath.Join(dir, "bin"),
|
||||
"BINARY_NAME=pulse-agent")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("process observation failed: %v\n%s", err, output)
|
||||
}
|
||||
for _, row := range rows {
|
||||
fields := strings.Fields(row.process)
|
||||
want := row.unraid
|
||||
if platform == "QNAP" {
|
||||
want = row.qnap
|
||||
}
|
||||
if line := fields[0] + " " + fields[1] + " " + want + "\n"; !strings.Contains(string(output), line) {
|
||||
t.Errorf("%q: want role %s, got inventory:\n%s", row.process, want, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user