diff --git a/sdpfrag/sdpfrag.go b/sdpfrag/sdpfrag.go new file mode 100644 index 0000000..b8afe0e --- /dev/null +++ b/sdpfrag/sdpfrag.go @@ -0,0 +1,228 @@ +// Package sdpfrag is an incomplete implementation of RFC 8840 Section 9 +package sdpfrag + +import ( + "bufio" + "bytes" + "errors" + "fmt" + + "github.com/pion/sdp/v3" + "github.com/pion/webrtc/v4" +) + +// An SDPFrag contains the ICE-related subset of a session description. +type SDPFrag struct { + UsernameFragment, Password string + Candidates []webrtc.ICECandidateInit + MediaDescriptions []MediaDescription +} + +type MediaDescription struct { + MLine string + Mid string + UsernameFragment, Password string + Candidates []webrtc.ICECandidateInit +} + +func (s *SDPFrag) Unmarshal(value []byte) error { + scanner := bufio.NewScanner(bytes.NewReader(value)) + + var mediaDescription *MediaDescription + + for scanner.Scan() { + l := scanner.Bytes() + if bytes.HasPrefix(l, []byte("a=ice-ufrag:")) { + ufrag := string(l[len("a=ice-ufrag:"):]) + if mediaDescription == nil { + s.UsernameFragment = ufrag + } else { + mediaDescription.UsernameFragment = ufrag + } + } else if bytes.HasPrefix(l, []byte("a=ice-pwd:")) { + pwd := string(l[len("a=ice-pwd:"):]) + if mediaDescription == nil { + s.Password = pwd + } else { + mediaDescription.Password = pwd + } + } else if bytes.HasPrefix(l, []byte("m=")) { + if mediaDescription != nil { + s.MediaDescriptions = append( + s.MediaDescriptions, + *mediaDescription, + ) + } + mediaDescription = &MediaDescription{ + MLine: string(l[len("m="):]), + } + } else if bytes.HasPrefix(l, []byte("a=mid:")) { + if mediaDescription == nil { + return errors.New("unexpected mid") + } + mediaDescription.Mid = string(l[len("a=mid:"):]) + } else if bytes.HasPrefix(l, []byte("a=candidate:")) { + init := webrtc.ICECandidateInit{ + Candidate: string(l[len("a=candidate:"):]), + } + if len(s.UsernameFragment) > 0 { + ufrag := s.UsernameFragment + init.UsernameFragment = &ufrag + } + if mediaDescription != nil { + i := uint16(len(s.MediaDescriptions)) + init.SDPMLineIndex = &i + m := mediaDescription.Mid + init.SDPMid = &m + mediaDescription.Candidates = append( + mediaDescription.Candidates, + init, + ) + } else { + s.Candidates = append(s.Candidates, init) + } + } + } + if mediaDescription != nil { + s.MediaDescriptions = append( + s.MediaDescriptions, + *mediaDescription, + ) + } + return nil +} + +func (s *SDPFrag) Marshal() ([]byte, error) { + w := new(bytes.Buffer) + + if s.UsernameFragment != "" { + fmt.Fprintf(w, "a=ice-ufrag:%v\r\n", s.UsernameFragment) + } + if s.Password != "" { + fmt.Fprintf(w, "a=ice-pwd:%v\r\n", s.Password) + } + for _, c := range s.Candidates { + fmt.Fprintf(w, "a=%v\r\n", c.Candidate) + } + for _, m := range s.MediaDescriptions { + fmt.Fprintf(w, "m=%v\r\n", m.MLine) + fmt.Fprintf(w, "a=mid:%v\r\n", m.Mid) + if m.UsernameFragment != "" { + fmt.Fprintf(w, "a=ice-ufrag:%v\r\n", m.UsernameFragment) + } + if m.Password != "" { + fmt.Fprintf(w, "a=ice-pwd:%v\r\n", m.Password) + } + for _, c := range m.Candidates { + fmt.Fprintf(w, "a=candidate:%v\r\n", c.Candidate) + } + } + + return w.Bytes(), nil +} + +func patch(ufrag, pwd string, old []sdp.Attribute, cs []webrtc.ICECandidateInit, override bool) []sdp.Attribute { + var as []sdp.Attribute + for _, a := range old { + if a.Key == "ice-ufrag" { + if ufrag == "" { + continue + } + as = append(as, sdp.Attribute{ + Key: "ice-ufrag", + Value: ufrag, + }) + } else if a.Key == "ice-pwd" { + if pwd == "" { + continue + } + as = append(as, sdp.Attribute{ + Key: "ice-pwd", + Value: pwd, + }) + } else if !override || a.Key != "candidate" { + as = append(as, a) + } + } + for _, c := range cs { + as = append(as, sdp.Attribute{ + Key: "candidate", + Value: c.Candidate, + }) + } + return as +} + +// SDPUFragPwd returns the ICE ufrag and password of an SDP. +// In principle, the ufrag/pwd should be at the same place in the +// frag as in the original SDP, RFC 8840 Section 9. However, we +// only do bundle, and some implementations put the data at random +// places, so we walk the whole structure to find the ufrag/pwd pair. +func SDPUFragPwd(s sdp.SessionDescription) (string, string) { + ufrag, _ := s.Attribute("ice-ufrag") + pwd, _ := s.Attribute("ice-pwd") + if ufrag != "" { + return ufrag, pwd + } + for _, m := range s.MediaDescriptions { + ufrag, _ := m.Attribute("ice-ufrag") + pwd, _ := m.Attribute("ice-pwd") + if ufrag != "" { + return ufrag, pwd + } + } + return "", "" +} + +// UFragPwd returns the ICE ufrag and password of a fragment. +// See the description of [SDPUFragPwd] for details. +func (f *SDPFrag) UFragPwd() (string, string) { + ufrag := f.UsernameFragment + pwd := f.Password + if ufrag != "" { + return ufrag, pwd + } + for _, m := range f.MediaDescriptions { + ufrag := m.UsernameFragment + pwd := m.Password + if ufrag != "" { + return ufrag, pwd + } + } + return "", "" +} + +func (f *SDPFrag) AllCandidates() []webrtc.ICECandidateInit { + var cs []webrtc.ICECandidateInit + cs = append(cs, f.Candidates...) + for _, m := range f.MediaDescriptions { + cs = append(cs, m.Candidates...) + } + return cs +} + +// PatchSDP applies an [SDPFrag] to an [sdp.SessionDescription], +// either adding or replacing candidates depending on the ufrag/password +// values. +func PatchSDP(s sdp.SessionDescription, f SDPFrag) (sdp.SessionDescription, bool) { + ufrag, pwd := SDPUFragPwd(s) + ufrag1, pwd1 := f.UFragPwd() + + override := (ufrag != ufrag1) || (pwd != pwd1) + + s.Attributes = patch(ufrag1, pwd1, s.Attributes, f.Candidates, override) + + old := s.MediaDescriptions + s.MediaDescriptions = nil + for i, m := range old { + var mm MediaDescription + if i < len(f.MediaDescriptions) { + mm = f.MediaDescriptions[i] + } + m.Attributes = patch( + ufrag1, pwd1, m.Attributes, mm.Candidates, override, + ) + s.MediaDescriptions = append(s.MediaDescriptions, m) + } + return s, override +} diff --git a/sdpfrag/sdpfrag_test.go b/sdpfrag/sdpfrag_test.go new file mode 100644 index 0000000..96dc92a --- /dev/null +++ b/sdpfrag/sdpfrag_test.go @@ -0,0 +1,302 @@ +package sdpfrag + +import ( + "testing" + "reflect" + + "github.com/pion/sdp/v3" +) + +var sdpOffer = `v=0 +o=- 999478090166020379 2 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE 0 1 +a=extmap-allow-mixed +a=msid-semantic: WMS ef5d4db1-5c64-4b6e-9f97-8b1f3e598bfe +m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 13 110 126 +c=IN IP4 0.0.0.0 +a=rtcp:9 IN IP4 0.0.0.0 +a=ice-ufrag:qiKa +a=ice-pwd:bcfs93hb/+ZLuUE2K50HVbkr +a=ice-options:trickle +a=fingerprint:sha-256 EF:FE:1C:DA:83:C0:AF:B3:12:31:42:32:A4:37:04:5A:BE:7A:8D:BA:9D:0B:F2:A0:81:17:51:60:F4:96:11:5D +a=setup:actpass +a=mid:0 +a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level +a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time +a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 +a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid +a=sendrecv +a=msid:ef5d4db1-5c64-4b6e-9f97-8b1f3e598bfe d71aa4c4-6f2a-480a-9c83-75e95ef1519c +a=rtcp-mux +a=rtcp-rsize +a=rtpmap:111 opus/48000/2 +a=rtcp-fb:111 transport-cc +a=fmtp:111 minptime=10;useinbandfec=1 +a=rtpmap:63 red/48000/2 +a=fmtp:63 111/111 +a=rtpmap:9 G722/8000 +a=rtpmap:0 PCMU/8000 +a=rtpmap:8 PCMA/8000 +a=rtpmap:13 CN/8000 +a=rtpmap:110 telephone-event/48000 +a=rtpmap:126 telephone-event/8000 +a=ssrc:870576534 cname:VkgQMWvIx9VqAt+A +a=ssrc:870576534 msid:ef5d4db1-5c64-4b6e-9f97-8b1f3e598bfe d71aa4c4-6f2a-480a-9c83-75e95ef1519c +m=video 9 UDP/TLS/RTP/SAVPF 96 97 103 104 107 108 109 114 115 116 117 118 39 40 45 46 98 99 100 101 119 120 121 +c=IN IP4 0.0.0.0 +a=rtcp:9 IN IP4 0.0.0.0 +a=ice-ufrag:qiKa +a=ice-pwd:bcfs93hb/+ZLuUE2K50HVbkr +a=ice-options:trickle +a=fingerprint:sha-256 EF:FE:1C:DA:83:C0:AF:B3:12:31:42:32:A4:37:04:5A:BE:7A:8D:BA:9D:0B:F2:A0:81:17:51:60:F4:96:11:5D +a=setup:actpass +a=mid:1 +a=extmap:14 urn:ietf:params:rtp-hdrext:toffset +a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time +a=extmap:13 urn:3gpp:video-orientation +a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 +a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay +a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type +a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing +a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space +a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid +a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id +a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id +a=sendrecv +a=msid:ef5d4db1-5c64-4b6e-9f97-8b1f3e598bfe 4fada03b-35ae-435d-b08e-001b6ce8362f +a=rtcp-mux +a=rtcp-rsize +a=rtpmap:96 VP8/90000 +a=rtcp-fb:96 goog-remb +a=rtcp-fb:96 transport-cc +a=rtcp-fb:96 ccm fir +a=rtcp-fb:96 nack +a=rtcp-fb:96 nack pli +a=rtpmap:97 rtx/90000 +a=fmtp:97 apt=96 +a=rtpmap:103 H264/90000 +a=rtcp-fb:103 goog-remb +a=rtcp-fb:103 transport-cc +a=rtcp-fb:103 ccm fir +a=rtcp-fb:103 nack +a=rtcp-fb:103 nack pli +a=fmtp:103 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f +a=rtpmap:104 rtx/90000 +a=fmtp:104 apt=103 +a=rtpmap:107 H264/90000 +a=rtcp-fb:107 goog-remb +a=rtcp-fb:107 transport-cc +a=rtcp-fb:107 ccm fir +a=rtcp-fb:107 nack +a=rtcp-fb:107 nack pli +a=fmtp:107 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f +a=rtpmap:108 rtx/90000 +a=fmtp:108 apt=107 +a=rtpmap:109 H264/90000 +a=rtcp-fb:109 goog-remb +a=rtcp-fb:109 transport-cc +a=rtcp-fb:109 ccm fir +a=rtcp-fb:109 nack +a=rtcp-fb:109 nack pli +a=fmtp:109 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f +a=rtpmap:114 rtx/90000 +a=fmtp:114 apt=109 +a=rtpmap:115 H264/90000 +a=rtcp-fb:115 goog-remb +a=rtcp-fb:115 transport-cc +a=rtcp-fb:115 ccm fir +a=rtcp-fb:115 nack +a=rtcp-fb:115 nack pli +a=fmtp:115 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f +a=rtpmap:116 rtx/90000 +a=fmtp:116 apt=115 +a=rtpmap:117 H264/90000 +a=rtcp-fb:117 goog-remb +a=rtcp-fb:117 transport-cc +a=rtcp-fb:117 ccm fir +a=rtcp-fb:117 nack +a=rtcp-fb:117 nack pli +a=fmtp:117 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f +a=rtpmap:118 rtx/90000 +a=fmtp:118 apt=117 +a=rtpmap:39 H264/90000 +a=rtcp-fb:39 goog-remb +a=rtcp-fb:39 transport-cc +a=rtcp-fb:39 ccm fir +a=rtcp-fb:39 nack +a=rtcp-fb:39 nack pli +a=fmtp:39 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f +a=rtpmap:40 rtx/90000 +a=fmtp:40 apt=39 +a=rtpmap:45 AV1/90000 +a=rtcp-fb:45 goog-remb +a=rtcp-fb:45 transport-cc +a=rtcp-fb:45 ccm fir +a=rtcp-fb:45 nack +a=rtcp-fb:45 nack pli +a=fmtp:45 level-idx=5;profile=0;tier=0 +a=rtpmap:46 rtx/90000 +a=fmtp:46 apt=45 +a=rtpmap:98 VP9/90000 +a=rtcp-fb:98 goog-remb +a=rtcp-fb:98 transport-cc +a=rtcp-fb:98 ccm fir +a=rtcp-fb:98 nack +a=rtcp-fb:98 nack pli +a=fmtp:98 profile-id=0 +a=rtpmap:99 rtx/90000 +a=fmtp:99 apt=98 +a=rtpmap:100 VP9/90000 +a=rtcp-fb:100 goog-remb +a=rtcp-fb:100 transport-cc +a=rtcp-fb:100 ccm fir +a=rtcp-fb:100 nack +a=rtcp-fb:100 nack pli +a=fmtp:100 profile-id=2 +a=rtpmap:101 rtx/90000 +a=fmtp:101 apt=100 +a=rtpmap:119 red/90000 +a=rtpmap:120 rtx/90000 +a=fmtp:120 apt=119 +a=rtpmap:121 ulpfec/90000 +a=ssrc-group:FID 18877847 3714356054 +a=ssrc:18877847 cname:VkgQMWvIx9VqAt+A +a=ssrc:18877847 msid:ef5d4db1-5c64-4b6e-9f97-8b1f3e598bfe 4fada03b-35ae-435d-b08e-001b6ce8362f +a=ssrc:3714356054 cname:VkgQMWvIx9VqAt+A +a=ssrc:3714356054 msid:ef5d4db1-5c64-4b6e-9f97-8b1f3e598bfe 4fada03b-35ae-435d-b08e-001b6ce8362f +` + +var sdpfragTrickle = `a=ice-ufrag:qiKa +a=ice-pwd:bcfs93hb/+ZLuUE2K50HVbkr +m=audio 9 UDP/TLS/RTP/SAVPF 0 +a=mid:0 +a=candidate:1937612317 1 udp 2113937151 40a192af-d71f-4cf2-b5b4-494a95dcf739.local 44501 typ host generation 0 ufrag qiKa network-cost 999 +a=candidate:670660484 1 udp 2113939711 47da31ec-00cb-4c68-bc90-c403b203d8d8.local 45749 typ host generation 0 ufrag qiKa network-cost 999 +` + +var sdpfragEndOfCandidates = `a=ice-ufrag:qiKa +a=ice-pwd:bcfs93hb/+ZLuUE2K50HVbkr +m=audio 9 UDP/TLS/RTP/SAVPF 0 +a=mid:0 +a=end-of-candidates +` + +var sdpfragRestart = `a=ice-ufrag:HWmk +a=ice-pwd:6kyIjoI1lVhYJUeo1EyUq0Ei +` + +func TestParseSDPFrag(t *testing.T) { + var frag SDPFrag + err := frag.Unmarshal([]byte(sdpfragTrickle)) + if err != nil { + t.Errorf("Unmarshal: %v", err) + } + if frag.UsernameFragment != "qiKa" || + frag.Password != "bcfs93hb/+ZLuUE2K50HVbkr" { + t.Errorf("Ufrag %v, pwd %v", frag.UsernameFragment, frag.Password) + } + if len(frag.Candidates) != 0 { + t.Errorf("Expected 0, got %v", len(frag.Candidates)) + } + if len(frag.MediaDescriptions) != 1 { + t.Errorf("Expected 1, got %v", len(frag.MediaDescriptions)) + } + if len(frag.MediaDescriptions[0].Candidates) != 2 { + t.Errorf("Expected 1, got %v", len(frag.MediaDescriptions[0].Candidates)) + } + c := frag.MediaDescriptions[0].Candidates[0] + if *c.SDPMLineIndex != 0 || + *c.SDPMid != "0" || + *c.UsernameFragment != "qiKa" { + t.Errorf("Got %v", c) + } +} + +func testRoundtrip(t *testing.T, sdpfrag string) { + var frag SDPFrag + err := frag.Unmarshal([]byte(sdpfrag)) + if err != nil { + t.Errorf("Unmarshal: %v", err) + } + + sdpfrag2, err := frag.Marshal() + if err != nil { + t.Errorf("Marshal: %v", err) + } + + var frag2 SDPFrag + err = frag2.Unmarshal(sdpfrag2) + if err != nil { + t.Errorf("Unmarshal: %v", err) + } + + if !reflect.DeepEqual(frag, frag2) { + t.Errorf("Not equal: %v %v", frag, frag2) + } +} + +func TestRoundtrip(t *testing.T) { + type test struct { name, frag string } + for _, tt := range []test{ + {"sdpfragTrickle", sdpfragTrickle}, + {"sdpfragEndOfCandidates", sdpfragEndOfCandidates}, + {"sdpfragRestart", sdpfragRestart}, + } { + t.Run(tt.name, func(t *testing.T) { + testRoundtrip(t, tt.frag) + }) + } +} + +func testPatchSDP(t *testing.T, sdpOffer, sdpFrag, ufrag string, ncandidates int) { + var s sdp.SessionDescription + err := s.Unmarshal([]byte(sdpOffer)) + if err != nil { + t.Fatalf("Unmarshal SDP: %v", err) + } + + var f SDPFrag + err = f.Unmarshal([]byte(sdpFrag)) + if err != nil { + t.Fatalf("Unmarshal frag: %v", err) + } + + ss, _ := PatchSDP(s, f) + u, _ := ss.MediaDescriptions[0].Attribute("ice-ufrag") + if u != ufrag { + t.Errorf("UFrag mismatch: %v and %v", u, ufrag) + } + + nc := 0 + for _, a := range ss.Attributes { + if a.Key == "candidate" { + nc++ + } + } + + for _, m := range ss.MediaDescriptions { + for _, a := range m.Attributes { + if a.Key == "candidate" { + nc++ + } + } + } + if nc != ncandidates { + t.Errorf("Candidate mismatch: got %v, expected %v", + nc, ncandidates) + } +} + +func TestPatchSDP(t *testing.T) { + t.Run("trickle", func(t *testing.T) { + testPatchSDP(t, sdpOffer, sdpfragTrickle, "qiKa", 2) + }) + t.Run("end-of-candidates", func(t *testing.T) { + testPatchSDP(t, sdpOffer, sdpfragEndOfCandidates, "qiKa", 0) + }) + t.Run("restart", func(t *testing.T) { + testPatchSDP(t, sdpOffer, sdpfragRestart, "HWmk", 0) + }) +} diff --git a/webserver/whip.go b/webserver/whip.go index a4504c8..b2346d3 100644 --- a/webserver/whip.go +++ b/webserver/whip.go @@ -1,8 +1,6 @@ package webserver import ( - "bufio" - "bytes" "crypto/aes" "crypto/cipher" crand "crypto/rand" @@ -22,6 +20,7 @@ import ( "github.com/jech/galene/group" "github.com/jech/galene/ice" "github.com/jech/galene/rtpconn" + "github.com/jech/galene/sdpfrag" ) var idSecret []byte @@ -358,9 +357,15 @@ func whipResourceHandler(w http.ResponseWriter, r *http.Request) { return } - ufrag, pwd, candidates, err := parseSDPFrag( - http.MaxBytesReader(w, r.Body, sdpLimit), - ) + data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, sdpLimit)) + if err != nil { + http.Error(w, "internal server error", + http.StatusInternalServerError) + return + } + + var frag sdpfrag.SDPFrag + err = frag.Unmarshal(data) if err != nil { log.Printf("WHIP trickle ICE: %v", err) http.Error(w, "bad request", http.StatusBadRequest) @@ -374,57 +379,18 @@ func whipResourceHandler(w http.ResponseWriter, r *http.Request) { ) return } - if u != ufrag || p != pwd { + uu, pp := frag.UFragPwd() + if uu != u || pp != p { http.Error(w, "ICE restarts are not supported yet", http.StatusUnprocessableEntity, ) return } - for _, init := range candidates { + for _, init := range frag.AllCandidates() { err := c.GotICECandidate(init) if err != nil { log.Printf("WHIP candidate: %v", err) } } - w.WriteHeader(http.StatusNoContent) } - -// RFC 8840 -func parseSDPFrag(r io.Reader) (string, string, []webrtc.ICECandidateInit, error) { - scanner := bufio.NewScanner(r) - mLineIndex := -1 - var mid, ufrag, pwd []byte - var candidates []webrtc.ICECandidateInit - for scanner.Scan() { - l := scanner.Bytes() - if bytes.HasPrefix(l, []byte("a=ice-ufrag:")) { - ufrag = l[len("a=ice-ufrag:"):] - } else if bytes.HasPrefix(l, []byte("a=ice-pwd:")) { - pwd = l[len("a=ice-pwd:"):] - } else if bytes.HasPrefix(l, []byte("m=")) { - mLineIndex++ - mid = nil - } else if bytes.HasPrefix(l, []byte("a=mid:")) { - mid = l[len("a=mid:"):] - } else if bytes.HasPrefix(l, []byte("a=candidate:")) { - init := webrtc.ICECandidateInit{ - Candidate: string(l[2:]), - } - if len(mid) > 0 { - s := string(mid) - init.SDPMid = &s - } - if mLineIndex >= 0 { - i := uint16(mLineIndex) - init.SDPMLineIndex = &i - } - if len(ufrag) > 0 { - s := string(ufrag) - init.UsernameFragment = &s - } - candidates = append(candidates, init) - } - } - return string(ufrag), string(pwd), candidates, nil -} diff --git a/webserver/whip_test.go b/webserver/whip_test.go deleted file mode 100644 index 4a7ddd2..0000000 --- a/webserver/whip_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package webserver - -import ( - "strings" - "testing" -) - -func TestParseSDPFrag(t *testing.T) { - sdp := `a=ice-ufrag:FZ0m -a=ice-pwd:NRT+gj1EhsEwMm9MA7ljzBRy -m=audio 9 UDP/TLS/RTP/SAVPF 0 -a=mid:0 -a=candidate:2930517337 1 udp 2113937151 1eaafdf1-4127-499f-90d4-8c35ea49d5e6.local 44360 typ host generation 0 ufrag FZ0m network-cost 999 -2024/09/30 00:07:41 {candidate:2930517337 1 udp 2113937151 1eaafdf1-4127-499f-90d4-8c35ea49d5e6.local 44360 typ host generation 0 ufrag FZ0m network-cost 999 0xc00062a580 0xc000620288 0xc00062a590} -a=end-of-candidates` - r := strings.NewReader(sdp) - ufrag, pwd, candidates, err := parseSDPFrag(r) - if err != nil { - t.Errorf("parseSDPFrag: %v", err) - } - if ufrag != "FZ0m" || pwd != "NRT+gj1EhsEwMm9MA7ljzBRy" { - t.Errorf("Ufrag %v, pwd %v", ufrag, pwd) - } - if len(candidates) != 1 { - t.Errorf("Expected 1, got %v", candidates) - } - if *candidates[0].SDPMLineIndex != 0 || - *candidates[0].SDPMid != "0" || - *candidates[0].UsernameFragment != "FZ0m" { - t.Errorf("Got %v", candidates[0]) - } -}