Skip to content

Commit 586eedb

Browse files
authored
Merge branch 'main' into issue-3876
2 parents 00fd485 + e42f226 commit 586eedb

9 files changed

Lines changed: 407 additions & 1 deletion

File tree

.github/workflows/security-scan.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ jobs:
2626
- name: Run Trivy vulnerability scanner in repo mode
2727
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
2828
with:
29+
version: 'v0.69.3'
2930
scan-type: 'fs'
3031
scan-ref: '.'
3132
format: 'sarif'
@@ -47,6 +48,7 @@ jobs:
4748
- name: Run Trivy configuration scanner
4849
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
4950
with:
51+
version: 'v0.69.3'
5052
scan-type: 'config'
5153
scan-ref: './deploy/charts'
5254
format: 'sarif'

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ require (
4141
github.com/pressly/goose/v3 v3.27.0
4242
github.com/prometheus/client_golang v1.23.2
4343
github.com/redis/go-redis/v9 v9.18.0
44+
github.com/shirou/gopsutil/v4 v4.25.6
4445
github.com/spf13/viper v1.21.0
4546
github.com/stacklok/toolhive-catalog v0.20260223.0
4647
github.com/stacklok/toolhive-core v0.0.8
@@ -237,7 +238,6 @@ require (
237238
github.com/sergi/go-diff v1.4.0 // indirect
238239
github.com/sethvargo/go-retry v0.3.0 // indirect
239240
github.com/shibumi/go-pathspec v1.3.0 // indirect
240-
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
241241
github.com/sigstore/protobuf-specs v0.5.0 // indirect
242242
github.com/sigstore/rekor v1.5.0 // indirect
243243
github.com/sigstore/rekor-tiles/v2 v2.0.1 // indirect

pkg/networking/port.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"log/slog"
1212
"math/big"
1313
"net"
14+
15+
gopsutilnet "github.com/shirou/gopsutil/v4/net"
1416
)
1517

1618
const (
@@ -177,3 +179,25 @@ func ValidateCallbackPort(callbackPort int, clientID string) error {
177179
func IsPreRegisteredClient(clientID string) bool {
178180
return clientID != ""
179181
}
182+
183+
// GetProcessOnPort returns the PID of the process listening on the given TCP port.
184+
// Returns 0 if the port is free or if the holder cannot be determined.
185+
// Uses gopsutil which provides cross-platform support (Linux: /proc, Windows: GetExtendedTcpTable,
186+
// Darwin/FreeBSD: lsof).
187+
func GetProcessOnPort(port int) (int, error) {
188+
if port <= 0 || port > MaxPort {
189+
return 0, fmt.Errorf("invalid port %d", port)
190+
}
191+
192+
conns, err := gopsutilnet.Connections("tcp")
193+
if err != nil {
194+
return 0, fmt.Errorf("failed to get TCP connections: %w", err)
195+
}
196+
197+
for _, c := range conns {
198+
if c.Laddr.Port == uint32(port) && c.Status == "LISTEN" && c.Pid > 0 { //nolint:gosec // G115 - port validated in [1, 65535]
199+
return int(c.Pid), nil
200+
}
201+
}
202+
return 0, nil
203+
}

pkg/networking/port_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44
package networking_test
55

66
import (
7+
"net"
78
"testing"
89

10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
913
"github.com/stacklok/toolhive/pkg/networking"
1014
)
1115

@@ -80,3 +84,53 @@ func TestValidateCallbackPort(t *testing.T) {
8084
})
8185
}
8286
}
87+
88+
func TestGetProcessOnPort_InvalidPort(t *testing.T) {
89+
t.Parallel()
90+
91+
tests := []struct {
92+
name string
93+
port int
94+
}{
95+
{"zero port", 0},
96+
{"negative port", -1},
97+
{"port too large", 65536},
98+
}
99+
for _, tt := range tests {
100+
t.Run(tt.name, func(t *testing.T) {
101+
t.Parallel()
102+
pid, err := networking.GetProcessOnPort(tt.port)
103+
require.Error(t, err)
104+
assert.Equal(t, 0, pid)
105+
})
106+
}
107+
}
108+
109+
func TestGetProcessOnPort_FreePort(t *testing.T) {
110+
t.Parallel()
111+
112+
// Use a port that FindAvailable guarantees is free
113+
port := networking.FindAvailable()
114+
require.NotZero(t, port, "FindAvailable should find a free port")
115+
116+
pid, err := networking.GetProcessOnPort(port)
117+
require.NoError(t, err)
118+
assert.Equal(t, 0, pid)
119+
}
120+
121+
func TestGetProcessOnPort_PortInUse(t *testing.T) {
122+
t.Parallel()
123+
124+
// Bind to a port, then verify GetProcessOnPort returns our process
125+
listener, err := net.Listen("tcp", "127.0.0.1:0")
126+
require.NoError(t, err)
127+
defer listener.Close()
128+
129+
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
130+
require.True(t, ok)
131+
port := tcpAddr.Port
132+
133+
pid, err := networking.GetProcessOnPort(port)
134+
require.NoError(t, err)
135+
assert.NotZero(t, pid, "port is in use, GetProcessOnPort should return the process PID")
136+
}

