Files
coder/agent/ports_supported_internal_test.go
T
Ben Potter 8a4adeec32 fix(agent): detect IPv6-bound listening ports (#27765)
Closes https://github.com/coder/coder/issues/15675

Dev servers that bind to the IPv6 wildcard address (Next.js, Node's
default `http.Server`, Go's `net.Listen` on wildcard addresses, and
others) never showed up in the dashboard Ports panel, even though they
were listening and reachable. The agent's port scanner only called
`netstat.TCPSocks`, which reads `/proc/net/tcp` (IPv4 only); an `[::]`
dual-stack socket lives exclusively in `/proc/net/tcp6`, so the panel
reported "No open ports were detected."

Also scan `netstat.TCP6Socks` and merge the results into the existing
dedupe loop. The IPv6 scan failure is non-fatal so hosts with IPv6
disabled (missing `/proc/net/tcp6`, Windows with the v6 stack off) keep
their IPv4 results instead of the Ports panel 500ing.

Both captures below: `python3 -m http.server 5123 --bind ::` running in
the same workspace, built from main vs this branch ([full
recordings](https://github.com/coder/coder/tree/recordings/recordings/ipv6-listening-ports)).

## Before


![before](https://raw.githubusercontent.com/coder/coder/recordings/recordings/ipv6-listening-ports/before.jpg)

## After


![after](https://raw.githubusercontent.com/coder/coder/recordings/recordings/ipv6-listening-ports/after.jpg)

<details>
<summary>Debugging and decision log</summary>

- Reproduced on a dogfood workspace: `next-server` listening on `*:3000`
per `ss -tlnp`, `curl` returning 200, but `GET
/api/v2/workspaceagents/{agent}/listening-ports` returning `{"ports":
[]}`. Port 3000 (hex `0BB8`) present in `/proc/net/tcp6`, absent from
`/proc/net/tcp`.
- Confirmed `agent/ports_supported.go` only calls `netstat.TCPSocks`;
the library's `TCP6Socks` (parses `/proc/net/tcp6` on Linux,
`GetTcp6Table2` on Windows) was never referenced.
- Control test: an IPv4-bound (`0.0.0.0`) listener was detected
correctly, isolating the bug to the missing IPv6 scan.
- Made the IPv6 scan failure non-fatal after review: returning an error
would break the entire ports endpoint (HTTP 500 on every poll) on
IPv6-disabled hosts, a regression from the current behavior of "IPv4
ports only." Silent fallback matches the spirit of
`ports_unsupported.go`, which returns an empty list rather than
erroring.
- The fix lives in the OS-backed `osListeningPortsGetter` behind the
`ListeningPortsGetter` interface from #20842, so the fake used by coderd
tests is unaffected.
- Dedupe by port in the existing `seen` map covers dual-stack sockets
that appear in both tables on Windows.
- `TestOSListeningPortsGetter_IPv6` listens on `[::]:0` and asserts
detection; it skips when the host can't bind IPv6. Verified it fails
against the pre-fix code.

</details>

> 🤖 This PR was opened by [Coder
Agents](https://coder.com/docs/ai-coder/agents) on behalf of @bpmct.
2026-08-03 11:50:36 -07:00

76 lines
1.7 KiB
Go

//go:build linux || (windows && amd64)
package agent
import (
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestOSListeningPortsGetter(t *testing.T) {
t.Parallel()
uut := &osListeningPortsGetter{
cacheDuration: 1 * time.Hour,
}
l, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)
defer l.Close()
ports, err := uut.GetListeningPorts()
require.NoError(t, err)
found := false
for _, port := range ports {
// #nosec G115 - Safe conversion as TCP port numbers are within uint16 range (0-65535)
if port.Port == uint16(l.Addr().(*net.TCPAddr).Port) {
found = true
break
}
}
require.True(t, found)
// check that we cache the ports
err = l.Close()
require.NoError(t, err)
portsNew, err := uut.GetListeningPorts()
require.NoError(t, err)
require.Equal(t, ports, portsNew)
// note that it's unsafe to try to assert that a port does not exist in the response
// because the OS may reallocate the port very quickly.
}
func TestOSListeningPortsGetter_IPv6(t *testing.T) {
t.Parallel()
uut := &osListeningPortsGetter{
cacheDuration: 1 * time.Hour,
}
// Many dev servers (e.g. Next.js) bind to the IPv6 wildcard address,
// which is dual-stack and only shows up in the IPv6 socket table.
l, err := net.Listen("tcp", "[::]:0")
if err != nil {
t.Skipf("unable to listen on IPv6 wildcard address: %s", err)
}
defer l.Close()
// #nosec G115 - Safe conversion as TCP port numbers are within uint16 range (0-65535)
want := uint16(l.Addr().(*net.TCPAddr).Port)
ports, err := uut.GetListeningPorts()
require.NoError(t, err)
found := false
for _, port := range ports {
if port.Port == want {
found = true
break
}
}
require.True(t, found, "port %d not found in %v", want, ports)
}