mirror of
https://github.com/soypat/lneto.git
synced 2026-09-05 22:39:06 +00:00
fix(xnet): allocate ephemeral ports sequentially, not randomly (#180)
Ephemeral ports were drawn at random from the 16384-port dynamic range on every dial. Under connection-per-request churn the birthday paradox reuses a recently-released port after only a few dozen dials (~1% per dial at 200 outstanding-in-teardown), and a reused 4-tuple lands on whatever state the previous conversation left along the path, such as the peer's TIME-WAIT socket or a NAT's flow-table entry, which silently swallows the new SYN. Measured end to end: an HTTP client with keep-alives off against a macOS peer hit a dead dial after ~286 connections and stayed dark for ~30 seconds, exactly one 2MSL TIME-WAIT expiry. Allocate sequentially from a per-stack random starting offset instead: a port is only revisited after the full 16384-port cycle, and the random start keeps a rebooted node off the ports its previous life just used. TestEphemeralPortSequence pins the full-cycle-no-reuse property. Co-authored-by: Derek den Haas <d.haas@directcode.com>
This commit is contained in:
@@ -53,6 +53,10 @@ type StackAsync struct {
|
|||||||
lookup dns.Message
|
lookup dns.Message
|
||||||
dnssv netip.Addr
|
dnssv netip.Addr
|
||||||
|
|
||||||
|
// ephPort drives sequential ephemeral-port allocation (see
|
||||||
|
// [StackAsync.ephemeralPort]); zero means not yet seeded.
|
||||||
|
ephPort uint32
|
||||||
|
|
||||||
ntpUDP internet.StackUDPPort
|
ntpUDP internet.StackUDPPort
|
||||||
ntp ntp.Client
|
ntp ntp.Client
|
||||||
|
|
||||||
@@ -352,6 +356,23 @@ func (s *StackAsync) Prand32() (randval uint32) {
|
|||||||
return randval
|
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 {
|
func (s *StackAsync) prand32() uint32 {
|
||||||
/* Algorithm "xor" from p. 4 of Marsaglia, "Xorshift RNGs" */
|
/* Algorithm "xor" from p. 4 of Marsaglia, "Xorshift RNGs" */
|
||||||
seed := internal.Prand32(s.prng)
|
seed := internal.Prand32(s.prng)
|
||||||
|
|||||||
+3
-2
@@ -102,8 +102,9 @@ func (s StackGo) SocketNetip(ctx context.Context, network string, family, sotype
|
|||||||
isDial := raddr.IsValid() && !raddr.Addr().IsUnspecified()
|
isDial := raddr.IsValid() && !raddr.Addr().IsUnspecified()
|
||||||
if laddr.Port() == 0 {
|
if laddr.Port() == 0 {
|
||||||
// Auto-assign an ephemeral port for both outbound dials and for listeners
|
// Auto-assign an ephemeral port for both outbound dials and for listeners
|
||||||
// that did not request a fixed port.
|
// that did not request a fixed port. Sequential, not random: see
|
||||||
laddr = netip.AddrPortFrom(laddr.Addr(), uint16(49152+s.blk.async.Prand32()%16384))
|
// [StackAsync.ephemeralPort] for why random selection breaks dial churn.
|
||||||
|
laddr = netip.AddrPortFrom(laddr.Addr(), s.blk.async.ephemeralPort())
|
||||||
}
|
}
|
||||||
if laddr.Addr().IsUnspecified() {
|
if laddr.Addr().IsUnspecified() {
|
||||||
// Fill in the stack's configured address for the requested family.
|
// Fill in the stack's configured address for the requested family.
|
||||||
|
|||||||
@@ -1196,3 +1196,39 @@ func TestEgressIP_TCPMSSAdvertisesMTU(t *testing.T) {
|
|||||||
t.Errorf("advertised MSS = %d, want %d (MTU %d - 40)", gotMSS, wantMSS, mtu)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user