348 lines
10 KiB
Go
348 lines
10 KiB
Go
package netdiag
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"net/netip"
|
|
"testing"
|
|
)
|
|
|
|
// rfc5769TxID is the transaction ID from the RFC 5769 sample messages; the
|
|
// hand-computed XOR-MAPPED-ADDRESS vectors below are derived from it.
|
|
var rfc5769TxID = [12]byte{0xb7, 0xe7, 0xa7, 0x01, 0xbc, 0x34, 0xd6, 0x86, 0xfa, 0x87, 0xdf, 0xae}
|
|
|
|
// stunTestTLV encodes one attribute with its 4-byte alignment padding.
|
|
func stunTestTLV(typ uint16, val []byte) []byte {
|
|
out := make([]byte, 4, 4+len(val)+3)
|
|
binary.BigEndian.PutUint16(out[0:2], typ)
|
|
binary.BigEndian.PutUint16(out[2:4], uint16(len(val)))
|
|
out = append(out, val...)
|
|
if pad := (4 - len(val)%4) % 4; pad > 0 {
|
|
out = append(out, make([]byte, pad)...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// stunTestRaw frames body as a STUN message with a correct length field.
|
|
func stunTestRaw(typ uint16, txid [12]byte, body []byte) []byte {
|
|
out := make([]byte, stunHeaderSize, stunHeaderSize+len(body))
|
|
binary.BigEndian.PutUint16(out[0:2], typ)
|
|
binary.BigEndian.PutUint16(out[2:4], uint16(len(body)))
|
|
binary.BigEndian.PutUint32(out[4:8], stunMagicCookie)
|
|
copy(out[8:20], txid[:])
|
|
return append(out, body...)
|
|
}
|
|
|
|
func mustHex(t *testing.T, s string) []byte {
|
|
t.Helper()
|
|
b, err := hex.DecodeString(s)
|
|
if err != nil {
|
|
t.Fatalf("bad hex %q: %v", s, err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func TestSTUNEncodeParseRoundTrip(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
msg stunMessage
|
|
attrs int
|
|
}{
|
|
{
|
|
name: "bare request",
|
|
msg: stunMessage{Type: stunBindingRequest, TxID: rfc5769TxID},
|
|
attrs: 0,
|
|
},
|
|
{
|
|
name: "change request",
|
|
msg: stunMessage{Type: stunBindingRequest, TxID: rfc5769TxID, Attrs: []stunAttr{
|
|
{Type: stunAttrChangeRequest, Value: []byte{0, 0, 0, stunChangeIP | stunChangePort}},
|
|
}},
|
|
attrs: 1,
|
|
},
|
|
{
|
|
name: "response with odd-length software",
|
|
msg: stunMessage{Type: stunBindingSuccess, TxID: rfc5769TxID, Attrs: []stunAttr{
|
|
{Type: stunAttrSoftware, Value: []byte("tslink/1")},
|
|
{Type: stunAttrXORMappedAddress, Value: stunEncodeAddr(netip.MustParseAddrPort("192.0.2.1:32853"), true, rfc5769TxID)},
|
|
{Type: stunAttrOtherAddress, Value: stunEncodeAddr(netip.MustParseAddrPort("198.51.100.7:3479"), false, rfc5769TxID)},
|
|
{Type: 0x7fff, Value: []byte{1, 2, 3, 4, 5}}, // unknown, needs padding
|
|
}},
|
|
attrs: 4,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
raw := tc.msg.encode()
|
|
if len(raw)%4 != 0 {
|
|
t.Fatalf("encoded message is not 4-byte aligned: %d", len(raw))
|
|
}
|
|
if got := binary.BigEndian.Uint32(raw[4:8]); got != stunMagicCookie {
|
|
t.Fatalf("magic cookie = %#x", got)
|
|
}
|
|
got, err := parseSTUNMessage(raw)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if got.Type != tc.msg.Type || got.TxID != tc.msg.TxID {
|
|
t.Fatalf("header mismatch: got %#x/%x", got.Type, got.TxID)
|
|
}
|
|
if len(got.Attrs) != tc.attrs {
|
|
t.Fatalf("attrs = %d, want %d", len(got.Attrs), tc.attrs)
|
|
}
|
|
for i, a := range tc.msg.Attrs {
|
|
if got.Attrs[i].Type != a.Type {
|
|
t.Errorf("attr %d type = %#x, want %#x", i, got.Attrs[i].Type, a.Type)
|
|
}
|
|
if !bytes.Equal(got.Attrs[i].Value, a.Value) {
|
|
t.Errorf("attr %d value = %x, want %x", i, got.Attrs[i].Value, a.Value)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSTUNDecodeXORMappedAddress(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
attr uint16
|
|
// hand-computed payload: reserved, family, xor-port, xor-address
|
|
payload string
|
|
want string
|
|
}{
|
|
{
|
|
// 192.0.2.1 ^ 2112a442 = e112a643, port 32853 ^ 0x2112 = 0xa147
|
|
name: "v4",
|
|
attr: stunAttrXORMappedAddress,
|
|
payload: "0001a147e112a643",
|
|
want: "192.0.2.1:32853",
|
|
},
|
|
{
|
|
// same, delivered under the legacy 0x8020 attribute type
|
|
name: "v4 legacy attr",
|
|
attr: stunAttrXORMappedAddrAlt,
|
|
payload: "0001a147e112a643",
|
|
want: "192.0.2.1:32853",
|
|
},
|
|
{
|
|
// 2001:db8:1234:5678:11:2233:4455:6677 ^ (cookie || txid)
|
|
name: "v6",
|
|
attr: stunAttrXORMappedAddress,
|
|
payload: "0002a1470113a9faa5d3f179bc25f4b5bed2b9d9",
|
|
want: "[2001:db8:1234:5678:11:2233:4455:6677]:32853",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
payload := mustHex(t, tc.payload)
|
|
raw := stunTestRaw(stunBindingSuccess, rfc5769TxID, stunTestTLV(tc.attr, payload))
|
|
msg, err := parseSTUNMessage(raw)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
got, ok := msg.mappedAddr()
|
|
if !ok {
|
|
t.Fatal("no mapped address decoded")
|
|
}
|
|
if got.String() != tc.want {
|
|
t.Fatalf("mapped = %s, want %s", got, tc.want)
|
|
}
|
|
// encoding it again must reproduce the same bytes
|
|
if back := stunEncodeAddr(got, true, rfc5769TxID); !bytes.Equal(back, payload) {
|
|
t.Fatalf("re-encoded = %x, want %x", back, payload)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSTUNDecodePlainMappedAddress(t *testing.T) {
|
|
payload := mustHex(t, "00010d96c0000201") // 192.0.2.1:3478, no XOR
|
|
raw := stunTestRaw(stunBindingSuccess, rfc5769TxID, stunTestTLV(stunAttrMappedAddress, payload))
|
|
msg, err := parseSTUNMessage(raw)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
got, ok := msg.mappedAddr()
|
|
if !ok || got.String() != "192.0.2.1:3478" {
|
|
t.Fatalf("mapped = %v (ok=%v), want 192.0.2.1:3478", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestSTUNParseTolerance(t *testing.T) {
|
|
good := stunTestTLV(stunAttrXORMappedAddress, mustHex(t, "0001a147e112a643"))
|
|
|
|
tests := []struct {
|
|
name string
|
|
raw []byte
|
|
wantErr bool
|
|
wantAttrs int
|
|
wantMap string
|
|
}{
|
|
{
|
|
name: "unknown attributes are skipped",
|
|
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, concat(stunTestTLV(0x7f01, []byte{9}), good, stunTestTLV(0xfffe, []byte("xyz")))),
|
|
wantAttrs: 3,
|
|
wantMap: "192.0.2.1:32853",
|
|
},
|
|
{
|
|
name: "missing trailing padding tolerated",
|
|
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, concat(good, []byte{0x80, 0x22, 0x00, 0x03, 'a', 'b', 'c'})),
|
|
wantAttrs: 2,
|
|
wantMap: "192.0.2.1:32853",
|
|
},
|
|
{
|
|
name: "fingerprint after mapped address",
|
|
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, concat(good, stunTestTLV(stunAttrFingerprint, []byte{1, 2, 3, 4}))),
|
|
wantAttrs: 2,
|
|
wantMap: "192.0.2.1:32853",
|
|
},
|
|
{
|
|
name: "header shorter than 20 bytes",
|
|
raw: []byte{0x01, 0x01, 0x00, 0x00},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "trailing bytes beyond declared length ignored",
|
|
raw: append(stunTestRaw(stunBindingSuccess, rfc5769TxID, nil), 0x00),
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "truncated attribute value",
|
|
raw: func() []byte {
|
|
b := stunTestRaw(stunBindingSuccess, rfc5769TxID, []byte{0x00, 0x20, 0x00, 0x10, 0x00, 0x01})
|
|
return b
|
|
}(),
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "truncated attribute header",
|
|
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, []byte{0x00, 0x20, 0x00}),
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "address attribute shorter than its family requires",
|
|
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, stunTestTLV(stunAttrXORMappedAddress, mustHex(t, "0002a1470113a9fa"))),
|
|
wantAttrs: 1,
|
|
wantMap: "", // v6 payload truncated: reported as absent, not fatal
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
msg, err := parseSTUNMessage(tc.raw)
|
|
if tc.wantErr {
|
|
if err == nil {
|
|
t.Fatal("expected an error, got none")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if tc.wantAttrs != 0 && len(msg.Attrs) != tc.wantAttrs {
|
|
t.Fatalf("attrs = %d, want %d", len(msg.Attrs), tc.wantAttrs)
|
|
}
|
|
got, ok := msg.mappedAddr()
|
|
if tc.wantMap == "" {
|
|
if ok {
|
|
t.Fatalf("expected no mapped address, got %s", got)
|
|
}
|
|
return
|
|
}
|
|
if !ok || got.String() != tc.wantMap {
|
|
t.Fatalf("mapped = %v (ok=%v), want %s", got, ok, tc.wantMap)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSTUNResponseFor(t *testing.T) {
|
|
other := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}
|
|
body := stunTestTLV(stunAttrXORMappedAddress, mustHex(t, "0001a147e112a643"))
|
|
|
|
tests := []struct {
|
|
name string
|
|
raw []byte
|
|
txid [12]byte
|
|
want bool
|
|
}{
|
|
{"matching success", stunTestRaw(stunBindingSuccess, rfc5769TxID, body), rfc5769TxID, true},
|
|
{"matching error response", stunTestRaw(stunBindingError, rfc5769TxID, nil), rfc5769TxID, true},
|
|
{"txid mismatch", stunTestRaw(stunBindingSuccess, other, body), rfc5769TxID, false},
|
|
{"request is not a response", stunTestRaw(stunBindingRequest, rfc5769TxID, nil), rfc5769TxID, false},
|
|
{"garbage", []byte("not a stun packet"), rfc5769TxID, false},
|
|
{"empty", nil, rfc5769TxID, false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
msg, ok := stunResponseFor(tc.raw, tc.txid)
|
|
if ok != tc.want {
|
|
t.Fatalf("ok = %v, want %v", ok, tc.want)
|
|
}
|
|
if ok && msg == nil {
|
|
t.Fatal("accepted response but returned nil message")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSTUNBindingRequestMsg(t *testing.T) {
|
|
plain := stunBindingRequestMsg(0)
|
|
if len(plain.Attrs) != 0 {
|
|
t.Fatalf("plain request carries %d attributes", len(plain.Attrs))
|
|
}
|
|
if plain.TxID == ([12]byte{}) {
|
|
t.Fatal("transaction id was not randomised")
|
|
}
|
|
if other := stunBindingRequestMsg(0); other.TxID == plain.TxID {
|
|
t.Fatal("two requests share a transaction id")
|
|
}
|
|
cr := stunBindingRequestMsg(stunChangeIP | stunChangePort)
|
|
v, ok := cr.attr(stunAttrChangeRequest)
|
|
if !ok || len(v) != 4 || v[3] != 0x06 {
|
|
t.Fatalf("change-request attribute = %x (ok=%v)", v, ok)
|
|
}
|
|
}
|
|
|
|
func TestSTUNServerLists(t *testing.T) {
|
|
var cn, intl int
|
|
hosts := map[string]bool{}
|
|
for _, s := range DefaultSTUNServers() {
|
|
if hosts[s.Host] {
|
|
t.Errorf("duplicate host %s", s.Host)
|
|
}
|
|
hosts[s.Host] = true
|
|
if s.Name == "" {
|
|
t.Errorf("%s has no name", s.Host)
|
|
}
|
|
switch s.Region {
|
|
case RegionCN:
|
|
cn++
|
|
case RegionIntl:
|
|
intl++
|
|
default:
|
|
t.Errorf("%s has unknown region %q", s.Host, s.Region)
|
|
}
|
|
}
|
|
if cn == 0 || intl == 0 {
|
|
t.Fatalf("default list must span both regions, got cn=%d intl=%d", cn, intl)
|
|
}
|
|
for _, s := range RFC5780Servers() {
|
|
if !hosts[s.Host] {
|
|
t.Errorf("rfc5780 server %s missing from the default list", s.Host)
|
|
}
|
|
}
|
|
}
|
|
|
|
func concat(parts ...[]byte) []byte {
|
|
var out []byte
|
|
for _, p := range parts {
|
|
out = append(out, p...)
|
|
}
|
|
return out
|
|
}
|