From e8458e169ca26a0d075389d3c2aa73ce2e0574bc Mon Sep 17 00:00:00 2001 From: Alix-007 <267018309+Alix-007@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:39:05 +0800 Subject: [PATCH] fix(gateway): verify pid identity before stop --- cmd/picoclaw/internal/gateway/control.go | 57 ++++++++++--- cmd/picoclaw/internal/gateway/control_test.go | 85 ++++++++++++++++++- .../gateway/process_identity_linux.go | 41 +++++++++ .../gateway/process_identity_other.go | 12 +++ 4 files changed, 181 insertions(+), 14 deletions(-) create mode 100644 cmd/picoclaw/internal/gateway/process_identity_linux.go create mode 100644 cmd/picoclaw/internal/gateway/process_identity_other.go diff --git a/cmd/picoclaw/internal/gateway/control.go b/cmd/picoclaw/internal/gateway/control.go index 398208599..1fd2f9481 100644 --- a/cmd/picoclaw/internal/gateway/control.go +++ b/cmd/picoclaw/internal/gateway/control.go @@ -12,6 +12,11 @@ import ( "github.com/sipeed/picoclaw/pkg/pid" ) +type gatewayTarget struct { + data *pid.PidFileData + process *os.Process +} + func newStatusCommand() *cobra.Command { return &cobra.Command{ Use: "status", @@ -35,30 +40,36 @@ func newStopCommand() *cobra.Command { } func gatewayStatusCmd(homePath string) error { - data := pid.ReadPidFileWithCheck(homePath) - if data == nil { + target, err := resolveGatewayTarget(homePath) + if err != nil { + return err + } + if target == nil { fmt.Println("Gateway status: stopped") return nil } fmt.Printf( "Gateway status: running (PID: %d, host: %s, port: %d)\n", - data.PID, - data.Host, - data.Port, + target.data.PID, + target.data.Host, + target.data.Port, ) return nil } func gatewayStopCmd(homePath string) error { - data := pid.ReadPidFileWithCheck(homePath) - if data == nil { + target, err := resolveGatewayTarget(homePath) + if err != nil { + return err + } + if target == nil { return fmt.Errorf("gateway is not running") } - process, err := os.FindProcess(data.PID) - if err != nil { - return fmt.Errorf("failed to find gateway process (PID: %d): %w", data.PID, err) + process := target.process + if process == nil { + return fmt.Errorf("failed to find gateway process (PID: %d)", target.data.PID) } if runtime.GOOS == "windows" { @@ -67,9 +78,31 @@ func gatewayStopCmd(homePath string) error { err = process.Signal(syscall.SIGTERM) } if err != nil { - return fmt.Errorf("failed to stop gateway (PID: %d): %w", data.PID, err) + return fmt.Errorf("failed to stop gateway (PID: %d): %w", target.data.PID, err) } - fmt.Printf("Sent stop signal to gateway (PID: %d)\n", data.PID) + fmt.Printf("Sent stop signal to gateway (PID: %d)\n", target.data.PID) return nil } + +func resolveGatewayTarget(homePath string) (*gatewayTarget, error) { + data := pid.ReadPidFileWithCheck(homePath) + if data == nil { + return nil, nil + } + + process, err := os.FindProcess(data.PID) + if err != nil { + return nil, fmt.Errorf("failed to find gateway process (PID: %d): %w", data.PID, err) + } + + err = verifyGatewayProcessIdentity(data.PID) + if err != nil { + return nil, err + } + + return &gatewayTarget{ + data: data, + process: process, + }, nil +} diff --git a/cmd/picoclaw/internal/gateway/control_test.go b/cmd/picoclaw/internal/gateway/control_test.go index df070240b..931f39c73 100644 --- a/cmd/picoclaw/internal/gateway/control_test.go +++ b/cmd/picoclaw/internal/gateway/control_test.go @@ -72,7 +72,7 @@ func TestGatewayStopCmdNotRunning(t *testing.T) { assert.Contains(t, err.Error(), "gateway is not running") } -func TestGatewayStopCmdRunningProcess(t *testing.T) { +func TestGatewayStatusCmdRejectsNonGatewayPID(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("requires POSIX signal semantics") } @@ -89,6 +89,51 @@ func TestGatewayStopCmdRunningProcess(t *testing.T) { writeGatewayPidFile(t, homePath, sleepCmd.Process.Pid) + _, err := captureGatewayStdout(t, func() error { + return gatewayStatusCmd(homePath) + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "non-gateway process") +} + +func TestGatewayStopCmdRejectsNonGatewayPID(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires POSIX signal semantics") + } + + homePath := t.TempDir() + sleepCmd := exec.Command("sleep", "30") + require.NoError(t, sleepCmd.Start()) + t.Cleanup(func() { + if sleepCmd.Process != nil { + _ = sleepCmd.Process.Kill() + } + _ = sleepCmd.Wait() + }) + + writeGatewayPidFile(t, homePath, sleepCmd.Process.Pid) + + err := gatewayStopCmd(homePath) + require.Error(t, err) + assert.Contains(t, err.Error(), "non-gateway process") +} + +func TestGatewayStopCmdRunningProcess(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires POSIX signal semantics") + } + + homePath := t.TempDir() + helperCmd := startGatewayHelperProcess(t) + t.Cleanup(func() { + if helperCmd.Process != nil { + _ = helperCmd.Process.Kill() + } + }) + + writeGatewayPidFile(t, homePath, helperCmd.Process.Pid) + output, err := captureGatewayStdout(t, func() error { return gatewayStopCmd(homePath) }) @@ -98,7 +143,7 @@ func TestGatewayStopCmdRunningProcess(t *testing.T) { done := make(chan error, 1) go func() { - done <- sleepCmd.Wait() + done <- helperCmd.Wait() }() select { @@ -110,3 +155,39 @@ func TestGatewayStopCmdRunningProcess(t *testing.T) { t.Fatal("gateway process did not exit after stop signal") } } + +func startGatewayHelperProcess(t *testing.T) *exec.Cmd { + t.Helper() + + exePath, err := os.Executable() + require.NoError(t, err) + + cmd := exec.Command( + exePath, + "-test.run=TestGatewayCommandHelperProcess", + "--", + "gateway", + ) + cmd.Env = append(os.Environ(), "GO_WANT_GATEWAY_HELPER_PROCESS=1") + require.NoError(t, cmd.Start()) + return cmd +} + +func TestGatewayCommandHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_GATEWAY_HELPER_PROCESS") != "1" { + return + } + + for i, arg := range os.Args { + if arg != "--" { + continue + } + args := os.Args[i+1:] + if len(args) > 0 && args[0] == "gateway" { + select {} + } + break + } + + os.Exit(2) +} diff --git a/cmd/picoclaw/internal/gateway/process_identity_linux.go b/cmd/picoclaw/internal/gateway/process_identity_linux.go new file mode 100644 index 000000000..1a04b479a --- /dev/null +++ b/cmd/picoclaw/internal/gateway/process_identity_linux.go @@ -0,0 +1,41 @@ +//go:build linux + +package gateway + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strconv" +) + +func verifyGatewayProcessIdentity(processID int) error { + targetExe, err := os.Readlink(filepath.Join("/proc", strconv.Itoa(processID), "exe")) + if err != nil { + return fmt.Errorf("failed to inspect gateway process executable (PID: %d): %w", processID, err) + } + + currentExe, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to inspect current executable: %w", err) + } + + if filepath.Base(targetExe) != filepath.Base(currentExe) { + return fmt.Errorf("pid file points to a non-gateway process (PID: %d)", processID) + } + + rawCmdline, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(processID), "cmdline")) + if err != nil { + return fmt.Errorf("failed to inspect gateway process command line (PID: %d): %w", processID, err) + } + + argv := bytes.Split(rawCmdline, []byte{0}) + for _, arg := range argv[1:] { + if string(arg) == "gateway" || string(arg) == "g" { + return nil + } + } + + return fmt.Errorf("pid file points to a non-gateway process (PID: %d)", processID) +} diff --git a/cmd/picoclaw/internal/gateway/process_identity_other.go b/cmd/picoclaw/internal/gateway/process_identity_other.go new file mode 100644 index 000000000..fb099ff26 --- /dev/null +++ b/cmd/picoclaw/internal/gateway/process_identity_other.go @@ -0,0 +1,12 @@ +//go:build !linux + +package gateway + +import "fmt" + +func verifyGatewayProcessIdentity(processID int) error { + return fmt.Errorf( + "gateway process identity verification is not supported on this platform (PID: %d)", + processID, + ) +}