diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 51204cfd20..5a06010f40 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) + } + } + }) + } +}