Skip to content

Commit 673522b

Browse files
committed
add a utility for finding all the TCP ports in listen (with the ability to exclude ports)
1 parent 12df85a commit 673522b

File tree

3 files changed

+259
-0
lines changed

3 files changed

+259
-0
lines changed

network/port_darwin.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//go:build darwin
2+
3+
package network
4+
5+
import (
6+
"bufio"
7+
"bytes"
8+
"fmt"
9+
"os/exec"
10+
"regexp"
11+
"sort"
12+
"strconv"
13+
)
14+
15+
// DetectListeningTCPPorts scans the system for TCP ports that are currently bound and listening.
16+
// It returns a slice of port numbers, excluding any in the exclude list.
17+
// Works on Linux by reading /proc/net/tcp and /proc/net/tcp6.
18+
func DetectListeningTCPPorts(exclude ...int) ([]int, error) {
19+
excludeSet := make(map[int]struct{})
20+
for _, p := range exclude {
21+
excludeSet[p] = struct{}{}
22+
}
23+
24+
cmd := exec.Command("lsof", "-nP", "-iTCP", "-sTCP:LISTEN")
25+
out, err := cmd.Output()
26+
if err != nil {
27+
return nil, fmt.Errorf("failed to run lsof: %w", err)
28+
}
29+
30+
scanner := bufio.NewScanner(bytes.NewReader(out))
31+
portPattern := regexp.MustCompile(`:(\d+)\s+\(LISTEN\)`)
32+
33+
ports := make(map[int]struct{})
34+
35+
for scanner.Scan() {
36+
line := scanner.Text()
37+
matches := portPattern.FindStringSubmatch(line)
38+
if len(matches) != 2 {
39+
continue
40+
}
41+
port, err := strconv.Atoi(matches[1])
42+
if err != nil {
43+
continue
44+
}
45+
if _, excluded := excludeSet[port]; !excluded {
46+
ports[port] = struct{}{}
47+
}
48+
}
49+
50+
if err := scanner.Err(); err != nil {
51+
return nil, err
52+
}
53+
54+
// Convert to sorted slice
55+
var result []int
56+
for p := range ports {
57+
result = append(result, p)
58+
}
59+
sort.Ints(result)
60+
return result, nil
61+
}

network/port_linux.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
//go:build linux
2+
3+
package network
4+
5+
import (
6+
"bufio"
7+
"os"
8+
"sort"
9+
"strconv"
10+
"strings"
11+
)
12+
13+
// DetectListeningTCPPorts scans the system for TCP ports that are currently bound and listening.
14+
// It returns a slice of port numbers, excluding any in the exclude list.
15+
// Works on Linux by reading /proc/net/tcp and /proc/net/tcp6.
16+
func DetectListeningTCPPorts(exclude ...int) ([]int, error) {
17+
excludeSet := make(map[int]struct{})
18+
for _, p := range exclude {
19+
excludeSet[p] = struct{}{}
20+
}
21+
22+
files := []string{"/proc/net/tcp", "/proc/net/tcp6"}
23+
var ports []int
24+
25+
for _, path := range files {
26+
f, err := os.Open(path)
27+
if err != nil {
28+
continue // skip if file doesn't exist or can't be read
29+
}
30+
defer f.Close()
31+
32+
scanner := bufio.NewScanner(f)
33+
// Skip the header line
34+
if scanner.Scan() {
35+
for scanner.Scan() {
36+
fields := strings.Fields(scanner.Text())
37+
if len(fields) < 4 {
38+
continue
39+
}
40+
41+
localAddr := fields[1]
42+
state := fields[3]
43+
if state != "0A" { // 0A means LISTEN
44+
continue
45+
}
46+
47+
// localAddr example: 0100007F:1F90
48+
parts := strings.Split(localAddr, ":")
49+
if len(parts) != 2 {
50+
continue
51+
}
52+
53+
portHex := parts[1]
54+
portDec, err := strconv.ParseInt(portHex, 16, 32)
55+
if err != nil {
56+
continue
57+
}
58+
port := int(portDec)
59+
60+
if _, excluded := excludeSet[port]; !excluded {
61+
ports = append(ports, port)
62+
}
63+
}
64+
}
65+
}
66+
67+
sort.Ints(ports)
68+
return ports, nil
69+
}

