Proxy: Unwrap legacy /rtc/v1/play/ JSON envelope for ICE parsing.
srs_bench and other legacy clients post the SDP offer as
{"sdp":"v=0\r\n...","streamurl":"..."} to /rtc/v1/play/ (and
/rtc/v1/publish/). The proxy was passing that raw body straight into
ParseIceUfragPwd, whose [^\s]+ class did not stop at the literal "\"
characters of the JSON-escaped newlines, so the captured ufrag absorbed
the next attributes. The contaminated ufrag was stored in the LB while
the player's STUN binding carried the clean wire ufrag, so
LoadWebRTCByUfrag missed and playback never started.
Add unwrapSDPEnvelope to extract the sdp field when the body is a JSON
envelope (forwarded bytes and the candidate port rewrite still operate
on the raw envelope so the client sees a valid response), and tighten
ParseIceUfragPwd to stop at backslash as well as whitespace.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
57d1062e91
commit
9b08a3809a
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user