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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user