network/port_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package network
2+
3+
import (
4+
"net"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestDetectListeningTCPPorts(t *testing.T) {
12+
ports, err := DetectListeningTCPPorts()
13+
require.NoError(t, err)
14+
assert.NotNil(t, ports)
15+
assert.GreaterOrEqual(t, len(ports), 0)
16+
17+
if len(ports) > 1 {
18+
for i := 1; i < len(ports); i++ {
19+
assert.Greater(t, ports[i], ports[i-1], "ports should be sorted in ascending order")
20+
}
21+
}
22+
}
23+
24+
func TestDetectListeningTCPPortsWithExclusion(t *testing.T) {
25+
allPorts, err := DetectListeningTCPPorts()
26+
require.NoError(t, err)
27+
28+
if len(allPorts) == 0 {
29+
t.Skip("no listening ports detected, skipping exclusion test")
30+
}
31+
32+
portToExclude := allPorts[0]
33+
filteredPorts, err := DetectListeningTCPPorts(portToExclude)
34+
require.NoError(t, err)
35+
36+
for _, port := range filteredPorts {
37+
assert.NotEqual(t, portToExclude, port, "excluded port should not be in result")
38+
}
39+
assert.Len(t, filteredPorts, len(allPorts)-1)
40+
}
41+
42+
func TestDetectListeningTCPPortsWithMultipleExclusions(t *testing.T) {
43+
allPorts, err := DetectListeningTCPPorts()
44+
require.NoError(t, err)
45+
46+
if len(allPorts) < 3 {
47+
t.Skip("not enough listening ports detected, skipping multiple exclusion test")
48+
}
49+
50+
excludePorts := []int{allPorts[0], allPorts[1], allPorts[2]}
51+
filteredPorts, err := DetectListeningTCPPorts(excludePorts...)
52+
require.NoError(t, err)
53+
54+
for _, port := range filteredPorts {
55+
for _, excluded := range excludePorts {
56+
assert.NotEqual(t, excluded, port, "excluded port should not be in result")
57+
}
58+
}
59+
assert.Len(t, filteredPorts, len(allPorts)-len(excludePorts))
60+
}
61+
62+
func TestDetectListeningTCPPortsWithNonExistentExclusion(t *testing.T) {
63+
allPorts, err := DetectListeningTCPPorts()
64+
require.NoError(t, err)
65+
66+
nonExistentPort := 65534
67+
filteredPorts, err := DetectListeningTCPPorts(nonExistentPort)
68+
require.NoError(t, err)
69+
70+
assert.Equal(t, len(allPorts), len(filteredPorts), "excluding non-existent port should not change result")
71+
}
72+
73+
func TestDetectListeningTCPPortsWithActualListener(t *testing.T) {
74+
listener, err := net.Listen("tcp", "127.0.0.1:0")
75+
require.NoError(t, err)
76+
defer listener.Close()
77+
78+
addr := listener.Addr().(*net.TCPAddr)
79+
boundPort := addr.Port
80+
81+
ports, err := DetectListeningTCPPorts()
82+
require.NoError(t, err)
83+
84+
found := false
85+
for _, port := range ports {
86+
if port == boundPort {
87+
found = true
88+
break
89+
}
90+
}
91+
assert.True(t, found, "should detect the port we just bound to")
92+
}
93+
94+
func TestDetectListeningTCPPortsWithActualListenerAndExclude(t *testing.T) {
95+
listener, err := net.Listen("tcp", "127.0.0.1:0")
96+
require.NoError(t, err)
97+
defer listener.Close()
98+
99+
addr := listener.Addr().(*net.TCPAddr)
100+
boundPort := addr.Port
101+
102+
ports, err := DetectListeningTCPPorts(boundPort)
103+
require.NoError(t, err)
104+
105+
for _, port := range ports {
106+
assert.NotEqual(t, boundPort, port, "excluded port should not appear even though it's listening")
107+
}
108+
}
109+
110+
func TestDetectListeningTCPPortsNoDuplicates(t *testing.T) {
111+
ports, err := DetectListeningTCPPorts()
112+
require.NoError(t, err)
113+
114+
seen := make(map[int]bool)
115+
for _, port := range ports {
116+
assert.False(t, seen[port], "port %d appears multiple times in result", port)
117+
seen[port] = true
118+
}
119+
}
120+
121+
func TestDetectListeningTCPPortsValidRange(t *testing.T) {
122+
ports, err := DetectListeningTCPPorts()
123+
require.NoError(t, err)
124+
125+
for _, port := range ports {
126+
assert.GreaterOrEqual(t, port, 1, "port should be >= 1")
127+
assert.LessOrEqual(t, port, 65535, "port should be <= 65535")
128+
}
129+
}

0 commit comments

Comments
 (0)