Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions packages/envd/internal/port/forward.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,11 @@ func NewForwarder(
scannerSub := scanner.AddSubscriber(
"port-forwarder",
// We only want to forward ports that are actively listening on localhost.
// "::" is included for IPv6 wildcard sockets (:::PORT): on a dual-stack
// kernel many frameworks bind "::" rather than "0.0.0.0", so gopsutil
// reports Laddr.IP = "::" for those connections.
&ScannerFilter{
IPs: []string{"127.0.0.1", "localhost", "::1"},
IPs: []string{"127.0.0.1", "localhost", "::1", "::"},
State: "LISTEN",
},
)
Expand Down Expand Up @@ -124,7 +127,10 @@ func (f *Forwarder) StartForwarding(ctx context.Context) {
// Let's refresh our map of currently forwarded ports and mark the currently opened ones with the "FORWARD" state.
// This will make sure we won't delete them later.
for _, p := range procs {
key := fmt.Sprintf("%d-%d", p.Pid, p.Laddr.Port)
// Include IP in the key so that a service listening on both
// 127.0.0.1:PORT and ::1:PORT gets two independent socats instead
// of the second entry silently overwriting the first.
key := fmt.Sprintf("%d-%d-%s", p.Pid, p.Laddr.Port, p.Laddr.IP)
Comment thread
AdaAibaby marked this conversation as resolved.

// We check if the opened port is in our map of forwarded ports.
val, portOk := f.ports[key]
Expand All @@ -133,10 +139,18 @@ func (f *Forwarder) StartForwarding(ctx context.Context) {
// The actual socat process that handles forwarding should be running from the last iteration.
val.state = PortStateForward
} else {
// A "::" wildcard socket accepts both IPv4 and IPv6; connect
// via IPv4 so socat resolves "127.0.0.1" without relying on
// /etc/hosts containing "::1 localhost" (missing on minimal images).
family := familyToIPVersion(p.Family)
if p.Laddr.IP == "::" {
family = 4
}

f.logger.Debug().
Str("ip", p.Laddr.IP).
Uint32("port", p.Laddr.Port).
Uint32("family", familyToIPVersion(p.Family)).
Uint32("family", family).
Str("state", p.Status).
Msg("Detected new opened port on localhost that is not forwarded")

Expand All @@ -145,7 +159,7 @@ func (f *Forwarder) StartForwarding(ctx context.Context) {
pid: p.Pid,
port: p.Laddr.Port,
state: PortStateForward,
family: familyToIPVersion(p.Family),
family: family,
}
f.ports[key] = ptf
f.startPortForwarding(ctx, ptf)
Expand All @@ -167,12 +181,20 @@ func (f *Forwarder) StartForwarding(ctx context.Context) {

func (f *Forwarder) startPortForwarding(ctx context.Context, p *PortToForward) {
// https://unix.stackexchange.com/questions/311492/redirect-application-listening-on-localhost-to-listening-on-external-interface
// socat -d -d TCP4-LISTEN:4000,bind=169.254.0.21,fork TCP4:localhost:4000
// socat -d -d TCP4-LISTEN:4000,bind=169.254.0.21,fork TCP4:127.0.0.1:4000
// reuseaddr is used to fix the "Address already in use" error when restarting socat quickly.
//
// Use literal addresses rather than "localhost" so that socat's name
// resolution does not depend on /etc/hosts containing "::1 localhost",
// which is absent on Alpine and many minimal base images.
backendAddr := "127.0.0.1"
if p.family == 6 {
backendAddr = "[::1]"
}
cmd := exec.CommandContext(ctx,
"socat", "-d", "-d", "-d",
fmt.Sprintf("TCP4-LISTEN:%v,bind=%s,reuseaddr,fork", p.port, f.sourceIP.To4()),
fmt.Sprintf("TCP%d:localhost:%v", p.family, p.port),
fmt.Sprintf("TCP%d:%s:%v", p.family, backendAddr, p.port),
)

cgroupFD, ok := f.cgroupManager.GetFileDescriptor(cgroups.ProcessTypeSocat)
Expand Down
118 changes: 111 additions & 7 deletions packages/envd/internal/port/forward_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,33 @@ package port

import (
"context"
"syscall"
"testing"
"time"

gopsnet "github.com/shirou/gopsutil/v4/net"

"github.com/rs/zerolog"

"github.com/e2b-dev/infra/packages/envd/internal/services/cgroups"
)

// newTestForwarder creates a Forwarder suitable for unit tests. It uses a
// NoopManager so startPortForwarding can be called without panicking (socat
// itself may not be installed and will simply fail to start, which is fine —
// the ports map entry is inserted before the exec attempt).
func newTestForwarder(scanner *Scanner) *Forwarder {
l := zerolog.Nop()

return &Forwarder{
logger: &l,
ports: make(map[string]*PortToForward),
sourceIP: defaultGatewayIP,
scannerSubscriber: scanner.AddSubscriber("test", nil),
cgroupManager: cgroups.NewNoopManager(),
}
}

// TestStartForwarding_StopsOnClosedMessages pins the defensive guard on the
// scan-result receive. Nothing closes Messages today, but a one-value receive
// would degrade badly if that ever changed: a closed channel is permanently
Expand All @@ -16,14 +37,8 @@ import (
func TestStartForwarding_StopsOnClosedMessages(t *testing.T) {
t.Parallel()

l := zerolog.Nop()
scanner := NewScanner(time.Hour)
f := &Forwarder{
logger: &l,
ports: make(map[string]*PortToForward),
sourceIP: defaultGatewayIP,
scannerSubscriber: scanner.AddSubscriber("test", nil),
}
f := newTestForwarder(scanner)

returned := make(chan struct{})
go func() {
Expand All @@ -41,3 +56,92 @@ func TestStartForwarding_StopsOnClosedMessages(t *testing.T) {
t.Fatal("StartForwarding did not stop after Messages was closed")
}
}

// TestStartForwarding_WildcardIPv6_NormalizedToFamilyFour verifies that a "::"
// wildcard listener is assigned family=4 so socat connects via 127.0.0.1 (Fix A
// normalization + Fix C). On a dual-stack kernel, frameworks like gRPC bind "::"
// by default; routing them through IPv4 avoids /etc/hosts resolution of "::1
// localhost" on minimal images.
func TestStartForwarding_WildcardIPv6_NormalizedToFamilyFour(t *testing.T) {
t.Parallel()

scanner := NewScanner(time.Hour)
f := newTestForwarder(scanner)

returned := make(chan struct{})
go func() {
defer close(returned)
f.StartForwarding(context.Background())
}()

f.scannerSubscriber.Messages <- []gopsnet.ConnectionStat{
{Pid: 42, Family: syscall.AF_INET6, Status: "LISTEN",
Laddr: gopsnet.Addr{IP: "::", Port: 8080}},
}
close(f.scannerSubscriber.Messages)

select {
case <-returned:
case <-time.After(time.Second):
t.Fatal("StartForwarding did not stop")
}

// The goroutine has exited so f.ports is safe to read without the lock.
key := "42-8080-::"
ptf, ok := f.ports[key]
if !ok {
t.Fatalf("expected ports[%q] but got keys %v", key, portKeys(f.ports))
}
if ptf.family != 4 {
t.Errorf("family = %d, want 4 (wildcard :: must be normalized to IPv4)", ptf.family)
}
}

// TestStartForwarding_DualStackKey_TwoEntries verifies that a service listening
// on both 127.0.0.1:PORT and ::1:PORT receives two independent port-forward
// entries (Fix B). Before the fix the key omitted the IP, so the second entry
// overwrote the first and only one socat was started.
func TestStartForwarding_DualStackKey_TwoEntries(t *testing.T) {
t.Parallel()

scanner := NewScanner(time.Hour)
f := newTestForwarder(scanner)

returned := make(chan struct{})
go func() {
defer close(returned)
f.StartForwarding(context.Background())
}()

f.scannerSubscriber.Messages <- []gopsnet.ConnectionStat{
{Pid: 100, Family: syscall.AF_INET, Status: "LISTEN",
Laddr: gopsnet.Addr{IP: "127.0.0.1", Port: 9090}},
{Pid: 100, Family: syscall.AF_INET6, Status: "LISTEN",
Laddr: gopsnet.Addr{IP: "::1", Port: 9090}},
}
close(f.scannerSubscriber.Messages)

select {
case <-returned:
case <-time.After(time.Second):
t.Fatal("StartForwarding did not stop")
}

wantKeys := []string{"100-9090-127.0.0.1", "100-9090-::1"}
for _, k := range wantKeys {
if _, ok := f.ports[k]; !ok {
t.Errorf("expected ports[%q] but got keys %v", k, portKeys(f.ports))
}
}
if len(f.ports) != 2 {
t.Errorf("len(ports) = %d, want 2; keys: %v", len(f.ports), portKeys(f.ports))
}
}

func portKeys(m map[string]*PortToForward) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
61 changes: 61 additions & 0 deletions packages/envd/internal/port/scanfilter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package port

import (
"testing"

"github.com/shirou/gopsutil/v4/net"
"github.com/stretchr/testify/assert"
)

func TestScannerFilter_Match(t *testing.T) {
t.Parallel()

filter := &ScannerFilter{
IPs: []string{"127.0.0.1", "localhost", "::1", "::"},
State: "LISTEN",
}

tests := []struct {
name string
conn net.ConnectionStat
want bool
}{
{
name: "IPv4 loopback matches",
conn: net.ConnectionStat{Laddr: net.Addr{IP: "127.0.0.1"}, Status: "LISTEN"},
want: true,
},
{
name: "IPv6 loopback matches",
conn: net.ConnectionStat{Laddr: net.Addr{IP: "::1"}, Status: "LISTEN"},
want: true,
},
{
name: "IPv6 wildcard matches",
conn: net.ConnectionStat{Laddr: net.Addr{IP: "::"}, Status: "LISTEN"},
want: true,
},
{
name: "external IP does not match",
conn: net.ConnectionStat{Laddr: net.Addr{IP: "10.0.0.1"}, Status: "LISTEN"},
want: false,
},
{
name: "wrong state does not match",
conn: net.ConnectionStat{Laddr: net.Addr{IP: "127.0.0.1"}, Status: "ESTABLISHED"},
want: false,
},
{
name: "IPv4 wildcard (0.0.0.0) does not match",
conn: net.ConnectionStat{Laddr: net.Addr{IP: "0.0.0.0"}, Status: "LISTEN"},
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, filter.Match(&tt.conn))
})
}
}
2 changes: 1 addition & 1 deletion packages/envd/pkg/version.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package pkg

const Version = "0.6.13" // x-release-please-version
const Version = "0.6.14" // x-release-please-version