From 741de36419541a991d0783aff4d5533db385f7bb Mon Sep 17 00:00:00 2001 From: Yassine El Bouchaibi Date: Wed, 8 Apr 2026 21:46:34 -0400 Subject: [PATCH 1/3] fix(netstat): handle missing /proc/net/tcp6 on IPv6-disabled hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Linux hosts booted with ipv6.disable=1 or built without CONFIG_IPV6, /proc/net/tcp6 and /proc/net/udp6 do not exist. doNetstat previously returned the os.IsNotExist error verbatim, which propagated through TCP6Socks and caused findPorts to discard already-collected IPv4 sockets and fail the entire watch loop — leaving IDEs such as openvscode unreachable because no ports were ever forwarded. Treat a missing procfile as "zero sockets of this family" by returning (nil, nil) only when os.IsNotExist is true. Permission errors, I/O errors, and parse errors continue to propagate unchanged. Adds the first unit tests for pkg/netstat, covering the missing-file path, the happy path, non-IsNotExist open errors, and parse errors. Fixes #705 Co-Authored-By: Claude Sonnet 4.6 --- pkg/netstat/netstat_util.go | 9 +++ pkg/netstat/netstat_util_test.go | 116 +++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 pkg/netstat/netstat_util_test.go diff --git a/pkg/netstat/netstat_util.go b/pkg/netstat/netstat_util.go index c3ec18b18..683945b83 100644 --- a/pkg/netstat/netstat_util.go +++ b/pkg/netstat/netstat_util.go @@ -246,9 +246,18 @@ func extractProcInfo(sktab []SockTabEntry) { } // doNetstat - collect information about network port status. +// +// If path does not exist (e.g. /proc/net/tcp6 on Linux hosts booted with +// ipv6.disable=1 or built without CONFIG_IPV6), this returns an empty slice +// and a nil error so callers can degrade gracefully instead of aborting the +// entire port scan. All other errors, including permission and parse +// failures, still propagate. See issue #705. func doNetstat(path string, fn AcceptFn) ([]SockTabEntry, error) { f, err := os.Open(path) if err != nil { + if os.IsNotExist(err) { + return nil, nil + } return nil, err } tabs, err := parseSocktab(f, fn) diff --git a/pkg/netstat/netstat_util_test.go b/pkg/netstat/netstat_util_test.go new file mode 100644 index 000000000..10e7a2cd6 --- /dev/null +++ b/pkg/netstat/netstat_util_test.go @@ -0,0 +1,116 @@ +package netstat + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type NetstatUtilTestSuite struct { + suite.Suite +} + +func TestNetstatUtilSuite(t *testing.T) { + suite.Run(t, new(NetstatUtilTestSuite)) +} + +// TestDoNetstat_MissingFileReturnsEmpty verifies that a missing procfile (as +// seen on Linux hosts booted with ipv6.disable=1, where /proc/net/tcp6 does +// not exist) causes doNetstat to return an empty slice and nil error rather +// than propagating the os.IsNotExist error. This is the core regression +// guard for issue #705. +func (s *NetstatUtilTestSuite) TestDoNetstat_MissingFileReturnsEmpty() { + missing := filepath.Join(s.T().TempDir(), "does-not-exist") + + entries, err := doNetstat(missing, NoopFilter) + + assert.NoError(s.T(), err, "missing procfile must not error") + assert.Empty(s.T(), entries, "missing procfile must yield zero entries") +} + +// TestDoNetstat_ParsesValidFile pins the happy-path behaviour of doNetstat so +// the IsNotExist fix cannot accidentally suppress successful parses. The +// synthetic content mimics the layout of /proc/net/tcp6: a header line +// followed by one listening IPv6 socket entry on [::]:22. +func (s *NetstatUtilTestSuite) TestDoNetstat_ParsesValidFile() { + // Listening on [::]:22 (ssh). Field layout mirrors /proc/net/tcp6: + // sl, local_address, rem_address, st, tx_queue:rx_queue, tr:tm->when, + // retrnsmt, uid, timeout, inode, ... parseSocktab discards the header + // and splits entries on whitespace, so single-spaced fields suffice. + // Local address is 32 hex chars (ipv6StrLen) : 4 hex port (0x0016=22). + // State 0x0A = Listen. UID is parsed as base-10. + fields := []string{ + "0:", + "00000000000000000000000000000000:0016", + "00000000000000000000000000000000:0000", + "0A", + "00000000:00000000", + "00:00000000", + "00000000", + "0", + "0", + "12345", + "1", + "0000000000000000", + "100", + "0", + "0", + "10", + "0", + } + content := "header line discarded by parseSocktab\n" + strings.Join(fields, " ") + "\n" + + path := filepath.Join(s.T().TempDir(), "tcp6") + require.NoError(s.T(), os.WriteFile(path, []byte(content), 0o600)) + + entries, err := doNetstat(path, func(e *SockTabEntry) bool { + return e.State == Listen + }) + + require.NoError(s.T(), err) + require.Len(s.T(), entries, 1) + assert.Equal(s.T(), uint16(22), entries[0].LocalAddr.Port) + assert.Equal(s.T(), Listen, entries[0].State) + assert.Equal(s.T(), uint32(0), entries[0].UID) +} + +// TestDoNetstat_PropagatesNonNotExistOpenError verifies that the IsNotExist +// special case is surgical: other os.Open errors (here, permission denied) +// must still surface so real breakage is reported rather than silently +// swallowed. Skipped on Windows (different permission model) and when +// running as root (chmod 0 does not deny root on Linux). +func (s *NetstatUtilTestSuite) TestDoNetstat_PropagatesNonNotExistOpenError() { + if runtime.GOOS == "windows" { + s.T().Skip("permission semantics differ on Windows") + } + if os.Geteuid() == 0 { + s.T().Skip("root bypasses file-mode permission checks") + } + + path := filepath.Join(s.T().TempDir(), "unreadable") + require.NoError(s.T(), os.WriteFile(path, []byte("irrelevant"), 0o000)) + s.T().Cleanup(func() { _ = os.Chmod(path, 0o600) }) + + _, err := doNetstat(path, NoopFilter) + + require.Error(s.T(), err, "permission errors must propagate") + assert.False(s.T(), os.IsNotExist(err), "error should not be IsNotExist") +} + +// TestDoNetstat_PropagatesParseError ensures that once the file is +// successfully opened, malformed content still yields an error rather than +// being hidden by the IsNotExist fix. +func (s *NetstatUtilTestSuite) TestDoNetstat_PropagatesParseError() { + path := filepath.Join(s.T().TempDir(), "garbage") + require.NoError(s.T(), os.WriteFile(path, []byte("header\nnot enough fields here\n"), 0o600)) + + _, err := doNetstat(path, NoopFilter) + + assert.Error(s.T(), err, "parser errors must propagate") +} From 25155137b7d903c352b3222ef8ae94bcb3b804ec Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 13 Apr 2026 23:41:04 -0500 Subject: [PATCH 2/3] fix(netstat): align comments with actual (nil, nil) return value Address CodeRabbit review: comments said "empty slice" but the code returns nil. Reword to say "(nil, nil)" for accuracy. --- pkg/netstat/netstat_util.go | 8 ++++---- pkg/netstat/netstat_util_test.go | 5 ++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/pkg/netstat/netstat_util.go b/pkg/netstat/netstat_util.go index 683945b83..c5ea4cb7b 100644 --- a/pkg/netstat/netstat_util.go +++ b/pkg/netstat/netstat_util.go @@ -248,10 +248,10 @@ func extractProcInfo(sktab []SockTabEntry) { // doNetstat - collect information about network port status. // // If path does not exist (e.g. /proc/net/tcp6 on Linux hosts booted with -// ipv6.disable=1 or built without CONFIG_IPV6), this returns an empty slice -// and a nil error so callers can degrade gracefully instead of aborting the -// entire port scan. All other errors, including permission and parse -// failures, still propagate. See issue #705. +// ipv6.disable=1 or built without CONFIG_IPV6), this returns (nil, nil) so +// callers degrade gracefully instead of aborting the entire port scan. All +// other errors, including permission and parse failures, still propagate. +// See issue #705. func doNetstat(path string, fn AcceptFn) ([]SockTabEntry, error) { f, err := os.Open(path) if err != nil { diff --git a/pkg/netstat/netstat_util_test.go b/pkg/netstat/netstat_util_test.go index 10e7a2cd6..3376cada0 100644 --- a/pkg/netstat/netstat_util_test.go +++ b/pkg/netstat/netstat_util_test.go @@ -22,9 +22,8 @@ func TestNetstatUtilSuite(t *testing.T) { // TestDoNetstat_MissingFileReturnsEmpty verifies that a missing procfile (as // seen on Linux hosts booted with ipv6.disable=1, where /proc/net/tcp6 does -// not exist) causes doNetstat to return an empty slice and nil error rather -// than propagating the os.IsNotExist error. This is the core regression -// guard for issue #705. +// not exist) causes doNetstat to return (nil, nil) rather than propagating +// the os.IsNotExist error. This is the core regression guard for issue #705. func (s *NetstatUtilTestSuite) TestDoNetstat_MissingFileReturnsEmpty() { missing := filepath.Join(s.T().TempDir(), "does-not-exist") From 80dac5dea5971b6c97d06e614db1f0febbaf2016 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 13 Apr 2026 23:53:11 -0500 Subject: [PATCH 3/3] chore(netstat): remove redundant comments from test and implementation --- pkg/netstat/netstat_util.go | 1 - pkg/netstat/netstat_util_test.go | 23 +---------------------- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/pkg/netstat/netstat_util.go b/pkg/netstat/netstat_util.go index c5ea4cb7b..439550fba 100644 --- a/pkg/netstat/netstat_util.go +++ b/pkg/netstat/netstat_util.go @@ -251,7 +251,6 @@ func extractProcInfo(sktab []SockTabEntry) { // ipv6.disable=1 or built without CONFIG_IPV6), this returns (nil, nil) so // callers degrade gracefully instead of aborting the entire port scan. All // other errors, including permission and parse failures, still propagate. -// See issue #705. func doNetstat(path string, fn AcceptFn) ([]SockTabEntry, error) { f, err := os.Open(path) if err != nil { diff --git a/pkg/netstat/netstat_util_test.go b/pkg/netstat/netstat_util_test.go index 3376cada0..fddba09b1 100644 --- a/pkg/netstat/netstat_util_test.go +++ b/pkg/netstat/netstat_util_test.go @@ -20,10 +20,6 @@ func TestNetstatUtilSuite(t *testing.T) { suite.Run(t, new(NetstatUtilTestSuite)) } -// TestDoNetstat_MissingFileReturnsEmpty verifies that a missing procfile (as -// seen on Linux hosts booted with ipv6.disable=1, where /proc/net/tcp6 does -// not exist) causes doNetstat to return (nil, nil) rather than propagating -// the os.IsNotExist error. This is the core regression guard for issue #705. func (s *NetstatUtilTestSuite) TestDoNetstat_MissingFileReturnsEmpty() { missing := filepath.Join(s.T().TempDir(), "does-not-exist") @@ -33,17 +29,8 @@ func (s *NetstatUtilTestSuite) TestDoNetstat_MissingFileReturnsEmpty() { assert.Empty(s.T(), entries, "missing procfile must yield zero entries") } -// TestDoNetstat_ParsesValidFile pins the happy-path behaviour of doNetstat so -// the IsNotExist fix cannot accidentally suppress successful parses. The -// synthetic content mimics the layout of /proc/net/tcp6: a header line -// followed by one listening IPv6 socket entry on [::]:22. func (s *NetstatUtilTestSuite) TestDoNetstat_ParsesValidFile() { - // Listening on [::]:22 (ssh). Field layout mirrors /proc/net/tcp6: - // sl, local_address, rem_address, st, tx_queue:rx_queue, tr:tm->when, - // retrnsmt, uid, timeout, inode, ... parseSocktab discards the header - // and splits entries on whitespace, so single-spaced fields suffice. - // Local address is 32 hex chars (ipv6StrLen) : 4 hex port (0x0016=22). - // State 0x0A = Listen. UID is parsed as base-10. + // Synthetic /proc/net/tcp6 entry: [::]:22 (ssh), state 0A=Listen. fields := []string{ "0:", "00000000000000000000000000000000:0016", @@ -79,11 +66,6 @@ func (s *NetstatUtilTestSuite) TestDoNetstat_ParsesValidFile() { assert.Equal(s.T(), uint32(0), entries[0].UID) } -// TestDoNetstat_PropagatesNonNotExistOpenError verifies that the IsNotExist -// special case is surgical: other os.Open errors (here, permission denied) -// must still surface so real breakage is reported rather than silently -// swallowed. Skipped on Windows (different permission model) and when -// running as root (chmod 0 does not deny root on Linux). func (s *NetstatUtilTestSuite) TestDoNetstat_PropagatesNonNotExistOpenError() { if runtime.GOOS == "windows" { s.T().Skip("permission semantics differ on Windows") @@ -102,9 +84,6 @@ func (s *NetstatUtilTestSuite) TestDoNetstat_PropagatesNonNotExistOpenError() { assert.False(s.T(), os.IsNotExist(err), "error should not be IsNotExist") } -// TestDoNetstat_PropagatesParseError ensures that once the file is -// successfully opened, malformed content still yields an error rather than -// being hidden by the IsNotExist fix. func (s *NetstatUtilTestSuite) TestDoNetstat_PropagatesParseError() { path := filepath.Join(s.T().TempDir(), "garbage") require.NoError(s.T(), os.WriteFile(path, []byte("header\nnot enough fields here\n"), 0o600))