From 741de36419541a991d0783aff4d5533db385f7bb Mon Sep 17 00:00:00 2001 From: Yassine El Bouchaibi Date: Wed, 8 Apr 2026 21:46:34 -0400 Subject: [PATCH] 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") +}