diff --git a/client.go b/client.go index 77f78425..0161a259 100644 --- a/client.go +++ b/client.go @@ -1133,7 +1133,7 @@ func (c *Client) destroyWriter() { c.writerMutex.Unlock() } -func (c *Client) connOpen() error { +func (c *Client) connOpen(u *base.URL) error { if c.nconn != nil { return nil } @@ -1156,7 +1156,7 @@ func (c *Client) connOpen() error { case TunnelHTTP: var err error nconn, err = newClientTunnelHTTP(dialCtx, addr, (c.Scheme == schemeRTSPS), - c.TLSConfig, c.DialContext, c.DialTLSContext) + c.TLSConfig, c.DialContext, c.DialTLSContext, u) if err != nil { return err } @@ -1385,7 +1385,7 @@ func (c *Client) doOptions(u *base.URL) (*base.Response, error) { return nil, err } - err = c.connOpen() + err = c.connOpen(u) if err != nil { return nil, err } @@ -1436,7 +1436,7 @@ func (c *Client) doDescribe(u *base.URL) (*description.Session, *base.Response, return nil, nil, err } - err = c.connOpen() + err = c.connOpen(u) if err != nil { return nil, nil, err } @@ -1549,7 +1549,7 @@ func (c *Client) doAnnounce(u *base.URL, desc *description.Session) (*base.Respo return nil, fmt.Errorf("recording with UDP multicast is not supported") } - err = c.connOpen() + err = c.connOpen(u) if err != nil { return nil, err } @@ -1638,7 +1638,7 @@ func (c *Client) doSetup( return nil, err } - err = c.connOpen() + err = c.connOpen(baseURL) if err != nil { return nil, err } diff --git a/client_test.go b/client_test.go index eb333169..3430ea3b 100644 --- a/client_test.go +++ b/client_test.go @@ -709,10 +709,10 @@ func TestClientTunnelHTTP(t *testing.T) { ProtoMajor: 1, ProtoMinor: 1, URL: &url.URL{ - Path: "/", + Path: "/teststream", }, Host: "localhost:8554", - RequestURI: "/", + RequestURI: "/teststream", Header: http.Header{ "Accept": []string{"application/x-rtsp-tunnelled"}, "Content-Length": []string{"30000"}, @@ -755,10 +755,10 @@ func TestClientTunnelHTTP(t *testing.T) { ProtoMajor: 1, ProtoMinor: 1, URL: &url.URL{ - Path: "/", + Path: "/teststream", }, Host: "localhost:8554", - RequestURI: "/", + RequestURI: "/teststream", Header: http.Header{ "Content-Type": []string{"application/x-rtsp-tunnelled"}, "Content-Length": []string{"30000"}, @@ -844,6 +844,190 @@ func TestClientTunnelHTTP(t *testing.T) { } } +func TestClientTunnelHTTPPathQuery(t *testing.T) { + for _, ca := range []string{"http", "https"} { + t.Run(ca, func(t *testing.T) { + var l net.Listener + var err error + + if ca == "http" { + l, err = net.Listen("tcp", "localhost:8554") + require.NoError(t, err) + defer l.Close() + } else { + var cert tls.Certificate + cert, err = tls.X509KeyPair(serverCert, serverKey) + require.NoError(t, err) + + l, err = tls.Listen("tcp", "localhost:8554", &tls.Config{Certificates: []tls.Certificate{cert}}) + require.NoError(t, err) + defer l.Close() + } + + var scheme string + if ca == "http" { + scheme = "rtsp" + } else { + scheme = "rtsps" + } + + serverDone := make(chan struct{}) + defer func() { <-serverDone }() + + go func() { + defer close(serverDone) + + nconn1, err2 := l.Accept() + require.NoError(t, err2) + defer nconn1.Close() + + buf1 := bufio.NewReader(nconn1) + req1, err2 := http.ReadRequest(buf1) + require.NoError(t, err2) + + require.Equal(t, &http.Request{ + Method: http.MethodGet, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + URL: &url.URL{ + Path: "/teststream", + RawQuery: "param=value", + }, + Host: "localhost:8554", + RequestURI: "/teststream?param=value", + Header: http.Header{ + "Accept": []string{"application/x-rtsp-tunnelled"}, + "Content-Length": []string{"30000"}, + "X-Sessioncookie": req1.Header["X-Sessioncookie"], + }, + ContentLength: 30000, + Body: req1.Body, + }, req1) + + require.NotEmpty(t, req1.Header.Get("X-Sessioncookie")) + + h := http.Header{} + h.Set("Cache-Control", "no-cache") + h.Set("Connection", "close") + h.Set("Content-Type", "application/x-rtsp-tunnelled") + h.Set("Pragma", "no-cache") + res := http.Response{ + StatusCode: http.StatusOK, + ProtoMajor: 1, + ProtoMinor: req1.ProtoMinor, + Header: h, + ContentLength: -1, + } + var resBuf bytes.Buffer + res.Write(&resBuf) //nolint:errcheck + _, err = nconn1.Write(resBuf.Bytes()) + require.NoError(t, err) + + nconn2, err2 := l.Accept() + require.NoError(t, err2) + defer nconn2.Close() + + buf2 := bufio.NewReader(nconn2) + req2, err2 := http.ReadRequest(buf2) + require.NoError(t, err2) + + require.Equal(t, &http.Request{ + Method: http.MethodPost, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + URL: &url.URL{ + Path: "/teststream", + RawQuery: "param=value", + }, + Host: "localhost:8554", + RequestURI: "/teststream?param=value", + Header: http.Header{ + "Content-Type": []string{"application/x-rtsp-tunnelled"}, + "Content-Length": []string{"30000"}, + "X-Sessioncookie": req2.Header["X-Sessioncookie"], + }, + ContentLength: 30000, + Body: req2.Body, + }, req2) + + require.Equal(t, req1.Header.Get("X-Sessioncookie"), req2.Header.Get("X-Sessioncookie")) + + h = http.Header{} + h.Set("Cache-Control", "no-cache") + h.Set("Connection", "close") + h.Set("Content-Type", "application/x-rtsp-tunnelled") + h.Set("Pragma", "no-cache") + res = http.Response{ + StatusCode: http.StatusOK, + ProtoMajor: 1, + ProtoMinor: req1.ProtoMinor, + Header: h, + ContentLength: -1, + } + resBuf = bytes.Buffer{} + res.Write(&resBuf) //nolint:errcheck + _, err = nconn2.Write(resBuf.Bytes()) + require.NoError(t, err) + + conn := conn.NewConn(bufio.NewReader(base64streamreader.New(buf2)), nconn1) + + req, err2 := conn.ReadRequest() + require.NoError(t, err2) + require.Equal(t, base.Options, req.Method) + + err2 = conn.WriteResponse(&base.Response{ + StatusCode: base.StatusOK, + Header: base.Header{ + "Public": base.HeaderValue{strings.Join([]string{ + string(base.Describe), + }, ", ")}, + }, + }) + require.NoError(t, err2) + + req, err2 = conn.ReadRequest() + require.NoError(t, err2) + require.Equal(t, base.Describe, req.Method) + require.Equal(t, mustParseURL(scheme+"://localhost:8554/teststream?param=value"), req.URL) + + medias := []*description.Media{testH264Media} + + err2 = conn.WriteResponse(&base.Response{ + StatusCode: base.StatusOK, + Header: base.Header{ + "Content-Type": base.HeaderValue{"application/sdp; charset=utf-8"}, + "Content-Base": base.HeaderValue{"/relative-content-base"}, + }, + Body: mediasToSDP(medias), + }) + require.NoError(t, err2) + }() + + u, err := base.ParseURL(scheme + "://localhost:8554/teststream?param=value") + require.NoError(t, err) + + c := Client{ + Scheme: u.Scheme, + Host: u.Host, + Tunnel: TunnelHTTP, + TLSConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + + err = c.Start() + require.NoError(t, err) + defer c.Close() + + _, res, err := c.Describe(u) + require.NoError(t, err) + require.Equal(t, base.StatusOK, res.StatusCode) + }) + } +} + func TestClientTunnelWebSocket(t *testing.T) { for _, ca := range []string{"ws", "wss"} { t.Run(ca, func(t *testing.T) { diff --git a/client_tunnel_http.go b/client_tunnel_http.go index e24a59a1..3086a437 100644 --- a/client_tunnel_http.go +++ b/client_tunnel_http.go @@ -12,6 +12,8 @@ import ( "time" "github.com/google/uuid" + + "github.com/bluenviron/gortsplib/v5/pkg/base" ) type clientTunnelHTTP struct { @@ -61,6 +63,7 @@ func newClientTunnelHTTP( tlsConfig *tls.Config, dialContext func(ctx context.Context, network, address string) (net.Conn, error), dialTLSContext func(ctx context.Context, network string, addr string) (net.Conn, error), + u *base.URL, ) (net.Conn, error) { c := &clientTunnelHTTP{} @@ -123,11 +126,12 @@ func newClientTunnelHTTP( }() tunnelID := strings.ReplaceAll(uuid.New().String(), "-", "") + requestTarget := clientTunnelHTTPRequestTarget(u) // do not use http.Request // since Content-Length requires a Body of same size _, err := c.readChan.Write([]byte( - "GET / HTTP/1.1\r\n" + + "GET " + requestTarget + " HTTP/1.1\r\n" + "Host: " + addr + "\r\n" + "X-Sessioncookie: " + tunnelID + "\r\n" + "Accept: application/x-rtsp-tunnelled\r\n" + @@ -189,7 +193,7 @@ func newClientTunnelHTTP( // do not use http.Request // since Content-Length requires a Body of same size _, err = c.writeChan.Write([]byte( - "POST / HTTP/1.1\r\n" + + "POST " + requestTarget + " HTTP/1.1\r\n" + "Host: " + addr + "\r\n" + "X-Sessioncookie: " + tunnelID + "\r\n" + "Content-Type: application/x-rtsp-tunnelled\r\n" + @@ -214,3 +218,20 @@ func newClientTunnelHTTP( ok = true return c, nil } + +func clientTunnelHTTPRequestTarget(u *base.URL) string { + if u == nil { + return "/" + } + + ret := u.Path + if ret == "" { + ret = "/" + } + + if u.RawQuery != "" { + ret += "?" + u.RawQuery + } + + return ret +} diff --git a/client_tunnel_http_test.go b/client_tunnel_http_test.go new file mode 100644 index 00000000..8ad759b8 --- /dev/null +++ b/client_tunnel_http_test.go @@ -0,0 +1,37 @@ +package gortsplib + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/bluenviron/gortsplib/v5/pkg/base" +) + +func TestClientTunnelHTTPRequestTarget(t *testing.T) { + for _, testCase := range []struct { + name string + streamURL *base.URL + expectedTarget string + }{ + { + name: "nil url", + streamURL: nil, + expectedTarget: "/", + }, + { + name: "empty path", + streamURL: mustParseURL("rtsp://localhost:8554"), + expectedTarget: "/", + }, + { + name: "path with query", + streamURL: mustParseURL("rtsp://localhost:8554/teststream?param=value"), + expectedTarget: "/teststream?param=value", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + require.Equal(t, testCase.expectedTarget, clientTunnelHTTPRequestTarget(testCase.streamURL)) + }) + } +}