diff --git a/x/xnet/stack-async.go b/x/xnet/stack-async.go index 62e24fe..85caf20 100644 --- a/x/xnet/stack-async.go +++ b/x/xnet/stack-async.go @@ -53,6 +53,10 @@ type StackAsync struct { lookup dns.Message dnssv netip.Addr + // ephPort drives sequential ephemeral-port allocation (see + // [StackAsync.ephemeralPort]); zero means not yet seeded. + ephPort uint32 + ntpUDP internet.StackUDPPort ntp ntp.Client @@ -352,6 +356,23 @@ func (s *StackAsync) Prand32() (randval uint32) { return randval } +// ephemeralPort returns the next port of the IANA dynamic range (49152-65535, +// RFC 6335 ยง6), allocated sequentially from a random per-stack start so a port is +// revisited only after the full 16384-port cycle. Random selection instead reuses +// a recent port at birthday-paradox rates, and a reused 4-tuple can collide with +// state the previous conversation left behind (a TIME-WAIT, a NAT flow entry) +// which swallows the new SYN. +func (s *StackAsync) ephemeralPort() uint16 { + s.mu.Lock() + if s.ephPort == 0 { + s.ephPort = s.prand32()%16384 | 1 + } + port := 49152 + s.ephPort%16384 + s.ephPort++ + s.mu.Unlock() + return uint16(port) +} + func (s *StackAsync) prand32() uint32 { /* Algorithm "xor" from p. 4 of Marsaglia, "Xorshift RNGs" */ seed := internal.Prand32(s.prng) diff --git a/x/xnet/stack-go.go b/x/xnet/stack-go.go index 496506c..6d863cd 100644 --- a/x/xnet/stack-go.go +++ b/x/xnet/stack-go.go @@ -102,8 +102,9 @@ func (s StackGo) SocketNetip(ctx context.Context, network string, family, sotype isDial := raddr.IsValid() && !raddr.Addr().IsUnspecified() if laddr.Port() == 0 { // Auto-assign an ephemeral port for both outbound dials and for listeners - // that did not request a fixed port. - laddr = netip.AddrPortFrom(laddr.Addr(), uint16(49152+s.blk.async.Prand32()%16384)) + // that did not request a fixed port. Sequential, not random: see + // [StackAsync.ephemeralPort] for why random selection breaks dial churn. + laddr = netip.AddrPortFrom(laddr.Addr(), s.blk.async.ephemeralPort()) } if laddr.Addr().IsUnspecified() { // Fill in the stack's configured address for the requested family. diff --git a/x/xnet/xnet_test.go b/x/xnet/xnet_test.go index 0b7b6b9..5312cbf 100644 --- a/x/xnet/xnet_test.go +++ b/x/xnet/xnet_test.go @@ -1196,3 +1196,39 @@ func TestEgressIP_TCPMSSAdvertisesMTU(t *testing.T) { t.Errorf("advertised MSS = %d, want %d (MTU %d - 40)", gotMSS, wantMSS, mtu) } } + +// TestEphemeralPortSequence checks no ephemeral port is reused before the whole +// 16384-port dynamic range has cycled, which is what keeps a redial off the +// teardown state (TIME-WAIT, NAT flow entries) of the conversation before it. +func TestEphemeralPortSequence(t *testing.T) { + s := new(StackAsync) + err := s.Reset(StackConfig{ + Hostname: "eph", + RandSeed: 42, + StaticAddress4: [4]byte{10, 0, 0, 50}, + MaxActiveTCPPorts: 1, + HardwareAddress: [6]byte{0xbe, 0xef, 0, 0, 0, 50}, + MTU: ethernet.MaxMTU, + ICMPQueueLimit: 2, + }) + if err != nil { + t.Fatal(err) + } + const cycle = 16384 + var seen [cycle]bool + for i := 0; i < cycle; i++ { + port := s.ephemeralPort() + if port < 49152 { + t.Fatalf("port %d below dynamic range (RFC 6335)", port) + } + idx := port - 49152 + if seen[idx] { + t.Fatalf("port %d reused after only %d allocations (want full %d cycle)", port, i, cycle) + } + seen[idx] = true + } + // The cycle is exhausted: the next allocation may legitimately reuse. + if got := s.ephemeralPort(); got < 49152 { + t.Fatalf("post-cycle port %d below dynamic range", got) + } +}