Parse Candidate Extensions (RFC5245)

- Rewrote `UnmarshalCandidate` to better align with RFC5245.
- Added Candidate `Extensions` and `GetExtension`.
- Updated `Equal` and `Marshal` to accommodate these changes.
- New Type `CandidateExtension` to handle.
This commit is contained in:
Joe Turki
2025-01-14 12:00:59 -06:00
parent abdc0cadec
commit ab6e243686
4 changed files with 1242 additions and 79 deletions

View File

@@ -52,12 +52,28 @@ type Candidate interface {
// candidate, which is useful for diagnostics and other purposes
RelatedAddress() *CandidateRelatedAddress
// Extensions returns a copy of all extension attributes associated with the ICECandidate.
// In the order of insertion, *(key value).
// Extension attributes are defined in RFC 5245, Section 15.1:
// https://datatracker.ietf.org/doc/html/rfc5245#section-15.1
//.
Extensions() []CandidateExtension
// GetExtension returns the value of the extension attribute associated with the ICECandidate.
// Extension attributes are defined in RFC 5245, Section 15.1:
// https://datatracker.ietf.org/doc/html/rfc5245#section-15.1
//.
GetExtension(key string) (value CandidateExtension, ok bool)
String() string
Type() CandidateType
TCPType() TCPType
Equal(other Candidate) bool
// DeepEqual same as Equal, But it also compares the candidate extensions.
DeepEqual(other Candidate) bool
Marshal() string
addr() net.Addr

View File

@@ -45,6 +45,7 @@ type candidateBase struct {
remoteCandidateCaches map[AddrPort]Candidate
isLocationTracked bool
extensions []CandidateExtension
}
// Done implements context.Context
@@ -406,6 +407,7 @@ func (c *candidateBase) Equal(other Candidate) bool {
return false
}
}
return c.NetworkType() == other.NetworkType() &&
c.Type() == other.Type() &&
c.Address() == other.Address() &&
@@ -414,6 +416,11 @@ func (c *candidateBase) Equal(other Candidate) bool {
c.RelatedAddress().Equal(other.RelatedAddress())
}
// DeepEqual is same as Equal but also compares the extensions
func (c *candidateBase) DeepEqual(other Candidate) bool {
return c.Equal(other) && c.extensionsEqual(other.Extensions())
}
// String makes the candidateBase printable
func (c *candidateBase) String() string {
return fmt.Sprintf("%s %s %s%s (resolved: %v)", c.NetworkType(), c.Type(), net.JoinHostPort(c.Address(), strconv.Itoa(c.Port())), c.relatedAddress, c.resolvedAddr)
@@ -496,10 +503,6 @@ func (c *candidateBase) Marshal() string {
c.Port(),
c.Type())
if c.tcpType != TCPTypeUnspecified {
val += fmt.Sprintf(" tcptype %s", c.tcpType.String())
}
if r := c.RelatedAddress(); r != nil && r.Address != "" && r.Port != 0 {
val = fmt.Sprintf("%s raddr %s rport %d",
val,
@@ -507,92 +510,468 @@ func (c *candidateBase) Marshal() string {
r.Port)
}
extensions := c.marshalExtensions()
if extensions != "" {
val = fmt.Sprintf("%s %s", val, extensions)
}
return val
}
// UnmarshalCandidate creates a Candidate from its string representation
func UnmarshalCandidate(raw string) (Candidate, error) {
split := strings.Fields(raw)
// Foundation not specified: not RFC 8445 compliant but seen in the wild
if len(raw) != 0 && raw[0] == ' ' {
split = append([]string{" "}, split...)
}
if len(split) < 8 {
return nil, fmt.Errorf("%w (%d)", errAttributeTooShortICECandidate, len(split))
// CandidateExtension represents a single candidate extension
// as defined in https://tools.ietf.org/html/rfc5245#section-15.1
// .
type CandidateExtension struct {
Key string
Value string
}
func (c *candidateBase) Extensions() []CandidateExtension {
// IF Extensions were not parsed using UnmarshalCandidate
// For backwards compatibility when the TCPType is set manually
if len(c.extensions) == 0 && c.TCPType() != TCPTypeUnspecified {
return []CandidateExtension{{
Key: "tcptype",
Value: c.TCPType().String(),
}}
}
// Foundation
foundation := split[0]
extensions := make([]CandidateExtension, len(c.extensions))
copy(extensions, c.extensions)
// Component
rawComponent, err := strconv.ParseUint(split[1], 10, 16)
if err != nil {
return nil, fmt.Errorf("%w: %v", errParseComponent, err) //nolint:errorlint
}
component := uint16(rawComponent)
return extensions
}
// Protocol
protocol := split[2]
// Get returns the value of the given key if it exists.
func (c *candidateBase) GetExtension(key string) (CandidateExtension, bool) {
extension := CandidateExtension{Key: key}
// Priority
priorityRaw, err := strconv.ParseUint(split[3], 10, 32)
if err != nil {
return nil, fmt.Errorf("%w: %v", errParsePriority, err) //nolint:errorlint
}
priority := uint32(priorityRaw)
for i := range c.extensions {
if c.extensions[i].Key == key {
extension.Value = c.extensions[i].Value
// Address
address := removeZoneIDFromAddress(split[4])
// Port
rawPort, err := strconv.ParseUint(split[5], 10, 16)
if err != nil {
return nil, fmt.Errorf("%w: %v", errParsePort, err) //nolint:errorlint
}
port := int(rawPort)
typ := split[7]
relatedAddress := ""
relatedPort := 0
tcpType := TCPTypeUnspecified
if len(split) > 8 {
split = split[8:]
if split[0] == "raddr" {
if len(split) < 4 {
return nil, fmt.Errorf("%w: incorrect length", errParseRelatedAddr)
}
// RelatedAddress
relatedAddress = split[1]
// RelatedPort
rawRelatedPort, parseErr := strconv.ParseUint(split[3], 10, 16)
if parseErr != nil {
return nil, fmt.Errorf("%w: %v", errParsePort, parseErr) //nolint:errorlint
}
relatedPort = int(rawRelatedPort)
} else if split[0] == "tcptype" {
if len(split) < 2 {
return nil, fmt.Errorf("%w: incorrect length", errParseTCPType)
}
tcpType = NewTCPType(split[1])
return extension, true
}
}
switch typ {
case "host":
return NewCandidateHost(&CandidateHostConfig{"", protocol, address, port, component, priority, foundation, tcpType, false})
case "srflx":
return NewCandidateServerReflexive(&CandidateServerReflexiveConfig{"", protocol, address, port, component, priority, foundation, relatedAddress, relatedPort})
case "prflx":
return NewCandidatePeerReflexive(&CandidatePeerReflexiveConfig{"", protocol, address, port, component, priority, foundation, relatedAddress, relatedPort})
case "relay":
return NewCandidateRelay(&CandidateRelayConfig{"", protocol, address, port, component, priority, foundation, relatedAddress, relatedPort, "", nil})
default:
// TCPType was manually set.
if key == "tcptype" && c.TCPType() != TCPTypeUnspecified {
extension.Value = c.TCPType().String()
return extension, true
}
return nil, fmt.Errorf("%w (%s)", ErrUnknownCandidateTyp, typ)
return extension, false
}
// marshalExtensions returns the string representation of the candidate extensions.
func (c *candidateBase) marshalExtensions() string {
value := ""
exts := c.Extensions()
for i := range exts {
if value != "" {
value += " "
}
value += exts[i].Key + " " + exts[i].Value
}
return value
}
// Equal returns true if the candidate extensions are equal.
func (c *candidateBase) extensionsEqual(other []CandidateExtension) bool {
freq1 := make(map[CandidateExtension]int)
freq2 := make(map[CandidateExtension]int)
if len(c.extensions) != len(other) {
return false
}
if len(c.extensions) == 0 {
return true
}
if len(c.extensions) == 1 {
return c.extensions[0] == other[0]
}
for i := range c.extensions {
freq1[c.extensions[i]]++
freq2[other[i]]++
}
for k, v := range freq1 {
if freq2[k] != v {
return false
}
}
return true
}
func (c *candidateBase) setExtensions(extensions []CandidateExtension) {
c.extensions = extensions
}
// UnmarshalCandidate Parses a candidate from a string
// https://datatracker.ietf.org/doc/html/rfc5245#section-15.1
func UnmarshalCandidate(raw string) (Candidate, error) {
// rfc5245
pos := 0
// foundation ( 1*32ice-char ) But we allow for empty foundation,
foundation, pos, err := readCandidateCharToken(raw, pos, 32)
if err != nil {
return nil, fmt.Errorf("%w: %v in %s", errParseFoundation, err, raw) //nolint:errorlint // we wrap the error
}
// Empty foundation, not RFC 8445 compliant but seen in the wild
if foundation == "" {
foundation = " "
}
if pos >= len(raw) {
return nil, fmt.Errorf("%w: expected component in %s", errAttributeTooShortICECandidate, raw)
}
// component-id ( 1*5DIGIT )
component, pos, err := readCandidateDigitToken(raw, pos, 5)
if err != nil {
return nil, fmt.Errorf("%w: %v in %s", errParseComponent, err, raw) //nolint:errorlint // we wrap the error
}
if pos >= len(raw) {
return nil, fmt.Errorf("%w: expected transport in %s", errAttributeTooShortICECandidate, raw)
}
// transport ( "UDP" / transport-extension ; from RFC 3261 ) SP
protocol, pos := readCandidateStringToken(raw, pos)
if pos >= len(raw) {
return nil, fmt.Errorf("%w: expected priority in %s", errAttributeTooShortICECandidate, raw)
}
// priority ( 1*10DIGIT ) SP
priority, pos, err := readCandidateDigitToken(raw, pos, 10)
if err != nil {
return nil, fmt.Errorf("%w: %v in %s", errParsePriority, err, raw) //nolint:errorlint // we wrap the error
}
if pos >= len(raw) {
return nil, fmt.Errorf("%w: expected address in %s", errAttributeTooShortICECandidate, raw)
}
// connection-address SP ;from RFC 4566
address, pos := readCandidateStringToken(raw, pos)
// Remove IPv6 ZoneID: https://github.com/pion/ice/pull/704
address = removeZoneIDFromAddress(address)
if pos >= len(raw) {
return nil, fmt.Errorf("%w: expected port in %s", errAttributeTooShortICECandidate, raw)
}
// port from RFC 4566
port, pos, err := readCandidatePort(raw, pos)
if err != nil {
return nil, fmt.Errorf("%w: %v in %s", errParsePort, err, raw) //nolint:errorlint // we wrap the error
}
// "typ" SP
typeKey, pos := readCandidateStringToken(raw, pos)
if typeKey != "typ" {
return nil, fmt.Errorf("%w (%s)", ErrUnknownCandidateTyp, typeKey)
}
if pos >= len(raw) {
return nil, fmt.Errorf("%w: expected candidate type in %s", errAttributeTooShortICECandidate, raw)
}
// SP cand-type ("host" / "srflx" / "prflx" / "relay")
typ, pos := readCandidateStringToken(raw, pos)
raddr, rport, pos, err := tryReadRelativeAddrs(raw, pos)
if err != nil {
return nil, err
}
tcpType := TCPTypeUnspecified
var extensions []CandidateExtension
var tcpTypeRaw string
if pos < len(raw) {
extensions, tcpTypeRaw, err = unmarshalCandidateExtensions(raw[pos:])
if err != nil {
return nil, fmt.Errorf("%w: %v", errParseExtension, err) //nolint:errorlint // we wrap the error
}
if tcpTypeRaw != "" {
tcpType = NewTCPType(tcpTypeRaw)
if tcpType == TCPTypeUnspecified {
return nil, fmt.Errorf("%w: invalid or unsupported TCPtype %s", errParseTCPType, tcpTypeRaw)
}
}
}
// this code is ugly because we can't break backwards compatibility
// with the old way of parsing candidates
switch typ {
case "host":
candidate, err := NewCandidateHost(&CandidateHostConfig{
"",
protocol,
address,
port,
uint16(component), //nolint:gosec // G115 no overflow we read 5 digits
uint32(priority), //nolint:gosec // G115 no overflow we read 5 digits
foundation,
tcpType,
false,
})
if err != nil {
return nil, err
}
candidate.setExtensions(extensions)
return candidate, nil
case "srflx":
candidate, err := NewCandidateServerReflexive(&CandidateServerReflexiveConfig{
"",
protocol,
address,
port,
uint16(component), //nolint:gosec // G115 no overflow we read 5 digits
uint32(priority), //nolint:gosec // G115 no overflow we read 5 digits
foundation,
raddr,
rport,
})
if err != nil {
return nil, err
}
candidate.setExtensions(extensions)
return candidate, nil
case "prflx":
candidate, err := NewCandidatePeerReflexive(&CandidatePeerReflexiveConfig{
"",
protocol,
address,
port,
uint16(component), //nolint:gosec // G115 no overflow we read 5 digits
uint32(priority), //nolint:gosec // G115 no overflow we read 5 digits
foundation,
raddr,
rport,
})
if err != nil {
return nil, err
}
candidate.setExtensions(extensions)
return candidate, nil
case "relay":
candidate, err := NewCandidateRelay(&CandidateRelayConfig{
"",
protocol,
address,
port,
uint16(component), //nolint:gosec // G115 no overflow we read 5 digits
uint32(priority), //nolint:gosec // G115 no overflow we read 5 digits
foundation,
raddr,
rport,
"",
nil,
})
if err != nil {
return nil, err
}
candidate.setExtensions(extensions)
return candidate, nil
default:
return nil, fmt.Errorf("%w (%s)", ErrUnknownCandidateTyp, typ)
}
}
// Read an ice-char token from the raw string
// ice-char = ALPHA / DIGIT / "+" / "/"
// stop reading when a space is encountered or the end of the string
func readCandidateCharToken(raw string, start int, limit int) (string, int, error) {
for i, char := range raw[start:] {
if char == 0x20 { // SP
return raw[start : start+i], start + i + 1, nil
}
if i == limit {
return "", 0, fmt.Errorf("token too long: %s expected 1x%d", raw[start:start+i], limit) //nolint: err113 // handled by caller
}
if !(char >= 'A' && char <= 'Z' ||
char >= 'a' && char <= 'z' ||
char >= '0' && char <= '9' ||
char == '+' || char == '/') {
return "", 0, fmt.Errorf("invalid ice-char token: %c", char) //nolint: err113 // handled by caller
}
}
return raw[start:], len(raw), nil
}
// Read an ice string token from the raw string until a space is encountered
// Or the end of the string, we imply that ice string are UTF-8 encoded
func readCandidateStringToken(raw string, start int) (string, int) {
for i, char := range raw[start:] {
if char == 0x20 { // SP
return raw[start : start+i], start + i + 1
}
}
return raw[start:], len(raw)
}
// Read a digit token from the raw string
// stop reading when a space is encountered or the end of the string
func readCandidateDigitToken(raw string, start, limit int) (int, int, error) {
var val int
for i, char := range raw[start:] {
if char == 0x20 { // SP
return val, start + i + 1, nil
}
if i == limit {
return 0, 0, fmt.Errorf("token too long: %s expected 1x%d", raw[start:start+i], limit) //nolint: err113 // handled by caller
}
if !(char >= '0' && char <= '9') {
return 0, 0, fmt.Errorf("invalid digit token: %c", char) //nolint: err113 // handled by caller
}
val = val*10 + int(char-'0')
}
return val, len(raw), nil
}
// Read and validate RFC 4566 port from the raw string
func readCandidatePort(raw string, start int) (int, int, error) {
port, pos, err := readCandidateDigitToken(raw, start, 5)
if err != nil {
return 0, 0, err
}
if port > 65535 {
return 0, 0, fmt.Errorf("invalid RFC 4566 port %d", port) //nolint: err113 // handled by caller
}
return port, pos, nil
}
// Read a byte-string token from the raw string
// As defined in RFC 4566 1*(%x01-09/%x0B-0C/%x0E-FF) ;any byte except NUL, CR, or LF
// we imply that extensions byte-string are UTF-8 encoded
func readCandidateByteString(raw string, start int) (string, int, error) {
for i, char := range raw[start:] {
if char == 0x20 { // SP
return raw[start : start+i], start + i + 1, nil
}
// 1*(%x01-09/%x0B-0C/%x0E-FF)
if !(char >= 0x01 && char <= 0x09 ||
char >= 0x0B && char <= 0x0C ||
char >= 0x0E && char <= 0xFF) {
return "", 0, fmt.Errorf("invalid byte-string character: %c", char) //nolint: err113 // handled by caller
}
}
return raw[start:], len(raw), nil
}
// Read and validate raddr and rport from the raw string
// [SP rel-addr] [SP rel-port]
// defined in https://datatracker.ietf.org/doc/html/rfc5245#section-15.1
// .
func tryReadRelativeAddrs(raw string, start int) (raddr string, rport, pos int, err error) {
key, pos := readCandidateStringToken(raw, start)
if key != "raddr" {
return "", 0, start, nil
}
if pos >= len(raw) {
return "", 0, 0, fmt.Errorf("%w: expected raddr value in %s", errParseRelatedAddr, raw)
}
raddr, pos = readCandidateStringToken(raw, pos)
if pos >= len(raw) {
return "", 0, 0, fmt.Errorf("%w: expected rport in %s", errParseRelatedAddr, raw)
}
key, pos = readCandidateStringToken(raw, pos)
if key != "rport" {
return "", 0, 0, fmt.Errorf("%w: expected rport in %s", errParseRelatedAddr, raw)
}
if pos >= len(raw) {
return "", 0, 0, fmt.Errorf("%w: expected rport value in %s", errParseRelatedAddr, raw)
}
rport, pos, err = readCandidatePort(raw, pos)
if err != nil {
return "", 0, 0, fmt.Errorf("%w: %v", errParseRelatedAddr, err) //nolint:errorlint // we wrap the error
}
return raddr, rport, pos, nil
}
// UnmarshalCandidateExtensions parses the candidate extensions from the raw string.
// *(SP extension-att-name SP extension-att-value)
// Where extension-att-name, and extension-att-value are byte-strings
// as defined in https://tools.ietf.org/html/rfc5245#section-15.1
func unmarshalCandidateExtensions(raw string) (extensions []CandidateExtension, rawTCPTypeRaw string, err error) {
extensions = make([]CandidateExtension, 0)
if raw == "" {
return extensions, "", nil
}
if raw[0] == 0x20 { // SP
return extensions, "", fmt.Errorf("%w: unexpected space %s", errParseExtension, raw)
}
for i := 0; i < len(raw); {
key, next, err := readCandidateByteString(raw, i)
if err != nil {
return extensions, "", fmt.Errorf("%w: failed to read key %v", errParseExtension, err) //nolint: errorlint // we wrap the error
}
i = next
if i >= len(raw) {
return extensions, "", fmt.Errorf("%w: missing value for %s in %s", errParseExtension, key, raw)
}
value, next, err := readCandidateByteString(raw, i)
if err != nil {
return extensions, "", fmt.Errorf("%w: failed to read value %v", errParseExtension, err) //nolint: errorlint // we are wrapping the error
}
i = next
if key == "tcptype" {
rawTCPTypeRaw = value
}
extensions = append(extensions, CandidateExtension{key, value})
}
return
}

View File

@@ -274,6 +274,19 @@ func mustCandidateHost(conf *CandidateHostConfig) Candidate {
return cand
}
func mustCandidateHostWithExtensions(t *testing.T, conf *CandidateHostConfig, extensions []CandidateExtension) Candidate {
t.Helper()
cand, err := NewCandidateHost(conf)
if err != nil {
panic(err)
}
cand.setExtensions(extensions)
return cand
}
func mustCandidateRelay(conf *CandidateRelayConfig) Candidate {
cand, err := NewCandidateRelay(conf)
if err != nil {
@@ -282,6 +295,19 @@ func mustCandidateRelay(conf *CandidateRelayConfig) Candidate {
return cand
}
func mustCandidateRelayWithExtensions(t *testing.T, conf *CandidateRelayConfig, extensions []CandidateExtension) Candidate {
t.Helper()
cand, err := NewCandidateRelay(conf)
if err != nil {
panic(err)
}
cand.setExtensions(extensions)
return cand
}
func mustCandidateServerReflexive(conf *CandidateServerReflexiveConfig) Candidate {
cand, err := NewCandidateServerReflexive(conf)
if err != nil {
@@ -290,6 +316,32 @@ func mustCandidateServerReflexive(conf *CandidateServerReflexiveConfig) Candidat
return cand
}
func mustCandidateServerReflexiveWithExtensions(t *testing.T, conf *CandidateServerReflexiveConfig, extensions []CandidateExtension) Candidate {
t.Helper()
cand, err := NewCandidateServerReflexive(conf)
if err != nil {
panic(err)
}
cand.setExtensions(extensions)
return cand
}
func mustCandidatePeerReflexiveWithExtensions(t *testing.T, conf *CandidatePeerReflexiveConfig, extensions []CandidateExtension) Candidate {
t.Helper()
cand, err := NewCandidatePeerReflexive(conf)
if err != nil {
panic(err)
}
cand.setExtensions(extensions)
return cand
}
func TestCandidateMarshal(t *testing.T) {
for idx, test := range []struct {
candidate Candidate
@@ -327,6 +379,25 @@ func TestCandidateMarshal(t *testing.T) {
"647372371 1 udp 1694498815 191.228.238.68 53991 typ srflx raddr 192.168.0.274 rport 53991",
false,
},
{
mustCandidatePeerReflexiveWithExtensions(
t,
&CandidatePeerReflexiveConfig{
Network: NetworkTypeTCP4.String(),
Address: "192.0.2.15",
Port: 50000,
RelAddr: "10.0.0.1",
RelPort: 12345,
},
[]CandidateExtension{
{"generation", "0"},
{"network-id", "2"},
{"network-cost", "10"},
},
),
"4207374052 1 tcp 1685790463 192.0.2.15 50000 typ prflx raddr 10.0.0.1 rport 12345 generation 0 network-id 2 network-cost 10",
false,
},
{
mustCandidateRelay(&CandidateRelayConfig{
Network: NetworkTypeUDP4.String(),
@@ -368,6 +439,28 @@ func TestCandidateMarshal(t *testing.T) {
" 1 udp 500 " + localhostIPStr + " 80 typ host",
false,
},
{
mustCandidateHost(&CandidateHostConfig{
Network: NetworkTypeUDP4.String(),
Address: localhostIPStr,
Port: 80,
Priority: 500,
Foundation: "+/3713fhi",
}),
"+/3713fhi 1 udp 500 " + localhostIPStr + " 80 typ host",
false,
},
{
mustCandidateHost(&CandidateHostConfig{
Network: NetworkTypeTCP4.String(),
Address: "172.28.142.173",
Port: 7686,
Priority: 1671430143,
Foundation: "+/3713fhi",
}),
"3359356140 1 tcp 1671430143 172.28.142.173 7686 typ host",
false,
},
// Invalid candidates
{nil, "", true},
@@ -382,11 +475,52 @@ func TestCandidateMarshal(t *testing.T) {
{nil, "4207374051 INVALID udp 2130706431 10.0.75.1 INVALID typ host", true},
{nil, "4207374051 1 udp 2130706431 10.0.75.1 53634 typ INVALID", true},
{nil, "4207374051 1 INVALID 2130706431 10.0.75.1 53634 typ host", true},
{nil, "4207374051 1 INVALID 2130706431 10.0.75.1 53634 typ", true},
{nil, "4207374051 1 INVALID 2130706431 10.0.75.1 53634", true},
{nil, "848194626 1 udp 16777215 50.0.0.^^1 5000 typ relay raddr 192.168.0.1 rport 5001", true},
{nil, "4207374052 1 tcp 1685790463 192.0#.2.15 50000 typ prflx raddr 10.0.0.1 rport 12345 rport 5001", true},
{nil, "647372371 1 udp 1694498815 191.228.2@338.68 53991 typ srflx raddr 192.168.0.274 rport 53991", true},
// invalid foundion; longer than 32 characters
{nil, "111111111111111111111111111111111 1 udp 500 " + localhostIPStr + " 80 typ host", true},
// Invalid ice-char
{nil, "3$3 1 udp 500 " + localhostIPStr + " 80 typ host", true},
// invalid component; longer than 5 digits
{nil, "4207374051 123456 udp 500 " + localhostIPStr + " 0 typ host", true},
// invalid priority; longer than 10 digits
{nil, "4207374051 99999 udp 12345678910 " + localhostIPStr + " 99999 typ host", true},
// invalid port;
{nil, "4207374051 99999 udp 500 " + localhostIPStr + " 65536 typ host", true},
{nil, "4207374051 99999 udp 500 " + localhostIPStr + " 999999 typ host", true},
{nil, "848194626 1 udp 16777215 50.0.0.1 5000 typ relay raddr 192.168.0.1 rport 999999", true},
// bad byte-string in extension value
{nil, "750 1 udp 500 fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a 53987 typ host ext valu\nu", true},
{nil, "848194626 1 udp 16777215 50.0.0.1 5000 typ relay raddr 192.168.0.1 rport 654 ext valu\nu", true},
{nil, "848194626 1 udp 16777215 50.0.0.1 5000 typ relay raddr 192.168.0.1 rport 654 ext valu\000e", true},
// bad byte-string in extension key
{nil, "848194626 1 udp 16777215 50.0.0.1 5000 typ relay raddr 192.168.0.1 rport 654 ext\r value", true},
// invalid tcptype
{nil, "1052353102 1 tcp 2128609279 192.168.0.196 0 typ host tcptype INVALID", true},
// expect rport after raddr
{nil, "848194626 1 udp 16777215 50.0.0.1 5000 typ relay raddr 192.168.0.1 extension 322", true},
{nil, "848194626 1 udp 16777215 50.0.0.1 5000 typ relay raddr 192.168.0.1 rport", true},
{nil, "848194626 1 udp 16777215 50.0.0.1 5000 typ relay raddr 192.168.0.1", true},
{nil, "848194626 1 udp 16777215 50.0.0.1 5000 typ relay raddr", true},
{nil, "4207374051 99999 udp 500 " + localhostIPStr + " 80 typ", true},
{nil, "4207374051 99999 udp 500 " + localhostIPStr + " 80", true},
{nil, "4207374051 99999 udp 500 " + localhostIPStr, true},
{nil, "4207374051 99999 udp 500 ", true},
{nil, "4207374051 99999 udp", true},
{nil, "4207374051 99999", true},
{nil, "4207374051", true},
} {
t.Run(strconv.Itoa(idx), func(t *testing.T) {
actualCandidate, err := UnmarshalCandidate(test.marshaled)
if test.expectError {
require.Error(t, err)
require.Error(t, err, "expected error", test.marshaled)
return
}
@@ -466,3 +600,635 @@ func TestMarshalUnmarshalCandidateWithZoneID(t *testing.T) {
require.NoError(t, err)
require.Truef(t, candidate.Equal(candidate2), "%s != %s", candidate.String(), candidate2.String())
}
func TestCandidateExtensionsMarshal(t *testing.T) {
testCases := []struct {
Extensions []CandidateExtension
candidate string
}{
{
[]CandidateExtension{
{"generation", "0"},
{"ufrag", "QNvE"},
{"network-id", "4"},
},
"1299692247 1 udp 2122134271 fdc8:cc8:c835:e400:343c:feb:32c8:17b9 58240 typ host generation 0 ufrag QNvE network-id 4",
},
{
[]CandidateExtension{
{"generation", "1"},
{"network-id", "2"},
{"network-cost", "50"},
},
"647372371 1 udp 1694498815 191.228.238.68 53991 typ srflx raddr 192.168.0.274 rport 53991 generation 1 network-id 2 network-cost 50",
},
{
[]CandidateExtension{
{"generation", "0"},
{"network-id", "2"},
{"network-cost", "10"},
},
"4207374052 1 tcp 1685790463 192.0.2.15 50000 typ prflx raddr 10.0.0.1 rport 12345 generation 0 network-id 2 network-cost 10",
},
{
[]CandidateExtension{
{"generation", "0"},
{"network-id", "1"},
{"network-cost", "20"},
{"ufrag", "frag42abcdef"},
{"password", "abc123exp123"},
},
"848194626 1 udp 16777215 50.0.0.1 5000 typ relay raddr 192.168.0.1 rport 5001 generation 0 network-id 1 network-cost 20 ufrag frag42abcdef password abc123exp123",
},
{
[]CandidateExtension{
{"tcptype", "active"},
{"generation", "0"},
},
"1052353102 1 tcp 2128609279 192.168.0.196 0 typ host tcptype active generation 0",
},
{
[]CandidateExtension{
{"tcptype", "active"},
{"generation", "0"},
},
"1052353102 1 tcp 2128609279 192.168.0.196 0 typ host tcptype active generation 0",
},
{
[]CandidateExtension{},
"1052353102 1 tcp 2128609279 192.168.0.196 0 typ host",
},
{
[]CandidateExtension{},
"1052353102 1 tcp 2128609279 192.168.0.196 0 typ host",
},
}
for _, tc := range testCases {
candidate, err := UnmarshalCandidate(tc.candidate)
require.NoError(t, err)
require.Equal(t, tc.Extensions, candidate.Extensions(), "Extensions should be equal", tc.candidate)
valueStr := candidate.Marshal()
candidate2, err := UnmarshalCandidate(valueStr)
require.NoError(t, err)
require.Equal(t, tc.Extensions, candidate2.Extensions(), "Marshal() should preserve extensions")
}
}
func TestCandidateExtensionsDeepEqual(t *testing.T) {
noExt, err := UnmarshalCandidate("750 0 udp 500 fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a 53987 typ host")
require.NoError(t, err)
generation := "0"
ufrag := "QNvE"
networkID := "4"
extensions := []CandidateExtension{
{"generation", generation},
{"ufrag", ufrag},
{"network-id", networkID},
}
candidate, err := UnmarshalCandidate(
"750 0 udp 500 fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a 53987 typ host generation " +
generation + " ufrag " + ufrag + " network-id " + networkID,
)
require.NoError(t, err)
testCases := []struct {
a Candidate
b Candidate
equal bool
}{
{
mustCandidateHost(&CandidateHostConfig{
Network: NetworkTypeUDP4.String(),
Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a",
Port: 53987,
Priority: 500,
Foundation: "750",
}),
noExt,
true,
},
{
mustCandidateHostWithExtensions(
t,
&CandidateHostConfig{
Network: NetworkTypeUDP4.String(),
Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a",
Port: 53987,
Priority: 500,
Foundation: "750",
},
[]CandidateExtension{},
),
noExt,
true,
},
{
mustCandidateHostWithExtensions(
t,
&CandidateHostConfig{
Network: NetworkTypeUDP4.String(),
Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a",
Port: 53987,
Priority: 500,
Foundation: "750",
},
extensions,
),
candidate,
true,
},
{
mustCandidateRelayWithExtensions(
t,
&CandidateRelayConfig{
Network: NetworkTypeUDP4.String(),
Address: "10.0.0.10",
Port: 5000,
RelAddr: "10.0.0.2",
RelPort: 5001,
},
[]CandidateExtension{
{"generation", "0"},
{"network-id", "1"},
},
),
mustCandidateRelayWithExtensions(
t,
&CandidateRelayConfig{
Network: NetworkTypeUDP4.String(),
Address: "10.0.0.10",
Port: 5000,
RelAddr: "10.0.0.2",
RelPort: 5001,
},
[]CandidateExtension{
{"network-id", "1"},
{"generation", "0"},
},
),
true,
},
{
mustCandidatePeerReflexiveWithExtensions(
t,
&CandidatePeerReflexiveConfig{
Network: NetworkTypeTCP4.String(),
Address: "192.0.2.15",
Port: 50000,
RelAddr: "10.0.0.1",
RelPort: 12345,
},
[]CandidateExtension{
{"generation", "0"},
{"network-id", "2"},
{"network-cost", "10"},
},
),
mustCandidatePeerReflexiveWithExtensions(
t,
&CandidatePeerReflexiveConfig{
Network: NetworkTypeTCP4.String(),
Address: "192.0.2.15",
Port: 50000,
RelAddr: "10.0.0.1",
RelPort: 12345,
},
[]CandidateExtension{
{"generation", "0"},
{"network-id", "2"},
{"network-cost", "10"},
},
),
true,
},
{
mustCandidateServerReflexiveWithExtensions(
t,
&CandidateServerReflexiveConfig{
Network: NetworkTypeUDP4.String(),
Address: "191.228.238.68",
Port: 53991,
RelAddr: "192.168.0.274",
RelPort: 53991,
},
[]CandidateExtension{
{"generation", "0"},
{"network-id", "2"},
{"network-cost", "10"},
},
),
mustCandidateServerReflexiveWithExtensions(
t,
&CandidateServerReflexiveConfig{
Network: NetworkTypeUDP4.String(),
Address: "191.228.238.68",
Port: 53991,
RelAddr: "192.168.0.274",
RelPort: 53991,
},
[]CandidateExtension{
{"generation", "0"},
{"network-id", "2"},
{"network-cost", "10"},
},
),
true,
},
{
mustCandidateHostWithExtensions(
t,
&CandidateHostConfig{
Network: NetworkTypeUDP4.String(),
Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a",
Port: 53987,
Priority: 500,
Foundation: "750",
},
[]CandidateExtension{
{"generation", "5"},
{"ufrag", ufrag},
{"network-id", networkID},
},
),
candidate,
false,
},
{
mustCandidateHostWithExtensions(
t,
&CandidateHostConfig{
Network: NetworkTypeTCP4.String(),
Address: "192.168.0.196",
Port: 0,
Priority: 2128609279,
Foundation: "1052353102",
TCPType: TCPTypeActive,
},
[]CandidateExtension{
{"tcptype", TCPTypeActive.String()},
{"generation", "0"},
},
),
mustCandidateHostWithExtensions(
t,
&CandidateHostConfig{
Network: NetworkTypeTCP4.String(),
Address: "192.168.0.197",
Port: 0,
Priority: 2128609279,
Foundation: "1052353102",
TCPType: TCPTypeActive,
},
[]CandidateExtension{
{"tcptype", TCPTypeActive.String()},
{"generation", "0"},
},
),
false,
},
}
for _, tc := range testCases {
require.Equal(t, tc.a.DeepEqual(tc.b), tc.equal, "a: %s, b: %s", tc.a.Marshal(), tc.b.Marshal())
}
}
func TestUnmarshalCandidateExtensions(t *testing.T) {
testCases := []struct {
name string
value string
expected []CandidateExtension
fail bool
}{
{
name: "empty string",
value: "",
expected: []CandidateExtension{},
fail: false,
},
{
name: "valid extension string",
value: "a b c d",
expected: []CandidateExtension{{"a", "b"}, {"c", "d"}},
fail: false,
},
{
name: "valid extension string",
value: "a b empty c d",
expected: []CandidateExtension{
{"a", "b"},
{"empty", ""},
{"c", "d"},
},
fail: false,
},
{
name: "invalid extension string",
value: "invalid",
expected: []CandidateExtension{},
fail: true,
},
{
name: "invalid extension",
value: " a b",
expected: []CandidateExtension{{"a", "b"}, {"c", "d"}},
fail: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
req := require.New(t)
actual, _, err := unmarshalCandidateExtensions(testCase.value)
if testCase.fail {
req.Error(err)
} else {
req.NoError(err)
req.EqualValuesf(
testCase.expected,
actual,
"UnmarshalCandidateExtensions() did not return the expected value %v",
testCase.value,
)
}
})
}
}
func TestCandidateGetExtension(t *testing.T) {
t.Run("Get extension", func(t *testing.T) {
extensions := []CandidateExtension{
{"a", "b"},
{"c", "d"},
}
candidate, err := NewCandidateHost(&CandidateHostConfig{
Network: NetworkTypeUDP4.String(),
Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a",
Port: 53987,
Priority: 500,
Foundation: "750",
})
if err != nil {
t.Error(err)
}
candidate.setExtensions(extensions)
value, ok := candidate.GetExtension("c")
require.True(t, ok)
require.Equal(t, "c", value.Key)
require.Equal(t, "d", value.Value)
value, ok = candidate.GetExtension("a")
require.True(t, ok)
require.Equal(t, "a", value.Key)
require.Equal(t, "b", value.Value)
value, ok = candidate.GetExtension("b")
require.False(t, ok)
require.Equal(t, "b", value.Key)
require.Equal(t, "", value.Value)
})
// This is undefined behavior in the spec; extension-att-name is not unique
// but it implied that it's unique in the implementation
t.Run("Extension with multiple values", func(t *testing.T) {
extensions := []CandidateExtension{
{"a", "1"},
{"a", "2"},
}
candidate, err := NewCandidateHost(&CandidateHostConfig{
Network: NetworkTypeUDP4.String(),
Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a",
Port: 53987,
Priority: 500,
Foundation: "750",
})
if err != nil {
t.Error(err)
}
candidate.setExtensions(extensions)
value, ok := candidate.GetExtension("a")
require.True(t, ok)
require.Equal(t, "a", value.Key)
require.Equal(t, "1", value.Value)
})
t.Run("TCPType extension", func(t *testing.T) {
extensions := []CandidateExtension{
{"tcptype", "passive"},
}
candidate, err := NewCandidateHost(&CandidateHostConfig{
Network: NetworkTypeTCP4.String(),
Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a",
Port: 53987,
Priority: 500,
Foundation: "750",
TCPType: TCPTypeActive,
})
if err != nil {
t.Error(err)
}
tcpType, ok := candidate.GetExtension("tcptype")
require.True(t, ok)
require.Equal(t, "tcptype", tcpType.Key)
require.Equal(t, TCPTypeActive.String(), tcpType.Value)
candidate.setExtensions(extensions)
tcpType, ok = candidate.GetExtension("tcptype")
require.True(t, ok)
require.Equal(t, "tcptype", tcpType.Key)
require.Equal(t, "passive", tcpType.Value)
candidate2, err := NewCandidateHost(&CandidateHostConfig{
Network: NetworkTypeTCP4.String(),
Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a",
Port: 53987,
Priority: 500,
Foundation: "750",
})
if err != nil {
t.Error(err)
}
tcpType, ok = candidate2.GetExtension("tcptype")
require.False(t, ok)
require.Equal(t, "tcptype", tcpType.Key)
require.Equal(t, "", tcpType.Value)
})
}
func TestBaseCandidateMarshalExtensions(t *testing.T) {
t.Run("Marshal extension", func(t *testing.T) {
extensions := []CandidateExtension{
{"generation", "0"},
{"ValuE", "KeE"},
{"empty", ""},
{"another", "value"},
}
candidate, err := NewCandidateHost(&CandidateHostConfig{
Network: NetworkTypeUDP4.String(),
Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a",
Port: 53987,
Priority: 500,
Foundation: "750",
})
if err != nil {
t.Error(err)
}
candidate.setExtensions(extensions)
value := candidate.marshalExtensions()
require.Equal(t, "generation 0 ValuE KeE empty another value", value)
})
t.Run("Marshal Empty", func(t *testing.T) {
candidate, err := NewCandidateHost(&CandidateHostConfig{
Network: NetworkTypeUDP4.String(),
Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a",
Port: 53987,
Priority: 500,
Foundation: "750",
})
if err != nil {
t.Error(err)
}
value := candidate.marshalExtensions()
require.Equal(t, "", value)
})
t.Run("Marshal TCPType no extension", func(t *testing.T) {
candidate, err := NewCandidateHost(&CandidateHostConfig{
Network: NetworkTypeUDP4.String(),
Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a",
Port: 53987,
Priority: 500,
Foundation: "750",
TCPType: TCPTypeActive,
})
if err != nil {
t.Error(err)
}
value := candidate.marshalExtensions()
require.Equal(t, "tcptype active", value)
})
}
func TestBaseCandidateExtensionsEqual(t *testing.T) {
testCases := []struct {
name string
extensions1 []CandidateExtension
extensions2 []CandidateExtension
expected bool
}{
{
name: "Empty extensions",
extensions1: []CandidateExtension{},
extensions2: []CandidateExtension{},
expected: true,
},
{
name: "Single value extensions",
extensions1: []CandidateExtension{{"a", "b"}},
extensions2: []CandidateExtension{{"a", "b"}},
expected: true,
},
{
name: "multiple value extensions",
extensions1: []CandidateExtension{
{"a", "b"},
{"c", "d"},
},
extensions2: []CandidateExtension{
{"a", "b"},
{"c", "d"},
},
expected: true,
},
{
name: "unsorted extensions",
extensions1: []CandidateExtension{
{"c", "d"},
{"a", "b"},
},
extensions2: []CandidateExtension{
{"a", "b"},
{"c", "d"},
},
expected: true,
},
{
name: "different values",
extensions1: []CandidateExtension{
{"a", "b"},
{"c", "d"},
},
extensions2: []CandidateExtension{
{"a", "b"},
{"c", "e"},
},
expected: false,
},
{
name: "different size",
extensions1: []CandidateExtension{
{"a", "b"},
{"c", "d"},
},
extensions2: []CandidateExtension{
{"a", "b"},
},
expected: false,
},
{
name: "different keys",
extensions1: []CandidateExtension{
{"a", "b"},
{"c", "d"},
},
extensions2: []CandidateExtension{
{"a", "b"},
{"e", "d"},
},
expected: false,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
cand, err := NewCandidateHost(&CandidateHostConfig{
Network: NetworkTypeUDP4.String(),
Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a",
Port: 53987,
Priority: 500,
Foundation: "750",
})
if err != nil {
t.Error(err)
}
cand.setExtensions(testCase.extensions1)
require.Equal(t, testCase.expected, cand.extensionsEqual(testCase.extensions2))
})
}
}

View File

@@ -125,10 +125,12 @@ var (
errNotImplemented = errors.New("not implemented yet")
errNoUDPMuxAvailable = errors.New("no UDP mux is available")
errNoXorAddrMapping = errors.New("no address mapping")
errParseFoundation = errors.New("failed to parse foundation")
errParseComponent = errors.New("failed to parse component")
errParsePort = errors.New("failed to parse port")
errParsePriority = errors.New("failed to parse priority")
errParseRelatedAddr = errors.New("failed to parse related addresses")
errParseExtension = errors.New("failed to parse extension")
errParseTCPType = errors.New("failed to parse TCP type")
errRead = errors.New("failed to read")
errUDPMuxDisabled = errors.New("UDPMux is not enabled")