From 27f7e11e9b29d813c78126da9c7e2daf4d30cbb9 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:22:54 +0100 Subject: [PATCH] Refuse Unraid uninstall while the rc.local go shell is live Uninstall treats a live Unraid go shell as an unknown boot owner and refuses, because that shell may still hold later boot lines in memory. Signalling the watchdog would release it to run them. The check only looked for /boot/config/go in a process whose name is a shell. Unraid's rc.local never runs that path: it writes a fromdos copy to /var/tmp/go (go.safemode in safe mode) and executes that copy directly. The kernel then names the process "go", with argv "/bin/bash /var/tmp/go", so the check never matched it. Hosts installed before the detached go entry still have this shell blocked on the old foreground wrapper line. On those hosts uninstall classified the shell as unrelated, stopped the watchdog, and let go run the rest of its in-memory script. That is the case the live-shell refusal was added to prevent. The role check now covers a go or go.safemode process, or a shell, that has /boot/config/go, /var/tmp/go or a .safemode form as a whole argument. The old substring match is gone, so near-miss paths such as /var/tmp/gopher.sh no longer block removal, and a Go toolchain process named go still reads as unrelated. The NAS teardown control covers the rc.local spellings, and a new role-table test pins Unraid and QNAP. Both fail against the previous install.sh. A go file without a #! line is still not recognised. Bash runs it in a forked copy of rc.local, and the contracts now say so. --- .../v6/internal/subsystems/agent-lifecycle.md | 12 +++- .../subsystems/deployment-installability.md | 12 +++- scripts/install.sh | 12 +++- scripts/installtests/agent_nas_teardown.py | 11 ++- scripts/installtests/install_sh_test.go | 71 +++++++++++++++++++ 5 files changed, 111 insertions(+), 7 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index fad0bfaec4..26095e03aa 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 9445acc4b1..34d56af2f4 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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. diff --git a/scripts/install.sh b/scripts/install.sh index ddf5472b6c..a0bdd04a7e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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)) diff --git a/scripts/installtests/agent_nas_teardown.py b/scripts/installtests/agent_nas_teardown.py index d0f6d4bb4a..8633ef539c 100644 --- a/scripts/installtests/agent_nas_teardown.py +++ b/scripts/installtests/agent_nas_teardown.py @@ -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) diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index c6240f9609..e4313bc6e4 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -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) + } + } + }) + } +}