From a604f8bd66d61bb288e6b1237e47ddaf857f9e4a Mon Sep 17 00:00:00 2001 From: aler9 <46489434+aler9@users.noreply.github.com> Date: Mon, 20 Jan 2020 10:15:24 +0100 Subject: [PATCH] initial commit --- .dockerignore | 1 + .travis.yml | 14 +++++ Makefile | 45 +++++++++++++ README.md | 10 ++- conn.go | 90 ++++++++++++++++++++++++++ go.mod | 5 ++ go.sum | 11 ++++ request.go | 88 ++++++++++++++++++++++++++ request_test.go | 134 +++++++++++++++++++++++++++++++++++++++ response.go | 95 ++++++++++++++++++++++++++++ response_test.go | 105 +++++++++++++++++++++++++++++++ utils.go | 161 +++++++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 758 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .travis.yml create mode 100644 Makefile create mode 100644 conn.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 request.go create mode 100644 request_test.go create mode 100644 response.go create mode 100644 response_test.go create mode 100644 utils.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..6b8710a7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.git diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..f272f1d8 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +language: go + +go: +- "1.12.x" +- "1.13.x" + +env: +- GO111MODULE=on + +before_install: +- go mod download + +script: +- make test-nodocker diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..1272a685 --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ + +.PHONY: $(shell ls) + +BASE_IMAGE = amd64/golang:1.13-alpine3.10 + +help: + @echo "usage: make [action]" + @echo "" + @echo "available actions:" + @echo "" + @echo " mod-tidy run go mod tidy" + @echo " format format source files" + @echo " test run available tests" + @echo "" + +mod-tidy: + docker run --rm -it -v $(PWD):/s $(BASE_IMAGE) \ + sh -c "apk add git && cd /s && go get && go mod tidy" + +format: + docker run --rm -it -v $(PWD):/s $(BASE_IMAGE) \ + sh -c "cd /s && find . -type f -name '*.go' | xargs gofmt -l -w -s" + +define DOCKERFILE_TEST +FROM $(BASE_IMAGE) +RUN apk add --no-cache make git +WORKDIR /s +COPY go.mod go.sum ./ +RUN go mod download +COPY . ./ +endef +export DOCKERFILE_TEST + +test: + echo "$$DOCKERFILE_TEST" | docker build -q . -f - -t temp + docker run --rm -it \ + --name temp \ + temp \ + make test-nodocker + +IMAGES = $(shell echo test-images/*/ | xargs -n1 basename) + +test-nodocker: + $(eval export CGO_ENABLED = 0) + go test -v . diff --git a/README.md b/README.md index 6d91bab3..fbb9db02 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,10 @@ + # gortsplib -RTSP primitives for the go programming language + +[![GoDoc](https://godoc.org/github.com/aler9/gortsplib?status.svg)](https://godoc.org/github.com/aler9/gortsplib) +[![Go Report Card](https://goreportcard.com/badge/github.com/aler9/gortsplib)](https://goreportcard.com/report/github.com/aler9/gortsplib) +[![Build Status](https://travis-ci.org/aler9/gortsplib.svg?branch=master)](https://travis-ci.org/aler9/gortsplib) + +RTSP primitives for the Go programming language. + +See [rtsp-simple-server](https://github.com/aler9/rtsp-simple-server) and [rtsp-simple-proxy](https://github.com/aler9/rtsp-simple-proxy) for examples on how to use this library. diff --git a/conn.go b/conn.go new file mode 100644 index 00000000..f73abd7a --- /dev/null +++ b/conn.go @@ -0,0 +1,90 @@ +package gortsplib + +import ( + "encoding/binary" + "fmt" + "io" + "net" +) + +type Conn struct { + c net.Conn + writeBuf []byte +} + +func NewConn(c net.Conn) *Conn { + return &Conn{ + c: c, + writeBuf: make([]byte, 2048), + } +} + +func (c *Conn) Close() error { + return c.c.Close() +} + +func (c *Conn) RemoteAddr() net.Addr { + return c.c.RemoteAddr() +} + +func (c *Conn) ReadRequest() (*Request, error) { + return requestDecode(c.c) +} + +func (c *Conn) WriteRequest(req *Request) error { + return requestEncode(c.c, req) +} + +func (c *Conn) ReadResponse() (*Response, error) { + return responseDecode(c.c) +} + +func (c *Conn) WriteResponse(res *Response) error { + return responseEncode(c.c, res) +} + +func (c *Conn) ReadInterleavedFrame(frame []byte) (int, int, error) { + var header [4]byte + _, err := io.ReadFull(c.c, header[:]) + if err != nil { + return 0, 0, err + } + + // connection terminated + if header[0] == 0x54 { + return 0, 0, io.EOF + } + + if header[0] != 0x24 { + return 0, 0, fmt.Errorf("wrong magic byte (0x%.2x)", header[0]) + } + + framelen := binary.BigEndian.Uint16(header[2:]) + if framelen > 2048 { + return 0, 0, fmt.Errorf("frame length greater than 2048") + } + + _, err = io.ReadFull(c.c, frame[:framelen]) + if err != nil { + return 0, 0, err + } + + return int(header[1]), int(framelen), nil +} + +func (c *Conn) WriteInterleavedFrame(channel int, frame []byte) error { + c.writeBuf[0] = 0x24 + c.writeBuf[1] = byte(channel) + binary.BigEndian.PutUint16(c.writeBuf[2:], uint16(len(frame))) + n := copy(c.writeBuf[4:], frame) + + _, err := c.c.Write(c.writeBuf[:4+n]) + if err != nil { + return err + } + return nil +} + +func (c *Conn) Read(buf []byte) (int, error) { + return c.c.Read(buf) +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..f69a9976 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/aler9/gortsplib + +go 1.13 + +require github.com/stretchr/testify v1.4.0 diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..8fdee585 --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/request.go b/request.go new file mode 100644 index 00000000..0a18d8cf --- /dev/null +++ b/request.go @@ -0,0 +1,88 @@ +package gortsplib + +import ( + "bufio" + "fmt" + "io" +) + +type Request struct { + Method string + Url string + Headers map[string]string + Content []byte +} + +func requestDecode(r io.Reader) (*Request, error) { + rb := bufio.NewReader(r) + + req := &Request{} + + byts, err := readBytesLimited(rb, ' ', 255) + if err != nil { + return nil, err + } + req.Method = string(byts[:len(byts)-1]) + + if len(req.Method) == 0 { + return nil, fmt.Errorf("empty method") + } + + byts, err = readBytesLimited(rb, ' ', 255) + if err != nil { + return nil, err + } + req.Url = string(byts[:len(byts)-1]) + + if len(req.Url) == 0 { + return nil, fmt.Errorf("empty path") + } + + byts, err = readBytesLimited(rb, '\r', 255) + if err != nil { + return nil, err + } + proto := string(byts[:len(byts)-1]) + + if proto != _RTSP_PROTO { + return nil, fmt.Errorf("expected '%s', got '%s'", _RTSP_PROTO, proto) + } + + err = readByteEqual(rb, '\n') + if err != nil { + return nil, err + } + + req.Headers, err = readHeaders(rb) + if err != nil { + return nil, err + } + + req.Content, err = readContent(rb, req.Headers) + if err != nil { + return nil, err + } + + return req, nil +} + +func requestEncode(w io.Writer, req *Request) error { + wb := bufio.NewWriter(w) + + _, err := wb.Write([]byte(req.Method + " " + req.Url + " " + _RTSP_PROTO + "\r\n")) + if err != nil { + return err + } + + err = writeHeaders(wb, req.Headers) + if err != nil { + return err + } + + err = writeContent(wb, req.Content) + if err != nil { + return err + } + + return wb.Flush() +} diff --git a/request_test.go b/request_test.go new file mode 100644 index 00000000..e6e5ba1a --- /dev/null +++ b/request_test.go @@ -0,0 +1,134 @@ +package gortsplib + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +var casesRequest = []struct { + name string + byts []byte + req *Request +}{ + { + "options", + []byte("OPTIONS rtsp://example.com/media.mp4 RTSP/1.0\r\n" + + "CSeq: 1\r\n" + + "Proxy-Require: gzipped-messages\r\n" + + "Require: implicit-play\r\n" + + "\r\n"), + &Request{ + Method: "OPTIONS", + Url: "rtsp://example.com/media.mp4", + Headers: map[string]string{ + "CSeq": "1", + "Require": "implicit-play", + "Proxy-Require": "gzipped-messages", + }, + }, + }, + { + "describe", + []byte("DESCRIBE rtsp://example.com/media.mp4 RTSP/1.0\r\n" + + "CSeq: 2\r\n" + + "\r\n"), + &Request{ + Method: "DESCRIBE", + Url: "rtsp://example.com/media.mp4", + Headers: map[string]string{ + "CSeq": "2", + }, + }, + }, + { + "announce", + []byte("ANNOUNCE rtsp://example.com/media.mp4 RTSP/1.0\r\n" + + "CSeq: 7\r\n" + + "Content-Length: 306\r\n" + + "Content-Type: application/sdp\r\n" + + "Date: 23 Jan 1997 15:35:06 GMT\r\n" + + "Session: 12345678\r\n" + + "\r\n" + + "v=0\n" + + "o=mhandley 2890844526 2890845468 IN IP4 126.16.64.4\n" + + "s=SDP Seminar\n" + + "i=A Seminar on the session description protocol\n" + + "u=http://www.cs.ucl.ac.uk/staff/M.Handley/sdp.03.ps\n" + + "e=mjh@isi.edu (Mark Handley)\n" + + "c=IN IP4 224.2.17.12/127\n" + + "t=2873397496 2873404696\n" + + "a=recvonly\n" + + "m=audio 3456 RTP/AVP 0\n" + + "m=video 2232 RTP/AVP 31\n"), + &Request{ + Method: "ANNOUNCE", + Url: "rtsp://example.com/media.mp4", + Headers: map[string]string{ + "CSeq": "7", + "Date": "23 Jan 1997 15:35:06 GMT", + "Session": "12345678", + "Content-Type": "application/sdp", + "Content-Length": "306", + }, + Content: []byte("v=0\n" + + "o=mhandley 2890844526 2890845468 IN IP4 126.16.64.4\n" + + "s=SDP Seminar\n" + + "i=A Seminar on the session description protocol\n" + + "u=http://www.cs.ucl.ac.uk/staff/M.Handley/sdp.03.ps\n" + + "e=mjh@isi.edu (Mark Handley)\n" + + "c=IN IP4 224.2.17.12/127\n" + + "t=2873397496 2873404696\n" + + "a=recvonly\n" + + "m=audio 3456 RTP/AVP 0\n" + + "m=video 2232 RTP/AVP 31\n", + ), + }, + }, + { + "get_parameter", + []byte("GET_PARAMETER rtsp://example.com/media.mp4 RTSP/1.0\r\n" + + "CSeq: 9\r\n" + + "Content-Length: 24\r\n" + + "Content-Type: text/parameters\r\n" + + "Session: 12345678\r\n" + + "\r\n" + + "packets_received\n" + + "jitter\n"), + &Request{ + Method: "GET_PARAMETER", + Url: "rtsp://example.com/media.mp4", + Headers: map[string]string{ + "CSeq": "9", + "Content-Type": "text/parameters", + "Session": "12345678", + "Content-Length": "24", + }, + Content: []byte("packets_received\n" + + "jitter\n", + ), + }, + }, +} + +func TestRequestDecode(t *testing.T) { + for _, c := range casesRequest { + t.Run(c.name, func(t *testing.T) { + req, err := requestDecode(bytes.NewBuffer(c.byts)) + require.NoError(t, err) + require.Equal(t, c.req, req) + }) + } +} + +func TestRequestEncode(t *testing.T) { + for _, c := range casesRequest { + t.Run(c.name, func(t *testing.T) { + var buf bytes.Buffer + err := requestEncode(&buf, c.req) + require.NoError(t, err) + require.Equal(t, c.byts, buf.Bytes()) + }) + } +} diff --git a/response.go b/response.go new file mode 100644 index 00000000..0080fb35 --- /dev/null +++ b/response.go @@ -0,0 +1,95 @@ +package gortsplib + +import ( + "bufio" + "fmt" + "io" + "strconv" +) + +type Response struct { + StatusCode int + Status string + Headers map[string]string + Content []byte +} + +func responseDecode(r io.Reader) (*Response, error) { + rb := bufio.NewReader(r) + + res := &Response{} + + byts, err := readBytesLimited(rb, ' ', 255) + if err != nil { + return nil, err + } + proto := string(byts[:len(byts)-1]) + + if proto != _RTSP_PROTO { + return nil, fmt.Errorf("expected '%s', got '%s'", _RTSP_PROTO, proto) + } + + byts, err = readBytesLimited(rb, ' ', 4) + if err != nil { + return nil, err + } + statusCodeStr := string(byts[:len(byts)-1]) + + statusCode64, err := strconv.ParseInt(statusCodeStr, 10, 32) + res.StatusCode = int(statusCode64) + if err != nil { + return nil, fmt.Errorf("unable to parse status code") + } + + byts, err = readBytesLimited(rb, '\r', 255) + if err != nil { + return nil, err + } + res.Status = string(byts[:len(byts)-1]) + + if len(res.Status) == 0 { + return nil, fmt.Errorf("empty status") + } + + err = readByteEqual(rb, '\n') + if err != nil { + return nil, err + } + + res.Headers, err = readHeaders(rb) + if err != nil { + return nil, err + } + + res.Content, err = readContent(rb, res.Headers) + if err != nil { + return nil, err + } + + return res, nil +} + +func responseEncode(w io.Writer, res *Response) error { + wb := bufio.NewWriter(w) + + _, err := wb.Write([]byte(_RTSP_PROTO + " " + strconv.FormatInt(int64(res.StatusCode), 10) + " " + res.Status + "\r\n")) + if err != nil { + return err + } + + if len(res.Content) != 0 { + res.Headers["Content-Length"] = strconv.FormatInt(int64(len(res.Content)), 10) + } + + err = writeHeaders(wb, res.Headers) + if err != nil { + return err + } + + err = writeContent(wb, res.Content) + if err != nil { + return err + } + + return wb.Flush() +} diff --git a/response_test.go b/response_test.go new file mode 100644 index 00000000..cc5d953f --- /dev/null +++ b/response_test.go @@ -0,0 +1,105 @@ +package gortsplib + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +var casesResponse = []struct { + name string + byts []byte + res *Response +}{ + { + "ok", + []byte("RTSP/1.0 200 OK\r\n" + + "CSeq: 1\r\n" + + "Public: DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSE\r\n" + + "\r\n", + ), + &Response{ + StatusCode: 200, + Status: "OK", + Headers: map[string]string{ + "CSeq": "1", + "Public": "DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSE", + }, + }, + }, + { + "ok with content", + []byte("RTSP/1.0 200 OK\r\n" + + "CSeq: 2\r\n" + + "Content-Base: rtsp://example.com/media.mp4\r\n" + + "Content-Length: 444\r\n" + + "Content-Type: application/sdp\r\n" + + "\r\n" + + "m=video 0 RTP/AVP 96\n" + + "a=control:streamid=0\n" + + "a=range:npt=0-7.741000\n" + + "a=length:npt=7.741000\n" + + "a=rtpmap:96 MP4V-ES/5544\n" + + "a=mimetype:string;\"video/MP4V-ES\"\n" + + "a=AvgBitRate:integer;304018\n" + + "a=StreamName:string;\"hinted video track\"\n" + + "m=audio 0 RTP/AVP 97\n" + + "a=control:streamid=1\n" + + "a=range:npt=0-7.712000\n" + + "a=length:npt=7.712000\n" + + "a=rtpmap:97 mpeg4-generic/32000/2\n" + + "a=mimetype:string;\"audio/mpeg4-generic\"\n" + + "a=AvgBitRate:integer;65790\n" + + "a=StreamName:string;\"hinted audio track\"\n", + ), + &Response{ + StatusCode: 200, + Status: "OK", + Headers: map[string]string{ + "Content-Base": "rtsp://example.com/media.mp4", + "Content-Length": "444", + "Content-Type": "application/sdp", + "CSeq": "2", + }, + Content: []byte("m=video 0 RTP/AVP 96\n" + + "a=control:streamid=0\n" + + "a=range:npt=0-7.741000\n" + + "a=length:npt=7.741000\n" + + "a=rtpmap:96 MP4V-ES/5544\n" + + "a=mimetype:string;\"video/MP4V-ES\"\n" + + "a=AvgBitRate:integer;304018\n" + + "a=StreamName:string;\"hinted video track\"\n" + + "m=audio 0 RTP/AVP 97\n" + + "a=control:streamid=1\n" + + "a=range:npt=0-7.712000\n" + + "a=length:npt=7.712000\n" + + "a=rtpmap:97 mpeg4-generic/32000/2\n" + + "a=mimetype:string;\"audio/mpeg4-generic\"\n" + + "a=AvgBitRate:integer;65790\n" + + "a=StreamName:string;\"hinted audio track\"\n", + ), + }, + }, +} + +func TestResponseDecode(t *testing.T) { + for _, c := range casesResponse { + t.Run(c.name, func(t *testing.T) { + res, err := responseDecode(bytes.NewBuffer(c.byts)) + require.NoError(t, err) + require.Equal(t, c.res, res) + }) + } +} + +func TestResponseEncode(t *testing.T) { + for _, c := range casesResponse { + t.Run(c.name, func(t *testing.T) { + var buf bytes.Buffer + err := responseEncode(&buf, c.res) + require.NoError(t, err) + require.Equal(t, c.byts, buf.Bytes()) + }) + } +} diff --git a/utils.go b/utils.go new file mode 100644 index 00000000..174b26c8 --- /dev/null +++ b/utils.go @@ -0,0 +1,161 @@ +package gortsplib + +import ( + "bufio" + "fmt" + "io" + "sort" + "strconv" +) + +const ( + _RTSP_PROTO = "RTSP/1.0" + _MAX_HEADER_COUNT = 255 + _MAX_HEADER_KEY_LENGTH = 255 + _MAX_HEADER_VALUE_LENGTH = 255 + _MAX_CONTENT_LENGTH = 4096 +) + +func readBytesLimited(rb *bufio.Reader, delim byte, n int) ([]byte, error) { + for i := 1; i <= n; i++ { + byts, err := rb.Peek(i) + if err != nil { + return nil, err + } + + if byts[len(byts)-1] == delim { + rb.Discard(len(byts)) + return byts, nil + } + } + return nil, fmt.Errorf("buffer length exceeds %d", n) +} + +func readByteEqual(rb *bufio.Reader, cmp byte) error { + byt, err := rb.ReadByte() + if err != nil { + return err + } + + if byt != cmp { + return fmt.Errorf("expected '%c', got '%c'", cmp, byt) + } + + return nil +} + +func readHeaders(rb *bufio.Reader) (map[string]string, error) { + ret := make(map[string]string) + + for { + byt, err := rb.ReadByte() + if err != nil { + return nil, err + } + + if byt == '\r' { + err := readByteEqual(rb, '\n') + if err != nil { + return nil, err + } + + break + } + + if len(ret) >= _MAX_HEADER_COUNT { + return nil, fmt.Errorf("headers count exceeds %d", _MAX_HEADER_COUNT) + } + + key := string([]byte{byt}) + byts, err := readBytesLimited(rb, ':', _MAX_HEADER_KEY_LENGTH-1) + if err != nil { + return nil, err + } + key += string(byts[:len(byts)-1]) + + err = readByteEqual(rb, ' ') + if err != nil { + return nil, err + } + + byts, err = readBytesLimited(rb, '\r', _MAX_HEADER_VALUE_LENGTH) + if err != nil { + return nil, err + } + val := string(byts[:len(byts)-1]) + + if len(val) == 0 { + return nil, fmt.Errorf("empty header value") + } + + err = readByteEqual(rb, '\n') + if err != nil { + return nil, err + } + + ret[key] = val + } + + return ret, nil +} + +func writeHeaders(wb *bufio.Writer, headers map[string]string) error { + // sort headers by key + // in order to obtain deterministic results + var keys []string + for key := range headers { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + _, err := wb.Write([]byte(key + ": " + headers[key] + "\r\n")) + if err != nil { + return err + } + } + + _, err := wb.Write([]byte("\r\n")) + if err != nil { + return err + } + + return nil +} + +func readContent(rb *bufio.Reader, headers map[string]string) ([]byte, error) { + cls, ok := headers["Content-Length"] + if !ok { + return nil, nil + } + + cl, err := strconv.ParseInt(cls, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid Content-Length") + } + + if cl > _MAX_CONTENT_LENGTH { + return nil, fmt.Errorf("Content-Length exceeds %d", _MAX_CONTENT_LENGTH) + } + + ret := make([]byte, cl) + n, err := io.ReadFull(rb, ret) + if err != nil && n != len(ret) { + return nil, err + } + + return ret, nil +} + +func writeContent(wb *bufio.Writer, content []byte) error { + if len(content) == 0 { + return nil + } + + _, err := wb.Write(content) + if err != nil { + return err + } + + return nil +}