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:
rcourtman
2026-10-24 10:52:43 +01:00
committed by GitHub
5 changed files with 111 additions and 7 deletions
@@ -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
View File
@@ -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))
+9 -2
View File
@@ -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)
+71
View File
@@ -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)
}
}
})
}
}