mirror of
https://github.com/coder/coder.git
synced 2026-10-16 06:37:58 +08:00
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  ## After  <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.
89 lines
2.3 KiB
Go
89 lines
2.3 KiB
Go
//go:build linux || (windows && amd64)
|
|
|
|
package agent
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/cakturk/go-netstat/netstat"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
type osListeningPortsGetter struct {
|
|
cacheDuration time.Duration
|
|
mut sync.Mutex
|
|
ports []codersdk.WorkspaceAgentListeningPort
|
|
mtime time.Time
|
|
}
|
|
|
|
func (lp *osListeningPortsGetter) GetListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
|
|
lp.mut.Lock()
|
|
defer lp.mut.Unlock()
|
|
|
|
if time.Since(lp.mtime) < lp.cacheDuration {
|
|
// copy
|
|
ports := make([]codersdk.WorkspaceAgentListeningPort, len(lp.ports))
|
|
copy(ports, lp.ports)
|
|
return ports, nil
|
|
}
|
|
|
|
acceptListening := func(s *netstat.SockTabEntry) bool {
|
|
return s.State == netstat.Listen
|
|
}
|
|
|
|
tabs, err := netstat.TCPSocks(acceptListening)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("scan listening ports: %w", err)
|
|
}
|
|
|
|
// Include IPv6 listeners too. Many dev servers (e.g. Next.js, Node's
|
|
// default http.Server) bind to the IPv6 wildcard address "::", which is
|
|
// a dual-stack socket that also accepts IPv4 connections, but only shows
|
|
// up in the IPv6 socket table (/proc/net/tcp6), not /proc/net/tcp.
|
|
//
|
|
// The IPv6 scan fails on systems with IPv6 disabled (e.g. missing
|
|
// /proc/net/tcp6 on Linux). Fall back to the IPv4 results instead of
|
|
// failing the whole scan, otherwise the ports UI breaks entirely on
|
|
// such systems.
|
|
tabs6, err := netstat.TCP6Socks(acceptListening)
|
|
if err == nil {
|
|
tabs = append(tabs, tabs6...)
|
|
}
|
|
|
|
seen := make(map[uint16]struct{}, len(tabs))
|
|
ports := []codersdk.WorkspaceAgentListeningPort{}
|
|
for _, tab := range tabs {
|
|
if tab.LocalAddr == nil {
|
|
continue
|
|
}
|
|
|
|
// Don't include ports that we've already seen. This can happen on
|
|
// Windows, and maybe on Linux if you're using a shared listener socket.
|
|
if _, ok := seen[tab.LocalAddr.Port]; ok {
|
|
continue
|
|
}
|
|
seen[tab.LocalAddr.Port] = struct{}{}
|
|
|
|
procName := ""
|
|
if tab.Process != nil {
|
|
procName = tab.Process.Name
|
|
}
|
|
ports = append(ports, codersdk.WorkspaceAgentListeningPort{
|
|
ProcessName: procName,
|
|
Network: "tcp",
|
|
Port: tab.LocalAddr.Port,
|
|
})
|
|
}
|
|
|
|
lp.ports = ports
|
|
lp.mtime = time.Now()
|
|
|
|
// copy
|
|
ports = make([]codersdk.WorkspaceAgentListeningPort, len(lp.ports))
|
|
copy(ports, lp.ports)
|
|
return ports, nil
|
|
}
|