From 0975d348643e8c8fd79e6b650d6f8fa4e0372baa Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Wed, 9 Apr 2025 00:07:14 +0530 Subject: [PATCH] Helpers to deal with SDP fragments. (#1039) * Helpers to deal with SDP fragments. WHIP uses SDP fragments which fail parsing using full-fledged SDP libraries like pion/sdp, go-sdp. Add utlities to deal with SDP fragments. * generated protobuf * SDP tests, mostly happy path, will add more later --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- sdp/sdp.go | 566 ++++++++++++++++++++++++++++++++++++++++++++++-- sdp/sdp_test.go | 180 +++++++++++++++ 2 files changed, 722 insertions(+), 24 deletions(-) create mode 100644 sdp/sdp_test.go diff --git a/sdp/sdp.go b/sdp/sdp.go index 71300fc..40f1e72 100644 --- a/sdp/sdp.go +++ b/sdp/sdp.go @@ -15,6 +15,8 @@ package sdp import ( + "errors" + "fmt" "strconv" "strings" @@ -96,44 +98,44 @@ func ExtractDTLSRole(desc *sdp.SessionDescription) webrtc.DTLSRole { } func ExtractICECredential(desc *sdp.SessionDescription) (string, string, error) { - remotePwds := []string{} - remoteUfrags := []string{} + pwds := []string{} + ufrags := []string{} if ufrag, haveUfrag := desc.Attribute("ice-ufrag"); haveUfrag { - remoteUfrags = append(remoteUfrags, ufrag) + ufrags = append(ufrags, ufrag) } if pwd, havePwd := desc.Attribute("ice-pwd"); havePwd { - remotePwds = append(remotePwds, pwd) + pwds = append(pwds, pwd) } for _, m := range desc.MediaDescriptions { if ufrag, haveUfrag := m.Attribute("ice-ufrag"); haveUfrag { - remoteUfrags = append(remoteUfrags, ufrag) + ufrags = append(ufrags, ufrag) } if pwd, havePwd := m.Attribute("ice-pwd"); havePwd { - remotePwds = append(remotePwds, pwd) + pwds = append(pwds, pwd) } } - if len(remoteUfrags) == 0 { + if len(ufrags) == 0 { return "", "", webrtc.ErrSessionDescriptionMissingIceUfrag - } else if len(remotePwds) == 0 { + } else if len(pwds) == 0 { return "", "", webrtc.ErrSessionDescriptionMissingIcePwd } - for _, m := range remoteUfrags { - if m != remoteUfrags[0] { + for _, m := range ufrags { + if m != ufrags[0] { return "", "", webrtc.ErrSessionDescriptionConflictingIceUfrag } } - for _, m := range remotePwds { - if m != remotePwds[0] { + for _, m := range pwds { + if m != pwds[0] { return "", "", webrtc.ErrSessionDescriptionConflictingIcePwd } } - return remoteUfrags[0], remotePwds[0], nil + return ufrags[0], pwds[0], nil } func ExtractStreamID(media *sdp.MediaDescription) (string, bool) { @@ -151,17 +153,6 @@ func ExtractStreamID(media *sdp.MediaDescription) (string, bool) { return streamID, true } -func GetTrackIDFromMediaDescription(m *sdp.MediaDescription) string { - trackId := "" - msid, ok := m.Attribute(sdp.AttrKeyMsid) - if ok { - if split := strings.Split(msid, " "); len(split) >= 2 { - trackId = split[1] - } - } - return trackId -} - func IsMediaDescriptionSimulcast(m *sdp.MediaDescription) bool { _, ok := m.Attribute("simulcast") return ok @@ -191,3 +182,530 @@ func CodecsFromMediaDescription(m *sdp.MediaDescription) (out []sdp.Codec, err e return out, nil } + +func GetBundleMid(parsed *sdp.SessionDescription) (string, bool) { + if groupAttribute, found := parsed.Attribute(sdp.AttrKeyGroup); found { + bundleIDs := strings.Split(groupAttribute, " ") + if len(bundleIDs) > 1 && strings.EqualFold(bundleIDs[0], "BUNDLE") { + return bundleIDs[1], true + } + } + + return "", false +} + +type sdpFragmentICE struct { + ufrag string + pwd string + lite *bool + options string +} + +func (i *sdpFragmentICE) Unmarshal(attributes []sdp.Attribute) error { + getAttr := func(key string) (string, bool) { + for _, a := range attributes { + if a.Key == key { + return a.Value, true + } + } + + return "", false + } + + iceUfrag, found := getAttr("ice-ufrag") + if found { + i.ufrag = iceUfrag + } + + icePwd, found := getAttr("ice-pwd") + if found { + i.pwd = icePwd + } + + _, found = getAttr(sdp.AttrKeyICELite) + if found { + lite := true + i.lite = &lite + } + + iceOptions, found := getAttr("ice-options") + if found { + i.options = iceOptions + } + + return nil +} + +func (i *sdpFragmentICE) Marshal() (string, error) { + iceFragment := []byte{} + addKeyValue := func(key string, value string) { + iceFragment = append(iceFragment, key...) + if value != "" { + iceFragment = append(iceFragment, value...) + } + iceFragment = append(iceFragment, "\r\n"...) + } + + if i.ufrag != "" { + addKeyValue("a=ice-ufrag:", i.ufrag) + } + if i.pwd != "" { + addKeyValue("a=ice-pwd:", i.pwd) + } + if i.lite != nil && *i.lite { + addKeyValue("a=ice-lite", "") + } + if i.options != "" { + addKeyValue("a=ice-options:", i.options) + } + + return string(iceFragment), nil +} + +type sdpFragmentMedia struct { + info string + mid string + ice *sdpFragmentICE + candidates []string + endOfCandidates *bool +} + +func (m *sdpFragmentMedia) Unmarshal(md *sdp.MediaDescription) error { + // MediaName conversion to string taken from github.com/pion/sdp + var info []byte + appendList := func(list []string, sep byte) { + for i, p := range list { + if i != 0 && i != len(list) { + info = append(info, sep) + } + info = append(info, p...) + } + } + + info = append(append(info, md.MediaName.Media...), ' ') + + info = strconv.AppendInt(info, int64(md.MediaName.Port.Value), 10) + if md.MediaName.Port.Range != nil { + info = append(info, '/') + info = strconv.AppendInt(info, int64(*md.MediaName.Port.Range), 10) + } + info = append(info, ' ') + + appendList(md.MediaName.Protos, '/') + info = append(info, ' ') + appendList(md.MediaName.Formats, ' ') + m.info = string(info) + + mid, found := md.Attribute(sdp.AttrKeyMID) + if found { + m.mid = mid + } + + m.ice = &sdpFragmentICE{} + if err := m.ice.Unmarshal(md.Attributes); err != nil { + return err + } + + for _, a := range md.Attributes { + if a.IsICECandidate() { + m.candidates = append(m.candidates, a.Value) + } + } + + _, found = md.Attribute(sdp.AttrKeyEndOfCandidates) + if found { + endOfCandidates := true + m.endOfCandidates = &endOfCandidates + } + return nil +} + +func (m *sdpFragmentMedia) Marshal() (string, error) { + mediaFragment := []byte{} + addKeyValue := func(key string, value string) { + mediaFragment = append(mediaFragment, key...) + if value != "" { + mediaFragment = append(mediaFragment, value...) + } + mediaFragment = append(mediaFragment, "\r\n"...) + } + + if m.info != "" { + addKeyValue("m=", m.info) + } + + if m.mid != "" { + addKeyValue("a=mid:", m.mid) + } + + if m.ice != nil { + iceFragment, err := m.ice.Marshal() + if err != nil { + return "", err + } + mediaFragment = append(mediaFragment, iceFragment...) + } + + for _, c := range m.candidates { + addKeyValue("a=candidate:", c) + } + if m.endOfCandidates != nil && *m.endOfCandidates { + addKeyValue("a=end-of-candidates", "") + } + + return string(mediaFragment), nil +} + +type SDPFragment struct { + group string + ice *sdpFragmentICE + media *sdpFragmentMedia +} + +// primarily for use with WHIP Trickle ICE - https://www.rfc-editor.org/rfc/rfc9725.html#name-trickle-ice +func (s *SDPFragment) Unmarshal(frag string) error { + lines := strings.Split(frag, "\n") + for _, line := range lines { + line = strings.TrimRight(line, " \r") + if len(line) == 0 { + continue + } + + if line[0] == 'm' { + if s.media != nil { + return errors.New("too many media sections") + } + + s.media = &sdpFragmentMedia{} + s.media.ice = &sdpFragmentICE{} + s.media.info = line[2:] + } + + if line[0] != 'a' { + // not an attribute, skip + continue + } + + if line[1] != '=' { + return errors.New("invalid attribute") + } + + attrParts := strings.Split(line[2:], ":") + if len(attrParts) != 2 { + return errors.New("invalid attribute") + } + + if s.ice == nil { + s.ice = &sdpFragmentICE{} + } + + switch attrParts[0] { + case sdp.AttrKeyGroup: + s.group = attrParts[1] + + case "ice-ufrag": + if s.media != nil { + s.media.ice.ufrag = attrParts[1] + } else { + s.ice.ufrag = attrParts[1] + } + + case "ice-pwd": + if s.media != nil { + s.media.ice.pwd = attrParts[1] + } else { + s.ice.pwd = attrParts[1] + } + + case sdp.AttrKeyICELite: + lite := true + if s.media != nil { + s.media.ice.lite = &lite + } else { + s.ice.lite = &lite + } + + case "ice-options": + if s.media != nil { + s.media.ice.options = attrParts[1] + } else { + s.ice.options = attrParts[1] + } + + case sdp.AttrKeyMID: + if s.media != nil { + s.media.mid = attrParts[1] + } + + case sdp.AttrKeyCandidate: + if s.media != nil { + s.media.candidates = append(s.media.candidates, attrParts[1]) + } + + case sdp.AttrKeyEndOfCandidates: + endOfCandidates := true + if s.media != nil { + s.media.endOfCandidates = &endOfCandidates + } + } + } + + if s.media == nil { + return errors.New("missing media section") + } + + if s.group != "" { + bundleIDs := strings.Split(s.group, " ") + if len(bundleIDs) > 1 && strings.EqualFold(bundleIDs[0], "BUNDLE") { + if s.media.mid != bundleIDs[1] { + return fmt.Errorf("bundle media mismatch, expected: %s, got: %s", bundleIDs[1], s.media.mid) + } + } + } + + return nil +} + +// primarily for use with WHIP ICE Restart - https://www.rfc-editor.org/rfc/rfc9725.html#name-ice-restarts +func (s *SDPFragment) Marshal() (string, error) { + sdpFragment := []byte{} + addKeyValue := func(key string, value string) { + sdpFragment = append(sdpFragment, key...) + if value != "" { + sdpFragment = append(sdpFragment, value...) + } + sdpFragment = append(sdpFragment, "\r\n"...) + } + + if s.group != "" { + addKeyValue("a=group:", s.group) + } + + if s.ice != nil { + iceFragment, err := s.ice.Marshal() + if err != nil { + return "", err + } + sdpFragment = append(sdpFragment, iceFragment...) + } + + if s.media != nil { + mediaFragment, err := s.media.Marshal() + if err != nil { + return "", err + } + sdpFragment = append(sdpFragment, mediaFragment...) + } + + return string(sdpFragment), nil +} + +func (s *SDPFragment) Mid() string { + if s.media != nil { + return s.media.mid + } + + return "" +} + +func (s *SDPFragment) Candidates() []string { + if s.media != nil { + return s.media.candidates + } + + return nil +} + +func (s *SDPFragment) ExtractICECredential() (string, string, error) { + pwds := []string{} + ufrags := []string{} + + if s.ice != nil { + if s.ice.ufrag != "" { + ufrags = append(ufrags, s.ice.ufrag) + } + if s.ice.pwd != "" { + pwds = append(pwds, s.ice.pwd) + } + } + + if s.media != nil { + if s.media.ice.ufrag != "" { + ufrags = append(ufrags, s.media.ice.ufrag) + } + if s.media.ice.pwd != "" { + pwds = append(pwds, s.media.ice.pwd) + } + } + + if len(ufrags) == 0 { + return "", "", webrtc.ErrSessionDescriptionMissingIceUfrag + } else if len(pwds) == 0 { + return "", "", webrtc.ErrSessionDescriptionMissingIcePwd + } + + for _, m := range ufrags { + if m != ufrags[0] { + return "", "", webrtc.ErrSessionDescriptionConflictingIceUfrag + } + } + + for _, m := range pwds { + if m != pwds[0] { + return "", "", webrtc.ErrSessionDescriptionConflictingIcePwd + } + } + + return ufrags[0], pwds[0], nil +} + +// primarily for use with WHIP ICE Restart - https://www.rfc-editor.org/rfc/rfc9725.html#name-ice-restarts +func (s *SDPFragment) PatchICECredentialIntoSDP(parsed *sdp.SessionDescription) error { + // ice-options and ice-lite should match + if s.ice != nil && (s.ice.lite != nil || s.ice.options != "") { + for _, a := range parsed.Attributes { + switch a.Key { + case "ice-lite": + if s.ice.lite == nil || !*s.ice.lite { + return errors.New("ice lite mismatch") + } + case "ice-options": + if s.ice.options != "" && s.ice.options != a.Value { + return errors.New("ice options mismatch") + } + } + } + } + if s.media != nil && s.media.mid != "" && s.media.ice != nil && (s.media.ice.lite != nil || s.media.ice.options != "") { + for _, md := range parsed.MediaDescriptions { + mid, found := md.Attribute(sdp.AttrKeyMID) + if !found || mid != s.media.mid { + continue + } + + for _, a := range md.Attributes { + switch a.Key { + case "ice-lite": + if s.media.ice.lite == nil || !*s.media.ice.lite { + return errors.New("ice lite mismatch") + } + case "ice-options": + if s.media.ice.options != "" && s.media.ice.options != a.Value { + return errors.New("ice options mismatch") + } + } + } + } + } + + if s.ice != nil && s.ice.ufrag != "" && s.ice.pwd != "" { + fmt.Printf("patching ufrag/pwd\n") // REMOVE + for idx, a := range parsed.Attributes { + switch a.Key { + case "ice-ufrag": + parsed.Attributes[idx] = sdp.Attribute{ + Key: "ice-ufrag", + Value: s.ice.ufrag, + } + case "ice-pwd": + parsed.Attributes[idx] = sdp.Attribute{ + Key: "ice-pwd", + Value: s.ice.pwd, + } + } + } + } + + if s.media != nil && s.media.mid != "" { + for _, md := range parsed.MediaDescriptions { + mid, found := md.Attribute(sdp.AttrKeyMID) + if !found || mid != s.media.mid { + continue + } + + for idx, a := range md.Attributes { + switch a.Key { + case "ice-ufrag": + if s.media.ice.ufrag != "" { + md.Attributes[idx] = sdp.Attribute{ + Key: "ice-ufrag", + Value: s.media.ice.ufrag, + } + } + case "ice-pwd": + if s.media.ice.pwd != "" { + md.Attributes[idx] = sdp.Attribute{ + Key: "ice-pwd", + Value: s.media.ice.pwd, + } + } + } + } + + // clean out existing candidates and patch in new ones + for idx, a := range md.Attributes { + if a.IsICECandidate() || a.Key == sdp.AttrKeyEndOfCandidates { + md.Attributes = append(md.Attributes[:idx], md.Attributes[idx+1:]...) + } + } + + for _, ic := range s.media.candidates { + md.Attributes = append( + md.Attributes, + sdp.Attribute{ + Key: sdp.AttrKeyCandidate, + Value: ic, + }, + ) + } + + if s.media.endOfCandidates != nil && *s.media.endOfCandidates { + md.Attributes = append( + md.Attributes, + sdp.Attribute{Key: sdp.AttrKeyEndOfCandidates}, + ) + } + } + } + return nil +} + +// primarily for use with WHIP ICE Restart - https://www.rfc-editor.org/rfc/rfc9725.html#name-ice-restarts +func ExtractSDPFragment(parsed *sdp.SessionDescription) (*SDPFragment, error) { + bundleMid, found := GetBundleMid(parsed) + if !found { + return nil, errors.New("could not get bundle mid") + } + + s := &SDPFragment{} + if group, found := parsed.Attribute(sdp.AttrKeyGroup); found { + s.group = group + } + + s.ice = &sdpFragmentICE{} + if err := s.ice.Unmarshal(parsed.Attributes); err != nil { + return nil, err + } + + foundBundleMedia := false + for _, md := range parsed.MediaDescriptions { + mid, found := md.Attribute(sdp.AttrKeyMID) + if !found || mid != bundleMid { + continue + } + + foundBundleMedia = true + + s.media = &sdpFragmentMedia{} + if err := s.media.Unmarshal(md); err != nil { + return nil, err + } + break + } + + if !foundBundleMedia { + return nil, fmt.Errorf("could not find bundle media: %s", bundleMid) + } + + return s, nil +} diff --git a/sdp/sdp_test.go b/sdp/sdp_test.go new file mode 100644 index 0000000..58dc586 --- /dev/null +++ b/sdp/sdp_test.go @@ -0,0 +1,180 @@ +// Copyright 2023 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sdp + +import ( + "testing" + + pionsdp "github.com/pion/sdp/v3" + "github.com/pion/webrtc/v4" + "github.com/stretchr/testify/require" +) + +func TestSDPUtilFunctions(t *testing.T) { + sdp := "v=0\r\no=- 4648475892259889561 3 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0 1\r\na=ice-ufrag:1hhfzwf0ijpzm\r\na=ice-pwd:jm5puo2ab1op3vs59ca53bdk7s\r\na=fingerprint:sha-256 40:42:FB:47:87:52:BF:CB:EC:3A:DF:EB:06:DA:2D:B7:2F:59:42:10:23:7B:9D:4C:C9:58:DD:FF:A2:8F:17:67\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\nc=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=setup:passive\r\na=mid:0\r\na=sendonly\r\na=rtcp-mux\r\na=rtpmap:96 H264/90000\r\na=rtcp-fb:96 nack\r\na=rtcp-fb:96 goog-remb\r\na=fmtp:96 packetization-mode=1;profile-level-id=42e01f\r\na=ssrc:1505338584 cname:10000000b5810aac\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\nc=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=setup:passive\r\na=mid:1\r\na=sendonly\r\na=rtcp-mux\r\na=rtpmap:111 opus/48000/2\r\na=ssrc:697641945 cname:10000000b5810aac\r\n" + sd := webrtc.SessionDescription{ + Type: webrtc.SDPTypeOffer, + SDP: sdp, + } + parsed, err := sd.Unmarshal() + require.NoError(t, err) + + require.Equal(t, "0", GetMidValue(parsed.MediaDescriptions[0])) + require.Equal(t, "1", GetMidValue(parsed.MediaDescriptions[1])) + + fp, alg, err := ExtractFingerprint(parsed) + require.NoError(t, err) + require.Equal(t, "40:42:FB:47:87:52:BF:CB:EC:3A:DF:EB:06:DA:2D:B7:2F:59:42:10:23:7B:9D:4C:C9:58:DD:FF:A2:8F:17:67", fp) + require.Equal(t, "sha-256", alg) + + dtlsRole := ExtractDTLSRole(parsed) + require.Equal(t, webrtc.DTLSRoleServer, dtlsRole) + + ufrag, pwd, err := ExtractICECredential(parsed) + require.NoError(t, err) + require.Equal(t, "1hhfzwf0ijpzm", ufrag) + require.Equal(t, "jm5puo2ab1op3vs59ca53bdk7s", pwd) + + streamID, found := ExtractStreamID(parsed.MediaDescriptions[0]) + require.False(t, found) + require.Empty(t, streamID) + + require.False(t, IsMediaDescriptionSimulcast(parsed.MediaDescriptions[1])) + + codecs, err := CodecsFromMediaDescription(parsed.MediaDescriptions[0]) + require.NoError(t, err) + expectedCodec := pionsdp.Codec{ + PayloadType: 96, + Name: "H264", + ClockRate: 90000, + Fmtp: "packetization-mode=1;profile-level-id=42e01f", + RTCPFeedback: []string{"nack", "goog-remb"}, + } + require.Equal(t, expectedCodec, codecs[0]) + + bundleMid, found := GetBundleMid(parsed) + require.True(t, found) + require.Equal(t, "0", bundleMid) +} + +func TestSDPFragment(t *testing.T) { + fragment := "a=ice-options:trickle ice2\r\na=group:BUNDLE 0 1\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=mid:0\r\na=ice-ufrag:ysXw\r\na=ice-pwd:vw5LmwG4y/e6dPP/zAP9Gp5k\r\na=candidate:1387637174 1 udp 2122260223 192.0.2.1 61764 typ host generation 0 ufrag EsAw network-id 1\r\na=candidate:3471623853 1 udp 2122194687 198.51.100.2 61765 typ host generation 0 ufrag EsAw network-id 2\r\na=candidate:473322822 1 tcp 1518280447 192.0.2.1 9 typ host tcptype active generation 0 ufrag EsAw network-id 1\r\na=candidate:2154773085 1 tcp 1518214911 198.51.100.2 9 typ host tcptype active generation 0 ufrag EsAw network-id 2\r\n" + + expectedSDPFragment := SDPFragment{ + group: "BUNDLE 0 1", + ice: &sdpFragmentICE{ + options: "trickle ice2", + }, + media: &sdpFragmentMedia{ + info: "audio 9 UDP/TLS/RTP/SAVPF 111", + mid: "0", + ice: &sdpFragmentICE{ + ufrag: "ysXw", + pwd: "vw5LmwG4y/e6dPP/zAP9Gp5k", + }, + candidates: []string{ + "1387637174 1 udp 2122260223 192.0.2.1 61764 typ host generation 0 ufrag EsAw network-id 1", + "3471623853 1 udp 2122194687 198.51.100.2 61765 typ host generation 0 ufrag EsAw network-id 2", + "473322822 1 tcp 1518280447 192.0.2.1 9 typ host tcptype active generation 0 ufrag EsAw network-id 1", + "2154773085 1 tcp 1518214911 198.51.100.2 9 typ host tcptype active generation 0 ufrag EsAw network-id 2", + }, + }, + } + + sdpFragment := &SDPFragment{} + err := sdpFragment.Unmarshal(fragment) + require.NoError(t, err) + require.Equal(t, expectedSDPFragment, *sdpFragment) + + expectedMarshalled := "a=group:BUNDLE 0 1\r\na=ice-options:trickle ice2\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=mid:0\r\na=ice-ufrag:ysXw\r\na=ice-pwd:vw5LmwG4y/e6dPP/zAP9Gp5k\r\na=candidate:1387637174 1 udp 2122260223 192.0.2.1 61764 typ host generation 0 ufrag EsAw network-id 1\r\na=candidate:3471623853 1 udp 2122194687 198.51.100.2 61765 typ host generation 0 ufrag EsAw network-id 2\r\na=candidate:473322822 1 tcp 1518280447 192.0.2.1 9 typ host tcptype active generation 0 ufrag EsAw network-id 1\r\na=candidate:2154773085 1 tcp 1518214911 198.51.100.2 9 typ host tcptype active generation 0 ufrag EsAw network-id 2\r\n" + marshalled, err := sdpFragment.Marshal() + require.NoError(t, err) + require.Equal(t, expectedMarshalled, marshalled) + + expectedCandidates := []string{ + "1387637174 1 udp 2122260223 192.0.2.1 61764 typ host generation 0 ufrag EsAw network-id 1", + "3471623853 1 udp 2122194687 198.51.100.2 61765 typ host generation 0 ufrag EsAw network-id 2", + "473322822 1 tcp 1518280447 192.0.2.1 9 typ host tcptype active generation 0 ufrag EsAw network-id 1", + "2154773085 1 tcp 1518214911 198.51.100.2 9 typ host tcptype active generation 0 ufrag EsAw network-id 2", + } + + sdpFragment1 := &SDPFragment{} + err = sdpFragment1.Unmarshal(marshalled) + require.NoError(t, err) + require.Equal(t, expectedSDPFragment, *sdpFragment1) + require.Equal(t, "0", sdpFragment1.Mid()) + require.Equal(t, expectedCandidates, sdpFragment1.Candidates()) + ufrag, pwd, err := sdpFragment1.ExtractICECredential() + require.NoError(t, err) + require.Equal(t, "ysXw", ufrag) + require.Equal(t, "vw5LmwG4y/e6dPP/zAP9Gp5k", pwd) + + mismatchedMidFragment := "a=ice-options:trickle ice2\r\na=group:BUNDLE 0 1\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=mid:1\r\na=ice-ufrag:ysXw\r\na=ice-pwd:vw5LmwG4y/e6dPP/zAP9Gp5k\r\na=candidate:1387637174 1 udp 2122260223 192.0.2.1 61764 typ host generation 0 ufrag EsAw network-id 1\r\na=candidate:3471623853 1 udp 2122194687 198.51.100.2 61765 typ host generation 0 ufrag EsAw network-id 2\r\na=candidate:473322822 1 tcp 1518280447 192.0.2.1 9 typ host tcptype active generation 0 ufrag EsAw network-id 1\r\na=candidate:2154773085 1 tcp 1518214911 198.51.100.2 9 typ host tcptype active generation 0 ufrag EsAw network-id 2\r\n" + sdpFragment2 := &SDPFragment{} + err = sdpFragment2.Unmarshal(mismatchedMidFragment) + require.Error(t, err) + + sdp := "v=0\r\no=- 4648475892259889561 3 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0 1\r\na=ice-options:trickle ice2\r\na=fingerprint:sha-256 40:42:FB:47:87:52:BF:CB:EC:3A:DF:EB:06:DA:2D:B7:2F:59:42:10:23:7B:9D:4C:C9:58:DD:FF:A2:8F:17:67\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\nc=IN IP4 0.0.0.0\r\na=ice-ufrag:1hhfzwf0ijpzm\r\na=ice-pwd:jm5puo2ab1op3vs59ca53bdk7s\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=setup:passive\r\na=mid:0\r\na=sendonly\r\na=rtcp-mux\r\na=rtpmap:96 H264/90000\r\na=rtcp-fb:96 nack\r\na=rtcp-fb:96 goog-remb\r\na=fmtp:96 packetization-mode=1;profile-level-id=42e01f\r\na=ssrc:1505338584 cname:10000000b5810aac\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\nc=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=setup:passive\r\na=mid:1\r\na=sendonly\r\na=rtcp-mux\r\na=rtpmap:111 opus/48000/2\r\na=ssrc:697641945 cname:10000000b5810aac\r\n" + sd := webrtc.SessionDescription{ + Type: webrtc.SDPTypeOffer, + SDP: sdp, + } + parsed, _ := sd.Unmarshal() + err = sdpFragment1.PatchICECredentialIntoSDP(parsed) + require.NoError(t, err) + + ufrag, pwd, err = ExtractICECredential(parsed) + require.NoError(t, err) + require.Equal(t, "ysXw", ufrag) + require.Equal(t, "vw5LmwG4y/e6dPP/zAP9Gp5k", pwd) + + candidates := []string{} + for _, a := range parsed.MediaDescriptions[0].Attributes { + if a.IsICECandidate() { + candidates = append(candidates, a.Value) + } + } + require.Equal(t, expectedCandidates, candidates) + + sdpFragment3, err := ExtractSDPFragment(parsed) + require.NoError(t, err) + + expectedSDPFragment3 := SDPFragment{ + group: "BUNDLE 0 1", + ice: &sdpFragmentICE{ + options: "trickle ice2", + }, + media: &sdpFragmentMedia{ + info: "video 9 UDP/TLS/RTP/SAVPF 96", + mid: "0", + ice: &sdpFragmentICE{ + ufrag: "ysXw", + pwd: "vw5LmwG4y/e6dPP/zAP9Gp5k", + }, + candidates: []string{ + "1387637174 1 udp 2122260223 192.0.2.1 61764 typ host generation 0 ufrag EsAw network-id 1", + "3471623853 1 udp 2122194687 198.51.100.2 61765 typ host generation 0 ufrag EsAw network-id 2", + "473322822 1 tcp 1518280447 192.0.2.1 9 typ host tcptype active generation 0 ufrag EsAw network-id 1", + "2154773085 1 tcp 1518214911 198.51.100.2 9 typ host tcptype active generation 0 ufrag EsAw network-id 2", + }, + }, + } + require.Equal(t, expectedSDPFragment3, *sdpFragment3) + + marshalledSDPFragment3, err := sdpFragment3.Marshal() + require.NoError(t, err) + expectedMarshalledSDPFragment3 := "a=group:BUNDLE 0 1\r\na=ice-options:trickle ice2\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=mid:0\r\na=ice-ufrag:ysXw\r\na=ice-pwd:vw5LmwG4y/e6dPP/zAP9Gp5k\r\na=candidate:1387637174 1 udp 2122260223 192.0.2.1 61764 typ host generation 0 ufrag EsAw network-id 1\r\na=candidate:3471623853 1 udp 2122194687 198.51.100.2 61765 typ host generation 0 ufrag EsAw network-id 2\r\na=candidate:473322822 1 tcp 1518280447 192.0.2.1 9 typ host tcptype active generation 0 ufrag EsAw network-id 1\r\na=candidate:2154773085 1 tcp 1518214911 198.51.100.2 9 typ host tcptype active generation 0 ufrag EsAw network-id 2\r\n" + require.Equal(t, expectedMarshalledSDPFragment3, marshalledSDPFragment3) +}