From 36e788402a3555908d253f988755d10699d8ef94 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 1 May 2026 15:50:28 +0300 Subject: [PATCH] internal/checker: STUN codec + tests Hand-rolled RFC 5389 binding-request encoder + binding-success-response parser. Just enough to extract XOR-MAPPED-ADDRESS from a server's reply after socks5UDPAssociate returns a relay endpoint. Avoids pulling in pion/stun for ~80 LOC of encoding/binary work. Provides NewTransactionID, EncodeBindingRequest, ParseBindingResponse and six sentinel errors (ErrSTUN*) so HintFor (T11) can match specific failure modes. Full TLV attribute walking with bounds checks; supports both IPv4 and IPv6 XOR-MAPPED-ADDRESS values. Tests cover encoder layout, IPv4/IPv6 happy paths, attribute walking past unknown attributes, all error paths, sentinel uniqueness, and a real loopback round-trip via net.ListenPacket. 90.0% combined coverage (socks5+stun); stun.go funcs all >= 87%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/checker/stun.go | 184 +++++++++++++++++ internal/checker/stun_test.go | 359 ++++++++++++++++++++++++++++++++++ 2 files changed, 543 insertions(+) create mode 100644 internal/checker/stun.go create mode 100644 internal/checker/stun_test.go diff --git a/internal/checker/stun.go b/internal/checker/stun.go new file mode 100644 index 0000000..42e6fb3 --- /dev/null +++ b/internal/checker/stun.go @@ -0,0 +1,184 @@ +// Package checker — STUN binding-request codec. +// +// Hand-rolled RFC 5389 binding request encoder + binding success response +// parser. Just enough to extract XOR-MAPPED-ADDRESS — no message integrity, +// no fingerprint, no ALTERNATE-SERVER, no STUN-USE-CANDIDATE. Used after +// socks5UDPAssociate succeeds to verify the relay actually forwards UDP +// to the public Internet. +// +// We deliberately avoid pulling in pion/stun: ~80 LOC of encoding/binary, +// one attribute type. Adding ~50 KB of compiled code + a transitive +// dependency for one round trip is overkill. +package checker + +import ( + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "net" +) + +// Magic cookie defined by RFC 5389 §6. +const stunMagicCookie = 0x2112A442 + +// STUN message types and attribute types we care about. +const ( + stunBindingRequest = 0x0001 + stunBindingSuccessResponse = 0x0101 + stunAttrXORMappedAddress = 0x0020 + stunAddressFamilyIPv4 = 0x01 + stunAddressFamilyIPv6 = 0x02 +) + +// Sentinel errors so HintFor (and tests) can match specific failure modes. +var ( + ErrSTUNTooShort = errors.New("stun: response shorter than 20-byte header") + ErrSTUNBadMagicCookie = errors.New("stun: magic cookie mismatch") + ErrSTUNNotSuccess = errors.New("stun: response is not a Binding Success Response") + ErrSTUNTxIDMismatch = errors.New("stun: transaction ID mismatch") + ErrSTUNNoMappedAddress = errors.New("stun: response has no XOR-MAPPED-ADDRESS attribute") + ErrSTUNUnsupportedFamily = errors.New("stun: unsupported XOR-MAPPED-ADDRESS family") +) + +// NewTransactionID returns 12 cryptographically-random bytes suitable for +// use as a STUN transaction ID. Errors only if rand.Reader fails — caller +// should propagate (the runtime is in trouble at that point anyway). +func NewTransactionID() ([12]byte, error) { + var id [12]byte + if _, err := rand.Read(id[:]); err != nil { + return id, fmt.Errorf("stun: read random transaction id: %w", err) + } + return id, nil +} + +// EncodeBindingRequest builds a 20-byte STUN Binding Request with the given +// transaction ID. RFC 5389 allows empty request bodies. +func EncodeBindingRequest(txID [12]byte) []byte { + buf := make([]byte, 20) + binary.BigEndian.PutUint16(buf[0:2], stunBindingRequest) + binary.BigEndian.PutUint16(buf[2:4], 0) // attribute length + binary.BigEndian.PutUint32(buf[4:8], stunMagicCookie) + copy(buf[8:20], txID[:]) + return buf +} + +// ParseBindingResponse decodes a STUN Binding Success Response and returns +// the public IP+port advertised in XOR-MAPPED-ADDRESS. +// +// Validates header (length, magic, message type, transaction ID), then +// walks the TLV attribute section and extracts the first XOR-MAPPED-ADDRESS +// attribute. Other attributes are skipped (per RFC 5389 §15 the +// "comprehension-optional" range is everything ≥ 0x8000; we skip every +// non-XOR-MAPPED-ADDRESS attribute regardless, since this is the only one +// we care about). +func ParseBindingResponse(buf []byte, expectedTxID [12]byte) (net.IP, uint16, error) { + if len(buf) < 20 { + return nil, 0, ErrSTUNTooShort + } + + msgType := binary.BigEndian.Uint16(buf[0:2]) + attrLen := binary.BigEndian.Uint16(buf[2:4]) + cookie := binary.BigEndian.Uint32(buf[4:8]) + if cookie != stunMagicCookie { + return nil, 0, ErrSTUNBadMagicCookie + } + if msgType != stunBindingSuccessResponse { + return nil, 0, fmt.Errorf("%w: type=0x%04x", ErrSTUNNotSuccess, msgType) + } + + var txID [12]byte + copy(txID[:], buf[8:20]) + if txID != expectedTxID { + return nil, 0, ErrSTUNTxIDMismatch + } + + // Sanity check: attrLen must not exceed buffer. + if int(attrLen) > len(buf)-20 { + return nil, 0, fmt.Errorf("stun: attribute section length %d exceeds buffer (%d bytes after header)", attrLen, len(buf)-20) + } + + attrs := buf[20 : 20+int(attrLen)] + off := 0 + for off < len(attrs) { + // Each attribute header is 4 bytes (type + length). + if len(attrs)-off < 4 { + return nil, 0, fmt.Errorf("stun: truncated attribute header at offset %d", off) + } + aType := binary.BigEndian.Uint16(attrs[off : off+2]) + aLen := binary.BigEndian.Uint16(attrs[off+2 : off+4]) + valStart := off + 4 + valEnd := valStart + int(aLen) + if valEnd > len(attrs) { + return nil, 0, fmt.Errorf("stun: attribute at offset %d claims length %d but only %d bytes remain", off, aLen, len(attrs)-valStart) + } + + if aType == stunAttrXORMappedAddress { + ip, port, err := parseXORMappedAddress(attrs[valStart:valEnd], txID) + if err != nil { + return nil, 0, err + } + return ip, port, nil + } + + // Skip attribute including padding to next 4-byte boundary. + paddedLen := (int(aLen) + 3) &^ 3 + next := valStart + paddedLen + if next > len(attrs) { + // Padding runs past end — treat as truncation. + return nil, 0, fmt.Errorf("stun: attribute padding at offset %d runs past end", off) + } + off = next + } + + return nil, 0, ErrSTUNNoMappedAddress +} + +// parseXORMappedAddress decodes the value of an XOR-MAPPED-ADDRESS attribute. +// +// Layout (RFC 5389 §15.2): +// +// 0 1 2 3 +// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +// |0 0 0 0 0 0 0 0| Family | X-Port | +// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +// | X-Address (Variable) +// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +func parseXORMappedAddress(val []byte, txID [12]byte) (net.IP, uint16, error) { + if len(val) < 4 { + return nil, 0, fmt.Errorf("stun: XOR-MAPPED-ADDRESS truncated (got %d bytes, need ≥4)", len(val)) + } + family := val[1] + xPort := binary.BigEndian.Uint16(val[2:4]) + port := xPort ^ uint16(stunMagicCookie>>16) + + switch family { + case stunAddressFamilyIPv4: + if len(val) < 8 { + return nil, 0, fmt.Errorf("stun: XOR-MAPPED-ADDRESS IPv4 truncated (got %d bytes, need 8)", len(val)) + } + xAddr := binary.BigEndian.Uint32(val[4:8]) + addr := xAddr ^ stunMagicCookie + ip := make(net.IP, 4) + binary.BigEndian.PutUint32(ip, addr) + return ip, port, nil + + case stunAddressFamilyIPv6: + if len(val) < 20 { + return nil, 0, fmt.Errorf("stun: XOR-MAPPED-ADDRESS IPv6 truncated (got %d bytes, need 20)", len(val)) + } + // XOR with magic_cookie || transaction_id (16 bytes total). + var key [16]byte + binary.BigEndian.PutUint32(key[0:4], stunMagicCookie) + copy(key[4:16], txID[:]) + ip := make(net.IP, 16) + for i := 0; i < 16; i++ { + ip[i] = val[4+i] ^ key[i] + } + return ip, port, nil + + default: + return nil, 0, fmt.Errorf("%w: family=0x%02x", ErrSTUNUnsupportedFamily, family) + } +} diff --git a/internal/checker/stun_test.go b/internal/checker/stun_test.go new file mode 100644 index 0000000..68fda5d --- /dev/null +++ b/internal/checker/stun_test.go @@ -0,0 +1,359 @@ +package checker + +import ( + "encoding/binary" + "errors" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mkXorMappedV4 builds a synthetic STUN binding success response carrying an +// XOR-MAPPED-ADDRESS attribute for an IPv4 endpoint. Used by several test +// cases to keep byte-construction DRY. +func mkXorMappedV4(t *testing.T, ip net.IP, port uint16, txID [12]byte) []byte { + t.Helper() + ip4 := ip.To4() + require.NotNil(t, ip4, "ip must be IPv4") + + // Attribute value: 1B reserved + 1B family + 2B xPort + 4B xAddr = 8 bytes. + attrVal := make([]byte, 8) + attrVal[0] = 0 + attrVal[1] = stunAddressFamilyIPv4 + binary.BigEndian.PutUint16(attrVal[2:4], port^uint16(stunMagicCookie>>16)) + xAddr := binary.BigEndian.Uint32(ip4) ^ stunMagicCookie + binary.BigEndian.PutUint32(attrVal[4:8], xAddr) + + // Attribute header (4B) + value (8B) = 12 bytes total, no padding needed. + attr := make([]byte, 4+len(attrVal)) + binary.BigEndian.PutUint16(attr[0:2], stunAttrXORMappedAddress) + binary.BigEndian.PutUint16(attr[2:4], uint16(len(attrVal))) + copy(attr[4:], attrVal) + + // 20B header + attrs. + resp := make([]byte, 20+len(attr)) + binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse) + binary.BigEndian.PutUint16(resp[2:4], uint16(len(attr))) + binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie) + copy(resp[8:20], txID[:]) + copy(resp[20:], attr) + return resp +} + +// mkXorMappedV6 builds a synthetic response with an IPv6 XOR-MAPPED-ADDRESS. +func mkXorMappedV6(t *testing.T, ip net.IP, port uint16, txID [12]byte) []byte { + t.Helper() + ip6 := ip.To16() + require.NotNil(t, ip6, "ip must be IPv6") + require.Equal(t, net.IPv6len, len(ip6)) + + attrVal := make([]byte, 4+16) + attrVal[0] = 0 + attrVal[1] = stunAddressFamilyIPv6 + binary.BigEndian.PutUint16(attrVal[2:4], port^uint16(stunMagicCookie>>16)) + + var key [16]byte + binary.BigEndian.PutUint32(key[0:4], stunMagicCookie) + copy(key[4:16], txID[:]) + for i := 0; i < 16; i++ { + attrVal[4+i] = ip6[i] ^ key[i] + } + + attr := make([]byte, 4+len(attrVal)) + binary.BigEndian.PutUint16(attr[0:2], stunAttrXORMappedAddress) + binary.BigEndian.PutUint16(attr[2:4], uint16(len(attrVal))) + copy(attr[4:], attrVal) + + resp := make([]byte, 20+len(attr)) + binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse) + binary.BigEndian.PutUint16(resp[2:4], uint16(len(attr))) + binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie) + copy(resp[8:20], txID[:]) + copy(resp[20:], attr) + return resp +} + +func TestNewTransactionID(t *testing.T) { + a, err := NewTransactionID() + require.NoError(t, err) + b, err := NewTransactionID() + require.NoError(t, err) + assert.NotEqual(t, a, b, "two consecutive transaction IDs should differ (cryptographic randomness)") + assert.Len(t, a[:], 12) +} + +func TestEncodeBindingRequest(t *testing.T) { + txID := [12]byte{0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} + got := EncodeBindingRequest(txID) + + require.Len(t, got, 20) + assert.Equal(t, byte(0x00), got[0]) + assert.Equal(t, byte(0x01), got[1], "type LSB = 0x01 (binding request)") + assert.Equal(t, byte(0x00), got[2]) + assert.Equal(t, byte(0x00), got[3], "attribute length = 0 (empty body)") + assert.Equal(t, []byte{0x21, 0x12, 0xA4, 0x42}, got[4:8], "magic cookie") + assert.Equal(t, txID[:], got[8:20], "transaction id") +} + +func TestParseBindingResponse_HappyV4(t *testing.T) { + txID := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} + ip := net.IPv4(198, 51, 100, 1).To4() + const port uint16 = 42 + resp := mkXorMappedV4(t, ip, port, txID) + + // Sanity-check the bytes match the worked example in the task description. + // xPort = 42 ^ 0x2112 = 0x2138 + assert.Equal(t, byte(0x21), resp[20+4+2]) + assert.Equal(t, byte(0x38), resp[20+4+3]) + // xIP = 0xC6336401 ^ 0x2112A442 = 0xE721C043 + assert.Equal(t, []byte{0xE7, 0x21, 0xC0, 0x43}, resp[20+4+4:20+4+8]) + + gotIP, gotPort, err := ParseBindingResponse(resp, txID) + require.NoError(t, err) + assert.True(t, gotIP.Equal(ip), "got %s want %s", gotIP, ip) + assert.Equal(t, port, gotPort) + assert.Len(t, gotIP, 4, "IPv4 result should be 4-byte slice") +} + +func TestParseBindingResponse_HappyV6(t *testing.T) { + txID := [12]byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC} + ip := net.ParseIP("2001:db8::1") + require.NotNil(t, ip) + const port uint16 = 0x1234 + resp := mkXorMappedV6(t, ip, port, txID) + + gotIP, gotPort, err := ParseBindingResponse(resp, txID) + require.NoError(t, err) + assert.True(t, gotIP.Equal(ip), "got %s want %s", gotIP, ip) + assert.Equal(t, port, gotPort) + assert.Len(t, gotIP, 16, "IPv6 result should be 16-byte slice") +} + +func TestParseBindingResponse_MultipleUnknownAttributesThenMapped(t *testing.T) { + txID := [12]byte{9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9} + ip := net.IPv4(8, 8, 8, 8).To4() + const port uint16 = 53 + + // Build header with three attributes: + // 1. unknown type 0x8022 (SOFTWARE), value="abc" -> 3 bytes value + 1 byte pad + // 2. unknown type 0x8023, value="hi" -> 2 bytes + 2 bytes pad + // 3. real XOR-MAPPED-ADDRESS + var attrs []byte + + addAttr := func(t uint16, val []byte) { + hdr := make([]byte, 4) + binary.BigEndian.PutUint16(hdr[0:2], t) + binary.BigEndian.PutUint16(hdr[2:4], uint16(len(val))) + attrs = append(attrs, hdr...) + attrs = append(attrs, val...) + // pad to 4-byte boundary + for len(attrs)%4 != 0 { + attrs = append(attrs, 0) + } + } + addAttr(0x8022, []byte("abc")) + addAttr(0x8023, []byte("hi")) + + // XOR-MAPPED-ADDRESS attribute + xmAttrVal := make([]byte, 8) + xmAttrVal[1] = stunAddressFamilyIPv4 + binary.BigEndian.PutUint16(xmAttrVal[2:4], port^uint16(stunMagicCookie>>16)) + xAddr := binary.BigEndian.Uint32(ip) ^ stunMagicCookie + binary.BigEndian.PutUint32(xmAttrVal[4:8], xAddr) + addAttr(stunAttrXORMappedAddress, xmAttrVal) + + resp := make([]byte, 20+len(attrs)) + binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse) + binary.BigEndian.PutUint16(resp[2:4], uint16(len(attrs))) + binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie) + copy(resp[8:20], txID[:]) + copy(resp[20:], attrs) + + gotIP, gotPort, err := ParseBindingResponse(resp, txID) + require.NoError(t, err) + assert.True(t, gotIP.Equal(ip)) + assert.Equal(t, port, gotPort) +} + +func TestParseBindingResponse_Errors(t *testing.T) { + txID := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} + otherTxID := [12]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} + + t.Run("truncated", func(t *testing.T) { + buf := make([]byte, 10) + _, _, err := ParseBindingResponse(buf, txID) + assert.ErrorIs(t, err, ErrSTUNTooShort) + }) + + t.Run("bad_magic_cookie", func(t *testing.T) { + resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID) + copy(resp[4:8], []byte{0xAA, 0xBB, 0xCC, 0xDD}) + _, _, err := ParseBindingResponse(resp, txID) + assert.ErrorIs(t, err, ErrSTUNBadMagicCookie) + }) + + t.Run("not_success_request_type", func(t *testing.T) { + resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID) + binary.BigEndian.PutUint16(resp[0:2], stunBindingRequest) // 0x0001 + _, _, err := ParseBindingResponse(resp, txID) + assert.ErrorIs(t, err, ErrSTUNNotSuccess) + }) + + t.Run("not_success_error_response_type", func(t *testing.T) { + resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID) + binary.BigEndian.PutUint16(resp[0:2], 0x0111) // binding error response + _, _, err := ParseBindingResponse(resp, txID) + assert.ErrorIs(t, err, ErrSTUNNotSuccess) + }) + + t.Run("no_xor_mapped_address", func(t *testing.T) { + // 20-byte header + zero attributes + resp := make([]byte, 20) + binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse) + binary.BigEndian.PutUint16(resp[2:4], 0) + binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie) + copy(resp[8:20], txID[:]) + _, _, err := ParseBindingResponse(resp, txID) + assert.ErrorIs(t, err, ErrSTUNNoMappedAddress) + }) + + t.Run("unsupported_family", func(t *testing.T) { + resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID) + // Flip family byte (offset 20 + 4 + 1 = 25) to 0x03. + resp[25] = 0x03 + _, _, err := ParseBindingResponse(resp, txID) + assert.ErrorIs(t, err, ErrSTUNUnsupportedFamily) + }) + + t.Run("attribute_length_overflow", func(t *testing.T) { + // Build a header claiming 24 bytes of attrs, but only put one bogus + // attribute of declared length 100 inside. + resp := make([]byte, 20+24) + binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse) + binary.BigEndian.PutUint16(resp[2:4], 24) + binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie) + copy(resp[8:20], txID[:]) + // attribute: type=0x0020, length=100 (lies — only 20 bytes of value follow) + binary.BigEndian.PutUint16(resp[20:22], stunAttrXORMappedAddress) + binary.BigEndian.PutUint16(resp[22:24], 100) + _, _, err := ParseBindingResponse(resp, txID) + require.Error(t, err) + assert.Contains(t, err.Error(), "claims length 100") + }) + + t.Run("attribute_section_length_overflow", func(t *testing.T) { + // Header says attrLen=200 but buffer only has 20 bytes after header. + resp := make([]byte, 20+20) + binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse) + binary.BigEndian.PutUint16(resp[2:4], 200) + binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie) + copy(resp[8:20], txID[:]) + _, _, err := ParseBindingResponse(resp, txID) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds buffer") + }) + + t.Run("tx_id_mismatch", func(t *testing.T) { + resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID) + _, _, err := ParseBindingResponse(resp, otherTxID) + assert.ErrorIs(t, err, ErrSTUNTxIDMismatch) + }) +} + +// TestRoundTripLocalhost stands up a tiny STUN server on loopback that +// handles exactly one binding request and replies with the client's own +// address as XOR-MAPPED-ADDRESS. Verifies the encode/parse pair end-to-end +// against a real UDP socket. +func TestRoundTripLocalhost(t *testing.T) { + server, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err, "net.ListenPacket must succeed for round-trip test (real-network requirement)") + t.Cleanup(func() { _ = server.Close() }) + + // Server goroutine: read one request, parse minimally, reply. + serverDone := make(chan struct{}) + go func() { + defer close(serverDone) + buf := make([]byte, 1500) + _ = server.SetReadDeadline(time.Now().Add(3 * time.Second)) + n, from, rerr := server.ReadFrom(buf) + if rerr != nil { + return + } + if n < 20 { + return + } + // Verify it's a binding request with right magic. + if binary.BigEndian.Uint16(buf[0:2]) != stunBindingRequest { + return + } + if binary.BigEndian.Uint32(buf[4:8]) != stunMagicCookie { + return + } + var txID [12]byte + copy(txID[:], buf[8:20]) + + udpFrom, ok := from.(*net.UDPAddr) + if !ok { + return + } + reply := mkXorMappedV4(t, udpFrom.IP.To4(), uint16(udpFrom.Port), txID) + _, _ = server.WriteTo(reply, from) + }() + + // Client side. + serverAddr := server.LocalAddr().(*net.UDPAddr) + conn, err := net.DialUDP("udp", nil, serverAddr) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + txID, err := NewTransactionID() + require.NoError(t, err) + + start := time.Now() + _, err = conn.Write(EncodeBindingRequest(txID)) + require.NoError(t, err) + + require.NoError(t, conn.SetReadDeadline(time.Now().Add(time.Second))) + respBuf := make([]byte, 1500) + n, err := conn.Read(respBuf) + require.NoError(t, err) + rtt := time.Since(start) + + gotIP, gotPort, err := ParseBindingResponse(respBuf[:n], txID) + require.NoError(t, err) + + clientLocal := conn.LocalAddr().(*net.UDPAddr) + assert.True(t, gotIP.Equal(net.IPv4(127, 0, 0, 1)), "got %s want 127.0.0.1", gotIP) + assert.Equal(t, uint16(clientLocal.Port), gotPort, "port should match client local port") + assert.Less(t, rtt, 200*time.Millisecond, "loopback RTT should be under 200ms (got %s)", rtt) + + // Make sure the server goroutine exits cleanly. + select { + case <-serverDone: + case <-time.After(2 * time.Second): + t.Fatal("server goroutine did not exit") + } +} + +// Sanity: errors.Is chain works for wrapped sentinels. +func TestSentinelsAreUnique(t *testing.T) { + all := []error{ + ErrSTUNTooShort, + ErrSTUNBadMagicCookie, + ErrSTUNNotSuccess, + ErrSTUNTxIDMismatch, + ErrSTUNNoMappedAddress, + ErrSTUNUnsupportedFamily, + } + for i, a := range all { + for j, b := range all { + if i == j { + continue + } + assert.False(t, errors.Is(a, b), "sentinel %d should not match sentinel %d", i, j) + } + } +}