Skip to content

fix(envd/port): detect :::PORT wildcard listeners and fix dual-stack key collision - #3588

Open
AdaAibaby wants to merge 4 commits into
e2b-dev:mainfrom
AdaAibaby:fix/envd-port-forwarder-ipv6
Open

fix(envd/port): detect :::PORT wildcard listeners and fix dual-stack key collision#3588
AdaAibaby wants to merge 4 commits into
e2b-dev:mainfrom
AdaAibaby:fix/envd-port-forwarder-ipv6

Conversation

@AdaAibaby

@AdaAibaby AdaAibaby commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #3586.

Relationship to #3587: #3587 (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. This PR fixes the three port-forwarder bugs
independently, so they are correct regardless of the IPv6 kernel setting and without depending on #3587 landing first. Either PR can merge in any order.

What changed

Three bugs in packages/envd/internal/port/forward.go caused services that bind to an IPv6 wildcard address (:::PORT) to be silently unreachable from outside the sandbox.

Bug A — :: wildcard not in scan filter (forward.go:79)

The ScannerFilter listed only 127.0.0.1, localhost, and ::1. On a dual-stack kernel, gRPC and other frameworks default to :::PORT; gopsutil reports Laddr.IP = "::" for those sockets. "::" was not in the filter → port never detected → no socat → service inaccessible.

Fix: add "::" to IPs. Wildcard :: listeners are then normalized to family=4 so socat connects via 127.0.0.1 rather than [::1] (see Bug C).

Bug B — port key collision drops one socat (forward.go:127)

Key was fmt.Sprintf("%d-%d", pid, port). Two connections with the same PID and port (one AF_INET/127.0.0.1, one AF_INET6/::1) produced the same key; the second overwrote the first. Only one socat started — whichever family /proc/net/tcp vs /proc/net/tcp6 returned first. The other address was silently unreachable.

Fix: fmt.Sprintf("%d-%d-%s", pid, port, ip) — include IP in the key.

Bug C — TCP6:localhost fails on minimal images (forward.go:175)

Backend address was TCP%d:localhost:PORT. For family=6 this is TCP6:localhost:PORT, which requires /etc/hosts to contain ::1 localhost. Alpine and many stripped images only have 127.0.0.1 localhost, so socat's name resolution returns no AAAA record and the connection fails.

Fix: literal addresses (127.0.0.1 / [::1]) instead of "localhost".

Tests

  • scanfilter_test.go (new): table-driven ScannerFilter.Match tests, including "::" wildcard and wrong-state cases.
  • forward_test.go: two new tests
    • TestStartForwarding_WildcardIPv6_NormalizedToFamilyFour — pushes a :: connection through the scan loop, asserts family=4 in the ports map.
    • TestStartForwarding_DualStackKey_TwoEntries — pushes same-PID, same-port 127.0.0.1 + ::1 connections, asserts two distinct map entries.

Envd version bump

0.6.13 → 0.6.14

…key collision

Fixes e2b-dev#3586.

Three bugs caused services binding to IPv6 wildcard addresses (:::PORT) to
be silently unreachable from outside the sandbox.

Bug A — "::" wildcard not in scan filter
The ScannerFilter for the port forwarder listed only "127.0.0.1", "localhost",
and "::1" as accepted local addresses. On a dual-stack kernel, gRPC and other
frameworks default to listening on "::" (IPv6 wildcard), which gopsutil reports
as Laddr.IP = "::". Because "::" was absent from the filter the port was never
detected and no socat was started.

Fix: add "::" to the filter IPs.  A wildcard "::" listener is then normalized to
family=4 so socat connects via 127.0.0.1 rather than [::1], avoiding /etc/hosts
AAAA-lookup failures on Alpine and other minimal images where "::1 localhost" is
absent.

Bug B — port key collision drops one socat for dual-stack services
The map key was fmt.Sprintf("%d-%d", pid, port), so two connections sharing the
same PID and port (one AF_INET on 127.0.0.1, one AF_INET6 on ::1) produced the
same key. The second entry silently overwrote the first in the ports map; only
one socat was started, for whichever family /proc/net/tcp vs /proc/net/tcp6
happened to return first.

Fix: include the local IP in the key → fmt.Sprintf("%d-%d-%s", pid, port, ip).

Bug C — TCP6:localhost fails on minimal images
startPortForwarding constructed the socat backend address as
TCP%d:localhost:PORT. For family=6 this is TCP6:localhost:PORT, which requires
/etc/hosts to contain "::1 localhost". Alpine and many stripped base images only
have "127.0.0.1 localhost", so the resolution returns no AAAA record and socat
fails.

Fix: use literal addresses (127.0.0.1 / [::1]) instead of "localhost".

Note: fixing e2b-dev#3585 (ipv6.disable=1) eliminates the dual-stack binding condition
that triggers Bugs A and B, but Bugs B and C are independently correct to fix
regardless of the IPv6 kernel setting.

Bump envd version 0.6.13 → 0.6.14 (behavioral change per CLAUDE.md).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b162b72c92

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/envd/internal/port/forward.go
adababys and others added 2 commits August 19, 2026 15:03
When an old envd binary exports port-forwards the Key field uses
"<pid>-<port>" (one dash).  The new scan loop already writes
"<pid>-<port>-<ip>" so the imported entry is never matched, gets
marked DELETE on the first scan cycle, and the restored socat is
killed — dropping all forwarded connections across a live upgrade.

Introduce normalizeForwardKey() in ImportForwards: a single-dash key
has its IP reconstructed from the Family field (4→127.0.0.1, 6→::1),
matching exactly what the scan loop will produce for that socket.
New-format keys (two dashes) pass through unchanged.

Update tests to use the new key format throughout and add
TestForwarder_ImportForwards_NormalizesOldKey covering the upgrade path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

envd/port-forwarder: IPv6 wildcard (:::PORT) listeners silently ignored; dual-stack port key collision drops one socat

2 participants