From 673522bd2968d33c3e8a23dae1e7898e616fde44 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Mon, 20 Oct 2025 21:19:58 -0500 Subject: [PATCH 1/3] add a utility for finding all the TCP ports in listen (with the ability to exclude ports) --- network/port_darwin.go | 61 +++++++++++++++++++ network/port_linux.go | 69 ++++++++++++++++++++++ network/port_test.go | 129 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 network/port_darwin.go create mode 100644 network/port_linux.go create mode 100644 network/port_test.go diff --git a/network/port_darwin.go b/network/port_darwin.go new file mode 100644 index 0000000..c26984a --- /dev/null +++ b/network/port_darwin.go @@ -0,0 +1,61 @@ +//go:build darwin + +package network + +import ( + "bufio" + "bytes" + "fmt" + "os/exec" + "regexp" + "sort" + "strconv" +) + +// DetectListeningTCPPorts scans the system for TCP ports that are currently bound and listening. +// It returns a slice of port numbers, excluding any in the exclude list. +// Works on Linux by reading /proc/net/tcp and /proc/net/tcp6. +func DetectListeningTCPPorts(exclude ...int) ([]int, error) { + excludeSet := make(map[int]struct{}) + for _, p := range exclude { + excludeSet[p] = struct{}{} + } + + cmd := exec.Command("lsof", "-nP", "-iTCP", "-sTCP:LISTEN") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to run lsof: %w", err) + } + + scanner := bufio.NewScanner(bytes.NewReader(out)) + portPattern := regexp.MustCompile(`:(\d+)\s+\(LISTEN\)`) + + ports := make(map[int]struct{}) + + for scanner.Scan() { + line := scanner.Text() + matches := portPattern.FindStringSubmatch(line) + if len(matches) != 2 { + continue + } + port, err := strconv.Atoi(matches[1]) + if err != nil { + continue + } + if _, excluded := excludeSet[port]; !excluded { + ports[port] = struct{}{} + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + // Convert to sorted slice + var result []int + for p := range ports { + result = append(result, p) + } + sort.Ints(result) + return result, nil +} diff --git a/network/port_linux.go b/network/port_linux.go new file mode 100644 index 0000000..78ca92e --- /dev/null +++ b/network/port_linux.go @@ -0,0 +1,69 @@ +//go:build linux + +package network + +import ( + "bufio" + "os" + "sort" + "strconv" + "strings" +) + +// DetectListeningTCPPorts scans the system for TCP ports that are currently bound and listening. +// It returns a slice of port numbers, excluding any in the exclude list. +// Works on Linux by reading /proc/net/tcp and /proc/net/tcp6. +func DetectListeningTCPPorts(exclude ...int) ([]int, error) { + excludeSet := make(map[int]struct{}) + for _, p := range exclude { + excludeSet[p] = struct{}{} + } + + files := []string{"/proc/net/tcp", "/proc/net/tcp6"} + var ports []int + + for _, path := range files { + f, err := os.Open(path) + if err != nil { + continue // skip if file doesn't exist or can't be read + } + defer f.Close() + + scanner := bufio.NewScanner(f) + // Skip the header line + if scanner.Scan() { + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 4 { + continue + } + + localAddr := fields[1] + state := fields[3] + if state != "0A" { // 0A means LISTEN + continue + } + + // localAddr example: 0100007F:1F90 + parts := strings.Split(localAddr, ":") + if len(parts) != 2 { + continue + } + + portHex := parts[1] + portDec, err := strconv.ParseInt(portHex, 16, 32) + if err != nil { + continue + } + port := int(portDec) + + if _, excluded := excludeSet[port]; !excluded { + ports = append(ports, port) + } + } + } + } + + sort.Ints(ports) + return ports, nil +} diff --git a/network/port_test.go b/network/port_test.go new file mode 100644 index 0000000..dbec18f --- /dev/null +++ b/network/port_test.go @@ -0,0 +1,129 @@ +package network + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetectListeningTCPPorts(t *testing.T) { + ports, err := DetectListeningTCPPorts() + require.NoError(t, err) + assert.NotNil(t, ports) + assert.GreaterOrEqual(t, len(ports), 0) + + if len(ports) > 1 { + for i := 1; i < len(ports); i++ { + assert.Greater(t, ports[i], ports[i-1], "ports should be sorted in ascending order") + } + } +} + +func TestDetectListeningTCPPortsWithExclusion(t *testing.T) { + allPorts, err := DetectListeningTCPPorts() + require.NoError(t, err) + + if len(allPorts) == 0 { + t.Skip("no listening ports detected, skipping exclusion test") + } + + portToExclude := allPorts[0] + filteredPorts, err := DetectListeningTCPPorts(portToExclude) + require.NoError(t, err) + + for _, port := range filteredPorts { + assert.NotEqual(t, portToExclude, port, "excluded port should not be in result") + } + assert.Len(t, filteredPorts, len(allPorts)-1) +} + +func TestDetectListeningTCPPortsWithMultipleExclusions(t *testing.T) { + allPorts, err := DetectListeningTCPPorts() + require.NoError(t, err) + + if len(allPorts) < 3 { + t.Skip("not enough listening ports detected, skipping multiple exclusion test") + } + + excludePorts := []int{allPorts[0], allPorts[1], allPorts[2]} + filteredPorts, err := DetectListeningTCPPorts(excludePorts...) + require.NoError(t, err) + + for _, port := range filteredPorts { + for _, excluded := range excludePorts { + assert.NotEqual(t, excluded, port, "excluded port should not be in result") + } + } + assert.Len(t, filteredPorts, len(allPorts)-len(excludePorts)) +} + +func TestDetectListeningTCPPortsWithNonExistentExclusion(t *testing.T) { + allPorts, err := DetectListeningTCPPorts() + require.NoError(t, err) + + nonExistentPort := 65534 + filteredPorts, err := DetectListeningTCPPorts(nonExistentPort) + require.NoError(t, err) + + assert.Equal(t, len(allPorts), len(filteredPorts), "excluding non-existent port should not change result") +} + +func TestDetectListeningTCPPortsWithActualListener(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + addr := listener.Addr().(*net.TCPAddr) + boundPort := addr.Port + + ports, err := DetectListeningTCPPorts() + require.NoError(t, err) + + found := false + for _, port := range ports { + if port == boundPort { + found = true + break + } + } + assert.True(t, found, "should detect the port we just bound to") +} + +func TestDetectListeningTCPPortsWithActualListenerAndExclude(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + addr := listener.Addr().(*net.TCPAddr) + boundPort := addr.Port + + ports, err := DetectListeningTCPPorts(boundPort) + require.NoError(t, err) + + for _, port := range ports { + assert.NotEqual(t, boundPort, port, "excluded port should not appear even though it's listening") + } +} + +func TestDetectListeningTCPPortsNoDuplicates(t *testing.T) { + ports, err := DetectListeningTCPPorts() + require.NoError(t, err) + + seen := make(map[int]bool) + for _, port := range ports { + assert.False(t, seen[port], "port %d appears multiple times in result", port) + seen[port] = true + } +} + +func TestDetectListeningTCPPortsValidRange(t *testing.T) { + ports, err := DetectListeningTCPPorts() + require.NoError(t, err) + + for _, port := range ports { + assert.GreaterOrEqual(t, port, 1, "port should be >= 1") + assert.LessOrEqual(t, port, 65535, "port should be <= 65535") + } +} From a49df1180eff8342144078b5291312403890f72f Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Mon, 20 Oct 2025 21:21:34 -0500 Subject: [PATCH 2/3] fix the comment --- network/port_darwin.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/network/port_darwin.go b/network/port_darwin.go index c26984a..c1fb34e 100644 --- a/network/port_darwin.go +++ b/network/port_darwin.go @@ -14,7 +14,7 @@ import ( // DetectListeningTCPPorts scans the system for TCP ports that are currently bound and listening. // It returns a slice of port numbers, excluding any in the exclude list. -// Works on Linux by reading /proc/net/tcp and /proc/net/tcp6. +// Works on MacOS by running the lsof command. func DetectListeningTCPPorts(exclude ...int) ([]int, error) { excludeSet := make(map[int]struct{}) for _, p := range exclude { From a6fbfc0abe2fa8303bb679c0ef94d06ff822edfd Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Mon, 20 Oct 2025 21:25:10 -0500 Subject: [PATCH 3/3] fixed --- network/port_linux.go | 8 ++++++-- network/port_test.go | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/network/port_linux.go b/network/port_linux.go index 78ca92e..76cc5a0 100644 --- a/network/port_linux.go +++ b/network/port_linux.go @@ -20,7 +20,7 @@ func DetectListeningTCPPorts(exclude ...int) ([]int, error) { } files := []string{"/proc/net/tcp", "/proc/net/tcp6"} - var ports []int + portSet := make(map[int]struct{}) for _, path := range files { f, err := os.Open(path) @@ -58,12 +58,16 @@ func DetectListeningTCPPorts(exclude ...int) ([]int, error) { port := int(portDec) if _, excluded := excludeSet[port]; !excluded { - ports = append(ports, port) + portSet[port] = struct{}{} } } } } + var ports []int + for p := range portSet { + ports = append(ports, p) + } sort.Ints(ports) return ports, nil } diff --git a/network/port_test.go b/network/port_test.go index dbec18f..1f094b8 100644 --- a/network/port_test.go +++ b/network/port_test.go @@ -36,7 +36,7 @@ func TestDetectListeningTCPPortsWithExclusion(t *testing.T) { for _, port := range filteredPorts { assert.NotEqual(t, portToExclude, port, "excluded port should not be in result") } - assert.Len(t, filteredPorts, len(allPorts)-1) + assert.Equal(t, len(allPorts)-1, len(filteredPorts), "should have one less port after exclusion") } func TestDetectListeningTCPPortsWithMultipleExclusions(t *testing.T) { @@ -56,7 +56,7 @@ func TestDetectListeningTCPPortsWithMultipleExclusions(t *testing.T) { assert.NotEqual(t, excluded, port, "excluded port should not be in result") } } - assert.Len(t, filteredPorts, len(allPorts)-len(excludePorts)) + assert.Equal(t, len(allPorts)-len(excludePorts), len(filteredPorts), "should have three fewer ports after exclusion") } func TestDetectListeningTCPPortsWithNonExistentExclusion(t *testing.T) {