From dddadd5ed612b3405a17f39930ffc73f8d3d2299 Mon Sep 17 00:00:00 2001 From: Joe Turki Date: Tue, 14 Jan 2025 12:00:59 -0600 Subject: [PATCH] Parse Candidate Extensions (RFC5245) - Rewrote `UnmarshalCandidate` to better align with RFC5245. - Added new methods for extracting candidate extensions. - Updated `Equal` and `Marshal` to accommodate these changes. - New Type `CandidateExtension` to handle. Signed-off-by: Joe Turki --- candidate.go | 16 + candidate_base.go | 487 +++++++++++++++++++++++++---- candidate_test.go | 764 +++++++++++++++++++++++++++++++++++++++++++++- errors.go | 2 + 4 files changed, 1211 insertions(+), 58 deletions(-) diff --git a/candidate.go b/candidate.go index 4324159b..5c5bcddd 100644 --- a/candidate.go +++ b/candidate.go @@ -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 string, 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 diff --git a/candidate_base.go b/candidate_base.go index ac9d8570..6abcb180 100644 --- a/candidate_base.go +++ b/candidate_base.go @@ -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,462 @@ 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 +// 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(), + }} + } + + extensions := make([]CandidateExtension, len(c.extensions)) + copy(extensions, c.extensions) + + return extensions +} + +// Get returns the value of the given key if it exists. +func (c *candidateBase) GetExtension(key string) (string, bool) { + for i := range c.extensions { + if c.extensions[i].key == key { + return c.extensions[i].value, true + } + } + + // TCPType was manually set. + if key == "tcptype" && c.TCPType() != TCPTypeUnspecified { + return c.TCPType().String(), true + } + + return "", 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) { - 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...) + // 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 } - if len(split) < 8 { - return nil, fmt.Errorf("%w (%d)", errAttributeTooShortICECandidate, len(split)) + + // Empty foundation, not RFC 8445 compliant but seen in the wild + if foundation == "" { + foundation = " " } - // Foundation - foundation := split[0] + if pos >= len(raw) { + return nil, fmt.Errorf("%w: expected component in %s", errAttributeTooShortICECandidate, raw) + } - // Component - rawComponent, err := strconv.ParseUint(split[1], 10, 16) + // component-id ( 1*5DIGIT ) + component, pos, err := readCandidateDigitToken(raw, pos, 5) if err != nil { - return nil, fmt.Errorf("%w: %v", errParseComponent, err) //nolint:errorlint + return nil, fmt.Errorf("%w: %v in %s", errParseComponent, err, raw) //nolint:errorlint // we wrap the error } - component := uint16(rawComponent) - // Protocol - protocol := split[2] + 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) - // Priority - priorityRaw, err := strconv.ParseUint(split[3], 10, 32) + 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", errParsePriority, err) //nolint:errorlint + 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) } - priority := uint32(priorityRaw) - // Address - address := removeZoneIDFromAddress(split[4]) + // connection-address SP ;from RFC 4566 + address, pos := readCandidateStringToken(raw, pos) + + // Remove IPv6 ZoneID: https://github.com/pion/ice/pull/704 + address = removeZoneIDFromAddress(address) - // Port - rawPort, err := strconv.ParseUint(split[5], 10, 16) + 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", errParsePort, err) //nolint:errorlint + return nil, fmt.Errorf("%w: %v in %s", errParsePort, err, raw) //nolint:errorlint // we wrap the error } - port := int(rawPort) - typ := split[7] - relatedAddress := "" - relatedPort := 0 - tcpType := TCPTypeUnspecified + // "typ" SP + typeKey, pos := readCandidateStringToken(raw, pos) + if typeKey != "typ" { + return nil, fmt.Errorf("%w (%s)", ErrUnknownCandidateTyp, typeKey) + } - if len(split) > 8 { - split = split[8:] + if pos >= len(raw) { + return nil, fmt.Errorf("%w: expected candidate type in %s", errAttributeTooShortICECandidate, raw) + } - if split[0] == "raddr" { - if len(split) < 4 { - return nil, fmt.Errorf("%w: incorrect length", errParseRelatedAddr) - } + // 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 + } - // RelatedAddress - relatedAddress = split[1] + tcpType := TCPTypeUnspecified + var extensions []CandidateExtension + var tcpTypeRaw string - // 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) - } + 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 + } - tcpType = NewTCPType(split[1]) + 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": - return NewCandidateHost(&CandidateHostConfig{"", protocol, address, port, component, priority, foundation, tcpType, false}) + 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": - return NewCandidateServerReflexive(&CandidateServerReflexiveConfig{"", protocol, address, port, component, priority, foundation, relatedAddress, relatedPort}) + 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": - return NewCandidatePeerReflexive(&CandidatePeerReflexiveConfig{"", protocol, address, port, component, priority, foundation, relatedAddress, relatedPort}) + 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": - return NewCandidateRelay(&CandidateRelayConfig{"", protocol, address, port, component, priority, foundation, relatedAddress, relatedPort, "", nil}) + 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 nil, fmt.Errorf("%w (%s)", ErrUnknownCandidateTyp, typ) + return } diff --git a/candidate_test.go b/candidate_test.go index aecea115..8285d6eb 100644 --- a/candidate_test.go +++ b/candidate_test.go @@ -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,631 @@ 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 TestCandidateGetExtension(t *testing.T) { + t.Run("valid", func(t *testing.T) { + candidate := 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"}, + }, + ) + + value, ok := candidate.GetExtension("tcptype") + require.Equal(t, TCPTypeActive.String(), value) + require.True(t, ok) + + value, ok = candidate.GetExtension("generation") + require.Equal(t, "0", value) + require.True(t, ok) + + value, ok = candidate.GetExtension("INVALID") + require.Equal(t, "", value) + require.False(t, ok) + }) + + t.Run("tcptype value no extenstion", func(t *testing.T) { + candidate := mustCandidateHost( + &CandidateHostConfig{ + Network: NetworkTypeTCP4.String(), + Address: "192.168.0.196", + Port: 0, + Priority: 2128609279, + Foundation: "1052353102", + TCPType: TCPTypeActive, + }, + ) + + value, ok := candidate.GetExtension("tcptype") + require.Equal(t, TCPTypeActive.String(), value) + require.True(t, ok) + }) +} + +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 TestExtensionGet(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, "d", value) + + value, ok = candidate.GetExtension("a") + require.True(t, ok) + require.Equal(t, "b", value) + + value, ok = candidate.GetExtension("b") + require.False(t, ok) + require.Equal(t, "", 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, "1", value) + }) +} + +func TestExtensionMarhshal(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 TestExtensionEqual(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)) + }) + } +} diff --git a/errors.go b/errors.go index 02736ccb..f48be141 100644 --- a/errors.go +++ b/errors.go @@ -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")