pkg/process/toolhive_proxy.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package process
5+
6+
import (
7+
"strings"
8+
9+
gopsutilprocess "github.com/shirou/gopsutil/v4/process"
10+
)
11+
12+
const (
13+
// toolHiveBinaryName is the binary name used to identify ToolHive processes.
14+
// Used when TOOLHIVE_DETACHED cannot be read (e.g. platform restrictions).
15+
toolHiveBinaryName = "thv"
16+
)
17+
18+
// containsThvBinary returns true if s appears to reference the thv binary
19+
// (e.g. /usr/bin/thv, thv start, thv.exe), avoiding false positives like "toolhive".
20+
func containsThvBinary(s string) bool {
21+
lower := strings.ToLower(s)
22+
return strings.Contains(lower, toolHiveBinaryName+".exe") ||
23+
strings.HasSuffix(lower, toolHiveBinaryName) ||
24+
strings.Contains(lower, "/"+toolHiveBinaryName) ||
25+
strings.Contains(lower, `\`+toolHiveBinaryName) ||
26+
strings.HasPrefix(lower, toolHiveBinaryName+" ") ||
27+
strings.Contains(lower, " "+toolHiveBinaryName+" ") ||
28+
strings.HasSuffix(lower, " "+toolHiveBinaryName)
29+
}
30+
31+
// IsToolHiveProxyForWorkload returns true if the given PID belongs to the ToolHive
32+
// proxy for the specified workload, so it is safe to kill when freeing a port.
33+
// Returns false if the process is not that workload's proxy or if identity cannot
34+
// be verified (fail-safe: do not kill).
35+
//
36+
// When workloadName is empty, only verifies it is a ToolHive process.
37+
// When workloadName is non-empty, also verifies the process cmdline contains
38+
// " start <workloadName> " (the detached proxy runs "thv start <name> --foreground").
39+
//
40+
// Verification checks, in order:
41+
// 1. TOOLHIVE_DETACHED=1 in process environment (most reliable)
42+
// 2. "thv" in executable path or command line (fallback when env unavailable)
43+
// 3. workloadName in cmdline (when provided, avoids killing another workload's proxy)
44+
func IsToolHiveProxyForWorkload(pid int, workloadName string) (bool, error) {
45+
if pid <= 0 {
46+
return false, nil
47+
}
48+
49+
// PID fits in int32 on all supported platforms
50+
p, err := gopsutilprocess.NewProcess(int32(pid)) //nolint:gosec // G115
51+
if err != nil {
52+
return false, err
53+
}
54+
55+
if !isToolHiveProcess(p) {
56+
return false, nil
57+
}
58+
59+
if workloadName != "" {
60+
cmdline, err := p.Cmdline()
61+
if err != nil || !cmdlineContainsWorkload(cmdline, workloadName) {
62+
return false, nil
63+
}
64+
}
65+
66+
return true, nil
67+
}
68+
69+
// isToolHiveProcess returns true if p is a ToolHive process (TOOLHIVE_DETACHED
70+
// or thv binary in exe/cmdline).
71+
func isToolHiveProcess(p *gopsutilprocess.Process) bool {
72+
if hasToolHiveDetachedEnv(p) {
73+
return true
74+
}
75+
if exe, err := p.Exe(); err == nil && containsThvBinary(exe) {
76+
return true
77+
}
78+
if cmdline, err := p.Cmdline(); err == nil && containsThvBinary(cmdline) {
79+
return true
80+
}
81+
return false
82+
}
83+
84+
func hasToolHiveDetachedEnv(p *gopsutilprocess.Process) bool {
85+
env, err := p.Environ()
86+
if err != nil {
87+
return false
88+
}
89+
target := ToolHiveDetachedEnv + "=" + ToolHiveDetachedValue
90+
for _, e := range env {
91+
if e == target {
92+
return true
93+
}
94+
}
95+
return false
96+
}
97+
98+
// cmdlineContainsWorkload returns true if the cmdline indicates a "thv start <name> ..." process.
99+
// Uses word boundaries to avoid partial matches (e.g. "g" matching "github").
100+
func cmdlineContainsWorkload(cmdline, workloadName string) bool {
101+
if workloadName == "" {
102+
return false
103+
}
104+
pattern := " start " + workloadName
105+
if !strings.Contains(cmdline, pattern) {
106+
return false
107+
}
108+
// Ensure workloadName is not a prefix of a longer word: next char must be space, -, or end
109+
idx := strings.Index(cmdline, pattern)
110+
end := idx + len(pattern)
111+
if end >= len(cmdline) {
112+
return true
113+
}
114+
next := cmdline[end]
115+
return next == ' ' || next == '-'
116+
}

pkg/process/toolhive_proxy_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package process
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestIsToolHiveProxyForWorkload_InvalidPID(t *testing.T) {
14+
t.Parallel()
15+
isToolHive, err := IsToolHiveProxyForWorkload(0, "")
16+
require.NoError(t, err)
17+
assert.False(t, isToolHive)
18+
19+
isToolHive, err = IsToolHiveProxyForWorkload(-1, "")
20+
require.NoError(t, err)
21+
assert.False(t, isToolHive)
22+
}
23+
24+
func TestIsToolHiveProxyForWorkload_NonToolHiveProcess(t *testing.T) {
25+
t.Parallel()
26+
// Use a very high PID that almost certainly doesn't exist.
27+
// IsToolHiveProxyForWorkload should return false (fail-safe: don't kill unknown processes).
28+
isToolHive, err := IsToolHiveProxyForWorkload(999999999, "")
29+
if err != nil {
30+
// Process may not exist; either way we must not report it as ToolHive
31+
assert.False(t, isToolHive)
32+
return
33+
}
34+
require.NoError(t, err)
35+
assert.False(t, isToolHive)
36+
}
37+
38+
func TestContainsThvBinary(t *testing.T) {
39+
t.Parallel()
40+
tests := []struct {
41+
name string
42+
input string
43+
expected bool
44+
}{
45+
{"thv exe", "thv.exe", true},
46+
{"path with thv", "/usr/local/bin/thv", true},
47+
{"path with thv windows", `C:\tools\thv.exe`, true},
48+
{"cmd thv start", "thv start myworkload --foreground", true},
49+
{"cmd with thv in middle", "/path/to/thv start x", true},
50+
{"toolhive not thv", "toolhive", false},
51+
{"toolhive.test", "/tmp/toolhive.test", false},
52+
{"unrelated", "node server.js", false},
53+
}
54+
for _, tt := range tests {
55+
tt := tt
56+
t.Run(tt.name, func(t *testing.T) {
57+
t.Parallel()
58+
got := containsThvBinary(tt.input)
59+
assert.Equal(t, tt.expected, got, "containsThvBinary(%q)", tt.input)
60+
})
61+
}
62+
}
63+
64+
func TestCmdlineContainsWorkload(t *testing.T) {
65+
t.Parallel()
66+
tests := []struct {
67+
name string
68+
cmdline string
69+
workload string
70+
expected bool
71+
}{
72+
{"matches github", "/usr/bin/thv start github --foreground", "github", true},
73+
{"matches with debug", "thv start slack --foreground --debug", "slack", true},
74+
{"matches at end", "thv start myworkload", "myworkload", true},
75+
{"rejects different workload", "/usr/bin/thv start slack --foreground", "github", false},
76+
{"rejects partial match", "thv start github --foreground", "git", false},
77+
{"rejects suffix match", "thv start github --foreground", "hub", false},
78+
{"empty workload", "thv start github --foreground", "", false},
79+
}
80+
for _, tt := range tests {
81+
tt := tt
82+
t.Run(tt.name, func(t *testing.T) {
83+
t.Parallel()
84+
got := cmdlineContainsWorkload(tt.cmdline, tt.workload)
85+
assert.Equal(t, tt.expected, got, "cmdlineContainsWorkload(%q, %q)", tt.cmdline, tt.workload)
86+
})
87+
}
88+
}

pkg/process/wait.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package process
5+
6+
import (
7+
"context"
8+
"time"
9+
)
10+
11+
// WaitForExit waits for the process with the given PID to exit.
12+
// It polls FindProcess every 50ms until the process is no longer running
13+
// or the context is cancelled. Callers should use context.WithTimeout to
14+
// impose a deadline.
15+
// Returns nil when the process has exited, or an error on context cancellation.
16+
func WaitForExit(ctx context.Context, pid int) error {
17+
ticker := time.NewTicker(50 * time.Millisecond)
18+
defer ticker.Stop()
19+
20+
for {
21+
alive, err := FindProcess(pid)
22+
if err != nil {
23+
return err
24+
}
25+
if !alive {
26+
return nil
27+
}
28+
29+
select {
30+
case <-ctx.Done():
31+
return ctx.Err()
32+
case <-ticker.C:
33+
}
34+
}
35+
}

0 commit comments

Comments
 (0)