envd/port-forwarder: IPv6 wildcard (:::PORT) listeners silently ignored; dual-stack port key collision drops one socat
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 1.6k
- Forks
- 438
- PR merge metrics
- No merged PRs in 30d
Description
Symptom
Case A — a user starts a gRPC server (or any modern framework that defaults to IPv6 wildcard binding) inside a sandbox. The service is running, but the port is never accessible from outside:
# inside sandbox
python -m grpc_tools ... &
ss -tlnp | grep 50051
# LISTEN 0 4096 *:50051 *:* ← kernel reports dual-stack :::50051
# from outside — never connects
grpcurl -plaintext <sandbox-host>:50051 list
# timeout / connection refused
Case B — a service listening on both 127.0.0.1:PORT and ::1:PORT gets only one socat. Which address socat connects to depends on the nondeterministic order that /proc/net/tcp and /proc/net/tcp6 entries are returned. If socat picks the wrong family the port becomes inaccessible with no error.
Root cause
Bug A — :::PORT wildcard not in the scan filter
packages/envd/internal/port/forward.go:79:
&ScannerFilter{
IPs: []string{"127.0.0.1", "localhost", "::1"},
State: "LISTEN",
},
packages/envd/internal/port/scanfilter.go:20:
ipMatch := slices.Contains(sf.IPs, proc.Laddr.IP)
net.Connections("tcp") reads both /proc/net/tcp and /proc/net/tcp6 (confirmed by the comment in scan.go:42). For an IPv6 wildcard socket, the kernel reports Laddr.IP = "::". "::" is not in the filter list → the port is never detected → no socat is started → service is inaccessible.
With IPv6 enabled in the guest (ipv6.disable=0, see #3585), frameworks that auto-select the bind address on a dual-stack kernel produce :::PORT rather than 0.0.0.0:PORT:
| Framework / runtime | Default bind | Laddr.IP reported |
Detected? |
|---|---|---|---|
| gRPC-Go, gRPC-Python | :::PORT |
"::" |
❌ |
Node.js net.createServer().listen(PORT) on dual-stack |
:::PORT |
"::" |
❌ |
Python socket.AF_INET6 with IPV6_V6ONLY=0 |
:::PORT |
"::" |
❌ |
Python uvicorn --host 0.0.0.0 |
0.0.0.0:PORT |
(IPv4 only) | ❌ (expected) |
127.0.0.1:PORT |
127.0.0.1:PORT |
"127.0.0.1" |
✅ |
::1:PORT |
::1:PORT |
"::1" |
✅ |
Bug B — port key collision drops one socat for dual-stack services
packages/envd/internal/port/forward.go:127:
key := fmt.Sprintf("%d-%d", p.Pid, p.Laddr.Port)
When a service listens on both 127.0.0.1:PORT (AF_INET) and ::1:PORT (AF_INET6), both ConnectionStat entries share the same PID and port number. Both pass the filter. The scan loop processes them sequentially:
Iteration 1: key="PID-PORT" not in map → create PortToForward{family=4}, start socat TCP4:localhost:PORT
Iteration 2: key="PID-PORT" already in map → just mark state=FORWARD, skip startPortForwarding
Only one socat is started, for whichever family /proc/net/tcp vs /proc/net/tcp6 returns first. If the app only responds on the skipped family the port silently breaks. The entry order is not guaranteed.
Bug C (secondary) — TCP6:localhost resolution fails on minimal images
packages/envd/internal/port/forward.go:175:
fmt.Sprintf("TCP%d:localhost:%v", p.family, p.port)
For family=6 socat uses TCP6:localhost:PORT. This requires /etc/hosts to contain ::1 localhost. Alpine Linux and many stripped base images ship only 127.0.0.1 localhost, so socat's name resolution returns no AAAA entry → connection fails → ::1 listeners are inaccessible even though the filter correctly detected them.
Why it matters
- gRPC is the most common affected framework — its default listener is
:::PORTon any dual-stack host - FastAPI / uvicorn with
--host ::, Gonet.Listen("tcp", ":PORT")on dual-stack kernels, all produce:::PORT - These services start and appear healthy inside the sandbox, but are completely unreachable from outside — no error, just a silent port forwarding gap
Proposed fix
Fix A — add "::" to the filter and normalize wildcard connections to the IPv4 path:
// forward.go:79
IPs: []string{"127.0.0.1", "localhost", "::1", "::"},
// forward.go:148 — wildcard binds accept both families; connect via IPv4 localhost
family: func() uint32 {
if p.Laddr.IP == "::" {
return 4
}
return familyToIPVersion(p.Family)
}(),
Fix B — include the IP in the port key to avoid collision:
// Before
key := fmt.Sprintf("%d-%d", p.Pid, p.Laddr.Port)
// After
key := fmt.Sprintf("%d-%d-%s", p.Pid, p.Laddr.Port, p.Laddr.IP)
Fix C — use literal addresses instead of hostname resolution:
// Before
fmt.Sprintf("TCP%d:localhost:%v", p.family, p.port)
// After
backendAddr := "127.0.0.1"
if p.family == 6 {
backendAddr = "[::1]"
}
fmt.Sprintf("TCP%d:%s:%v", p.family, backendAddr, p.port)
Note: fixing #3585 (ipv6.disable=1) eliminates the condition that causes dual-stack :::PORT binding in the first place — on an IPv4-only kernel the same bind("::") call falls back to 0.0.0.0. However Bugs B and C are independently correctness issues that should be fixed regardless of the IPv6 kernel setting.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with packages/envd/internal/port/forward.go and scanfilter.go, then read the connection handling described in scan.go. Reproduce listeners on ::, 127.0.0.1, and ::1 and trace detection, keying, and socat backend selection. Done means IPv6 wildcard services are detected, dual-stack entries are not collapsed, and IPv6 loopback forwarding works without hostname resolution.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go, linux
- Domain
- infrastructure, networking
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100