diff --git a/internal/proxy/rtc.go b/internal/proxy/rtc.go index 48a3e1e8f..ae9bacfef 100644 --- a/internal/proxy/rtc.go +++ b/internal/proxy/rtc.go @@ -6,6 +6,7 @@ package proxy import ( "context" "encoding/binary" + "encoding/json" "fmt" "io" "io/ioutil" @@ -241,13 +242,17 @@ func (v *webRTCProxyServer) proxyApiToBackend( localSDPAnswer = strings.Replace(localSDPAnswer, from, to, -1) } - // Fetch the ice-ufrag and ice-pwd from local SDP answer. - remoteICEUfrag, remoteICEPwd, err := utils.ParseIceUfragPwd(remoteSDPOffer) + // Fetch the ice-ufrag and ice-pwd from local SDP answer. The legacy SRS + // /rtc/v1/play/ and /rtc/v1/publish/ APIs wrap the SDP in a JSON envelope + // like {"sdp":"v=0\r\n..."}, so unwrap it before parsing ICE attributes. + // The forwarded bytes and the in-body candidate port rewrite still operate + // on the raw envelope, which is what the client expects to see back. + remoteICEUfrag, remoteICEPwd, err := utils.ParseIceUfragPwd(unwrapSDPEnvelope(remoteSDPOffer)) if err != nil { return errors.Wrapf(err, "parse remote sdp offer") } - localICEUfrag, localICEPwd, err := utils.ParseIceUfragPwd(localSDPAnswer) + localICEUfrag, localICEPwd, err := utils.ParseIceUfragPwd(unwrapSDPEnvelope(localSDPAnswer)) if err != nil { return errors.Wrapf(err, "parse local sdp answer") } @@ -520,6 +525,25 @@ func (v *rtcConnection) connectBackend(ctx context.Context) error { return nil } +// unwrapSDPEnvelope returns the SDP string carried inside the legacy SRS RTC +// JSON envelope used by /rtc/v1/play/ and /rtc/v1/publish/, e.g. body of the +// form {"sdp":"v=0\r\n...", ...}. For standards-based WHIP/WHEP bodies (raw +// SDP), or any input we can't recognise, the original body is returned +// unchanged so the caller can parse it as raw SDP. +func unwrapSDPEnvelope(body string) string { + trimmed := strings.TrimLeft(body, " \t\r\n") + if !strings.HasPrefix(trimmed, "{") { + return body + } + var env struct { + SDP string `json:"sdp"` + } + if err := json.Unmarshal([]byte(trimmed), &env); err != nil || env.SDP == "" { + return body + } + return env.SDP +} + type rtcICEPair struct { // The remote ufrag, used for ICE username and session id. RemoteICEUfrag string `json:"remote_ufrag"` diff --git a/internal/proxy/rtc_test.go b/internal/proxy/rtc_test.go index 64f3ac8cc..68696ad45 100644 --- a/internal/proxy/rtc_test.go +++ b/internal/proxy/rtc_test.go @@ -896,6 +896,95 @@ func TestWebRTCProxyServer_HandleApiForWHEP_HappyPath(t *testing.T) { } } +// Legacy /rtc/v1/play/ (used by srs_bench) wraps the SDP in a JSON envelope +// like {"sdp":"v=0\r\n..."} where \r\n is the literal 2-byte JSON escape, not +// real CRLF. The proxy must unwrap the envelope before parsing ICE attributes; +// otherwise the stored ufrag is contaminated with the next attributes and the +// STUN binding from the client cannot be matched to the connection. +func TestWebRTCProxyServer_HandleApiForWHEP_LegacyJSONEnvelope(t *testing.T) { + f := newWebRTCFixture() + f.env.WebRTCServerReturns("19000") + + const backendRTCPort = "18000" + answerJSON := `{"code":0,"sessionid":"sid","sdp":"v=0\r\na=ice-ufrag:local-ufrag\r\na=ice-pwd:local-pwd-very-long-value-32xxxx\r\na=candidate:1 1 udp 1 1.2.3.4 ` + backendRTCPort + ` typ host\r\n"}` + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(answerJSON)) + })) + defer backend.Close() + + f.server.backendURL = func(b *lb.OriginServer, r *http.Request) (string, error) { + return backend.URL + r.URL.Path, nil + } + f.lb.PickReturns(&lb.OriginServer{IP: "10.0.0.1", API: []string{"1985"}, RTC: []string{backendRTCPort}}, nil) + + offerJSON := `{"api":"http://10.0.0.1:1985/rtc/v1/play/","clientip":"","sdp":"v=0\r\na=ice-ufrag:remote-ufrag\r\na=ice-pwd:remote-pwd-very-long-value-32xx\r\n","streamurl":"webrtc://example.com/live/demo"}` + req := httptest.NewRequest(http.MethodPost, "http://example.com/rtc/v1/play/", strings.NewReader(offerJSON)) + rec := httptest.NewRecorder() + + if err := f.server.HandleApiForWHEP(context.Background(), rec, req); err != nil { + t.Fatalf("WHEP: %v", err) + } + if f.lb.StoreWebRTCCallCount() != 1 { + t.Fatalf("StoreWebRTC called %d times, want 1", f.lb.StoreWebRTCCallCount()) + } + _, _, stored := f.lb.StoreWebRTCArgsForCall(0) + if got, want := stored.GetUfrag(), "local-ufrag:remote-ufrag"; got != want { + t.Fatalf("stored ufrag=%q, want %q", got, want) + } + // The response forwarded to the client should still be the JSON envelope + // with the backend port rewritten to the proxy's WebRTC port. + body := rec.Body.String() + if !strings.Contains(body, " 19000 typ host") { + t.Fatalf("answer did not rewrite backend port; got %q", body) + } + if strings.Contains(body, " "+backendRTCPort+" typ host") { + t.Fatalf("answer still contains original backend port; got %q", body) + } +} + +func TestUnwrapSDPEnvelope(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "raw sdp passthrough", + in: "v=0\r\na=ice-ufrag:abc\r\n", + want: "v=0\r\na=ice-ufrag:abc\r\n", + }, + { + name: "json envelope unwrapped", + in: `{"code":0,"sdp":"v=0\r\na=ice-ufrag:abc\r\n"}`, + want: "v=0\r\na=ice-ufrag:abc\r\n", + }, + { + name: "json envelope with leading whitespace", + in: "\n\t " + `{"sdp":"v=0\r\n"}`, + want: "v=0\r\n", + }, + { + name: "malformed json falls back to body", + in: `{not json}`, + want: `{not json}`, + }, + { + name: "json without sdp falls back to body", + in: `{"code":0}`, + want: `{"code":0}`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := unwrapSDPEnvelope(tc.in); got != tc.want { + t.Fatalf("unwrapSDPEnvelope(%q)=%q, want %q", tc.in, got, tc.want) + } + }) + } +} + // --------------------------------------------------------------------------- // webRTCProxyServer.proxyApiToBackend: error paths // --------------------------------------------------------------------------- diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 367284a64..c031c0e59 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -211,10 +211,13 @@ func SrtParseSocketID(data []byte) uint32 { return 0 } -// ParseIceUfragPwd parse the ice-ufrag and ice-pwd from the SDP. +// ParseIceUfragPwd parse the ice-ufrag and ice-pwd from the SDP. The value class +// stops at any whitespace (real CRLF in raw SDP) or at a backslash, so the parser +// is also safe against JSON-escaped SDP bodies where line breaks appear as the +// 2-byte sequence "\r" / "\n" rather than real control characters. func ParseIceUfragPwd(sdp string) (ufrag, pwd string, err error) { if true { - ufragRe := regexp.MustCompile(`a=ice-ufrag:([^\s]+)`) + ufragRe := regexp.MustCompile(`a=ice-ufrag:([^\s\\]+)`) ufragMatch := ufragRe.FindStringSubmatch(sdp) if len(ufragMatch) <= 1 { return "", "", errors.Errorf("no ice-ufrag in sdp %v", sdp) @@ -223,7 +226,7 @@ func ParseIceUfragPwd(sdp string) (ufrag, pwd string, err error) { } if true { - pwdRe := regexp.MustCompile(`a=ice-pwd:([^\s]+)`) + pwdRe := regexp.MustCompile(`a=ice-pwd:([^\s\\]+)`) pwdMatch := pwdRe.FindStringSubmatch(sdp) if len(pwdMatch) <= 1 { return "", "", errors.Errorf("no ice-pwd in sdp %v", sdp) diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go index 2977bee5c..bab316628 100644 --- a/internal/utils/utils_test.go +++ b/internal/utils/utils_test.go @@ -338,6 +338,24 @@ func TestParseIceUfragPwd_MissingPwd(t *testing.T) { } } +// SDP embedded in the legacy /rtc/v1/play/ JSON envelope arrives with "\r\n" as +// the literal 2-byte sequence (backslash + r/n), not real CRLF. The value +// charset must stop at the backslash, otherwise the ufrag would absorb the rest +// of the SDP up to the next real whitespace. +func TestParseIceUfragPwd_JSONEscapedSDP(t *testing.T) { + sdp := `v=0\r\na=ice-ufrag:1f1n4272\r\na=ice-pwd:5f6y69408x2h55232i080mj894901b8n\r\na=fingerprint:sha-256 2D:1D\r\n` + ufrag, pwd, err := ParseIceUfragPwd(sdp) + if err != nil { + t.Fatalf("err = %v", err) + } + if ufrag != "1f1n4272" { + t.Fatalf("ufrag=%q, want 1f1n4272", ufrag) + } + if pwd != "5f6y69408x2h55232i080mj894901b8n" { + t.Fatalf("pwd=%q, want 5f6y69408x2h55232i080mj894901b8n", pwd) + } +} + func TestParseSRTStreamID_WithHost(t *testing.T) { host, resource, err := ParseSRTStreamID("h=example.com,r=live/stream") if err != nil {