diff --git a/network/port_darwin.go b/network/port_darwin.go new file mode 100644 index 0000000..c1fb34e --- /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 MacOS by running the lsof command. +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..76cc5a0 --- /dev/null +++ b/network/port_linux.go @@ -0,0 +1,73 @@ +//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"} + portSet := make(map[int]struct{}) + + 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 { + 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 new file mode 100644 index 0000000..1f094b8 --- /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.Equal(t, len(allPorts)-1, len(filteredPorts), "should have one less port after exclusion") +} + +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.Equal(t, len(allPorts)-len(excludePorts), len(filteredPorts), "should have three fewer ports after exclusion") +} + +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") + } +}