diff --git a/bittorrent/bittorrent.go b/bittorrent/bittorrent.go index eb18451..75c7289 100644 --- a/bittorrent/bittorrent.go +++ b/bittorrent/bittorrent.go @@ -124,11 +124,6 @@ func (p Peer) Equal(x Peer) bool { return p.EqualEndpoint(x) && p.ID == x.ID } // EqualEndpoint reports whether p and x have the same endpoint. func (p Peer) EqualEndpoint(x Peer) bool { return p.Port == x.Port && p.IP.Equal(x.IP) } -// Params is used to fetch request optional parameters from an Announce. -type Params interface { - String(key string) (string, bool) -} - // ClientError represents an error that should be exposed to the client over // the BitTorrent protocol implementation. type ClientError string diff --git a/bittorrent/params.go b/bittorrent/params.go new file mode 100644 index 0000000..9cb33c6 --- /dev/null +++ b/bittorrent/params.go @@ -0,0 +1,192 @@ +package bittorrent + +import ( + "errors" + "net/url" + "strconv" + "strings" +) + +// Params is used to fetch (optional) request parameters from an Announce. +// For HTTP Announces this includes the request path and parsed query, for UDP +// Announces this is the extracted path and parsed query from optional URLData +// as specified in BEP41. +// +// See ParseURLData for specifics on parsing and limitations. +type Params interface { + // String returns a string parsed from a query. Every key can be + // returned as a string because they are encoded in the URL as strings. + String(key string) (string, bool) + + // RawPath returns the raw path from the request URL. + // The path returned can contain URL encoded data. + // For a request of the form "/announce?port=1234" this would return + // "/announce". + RawPath() string + + // RawQuery returns the raw query from the request URL, excluding the + // delimiter '?'. + // For a request of the form "/announce?port=1234" this would return + // "port=1234" + RawQuery() string +} + +// ErrKeyNotFound is returned when a provided key has no value associated with +// it. +var ErrKeyNotFound = errors.New("query: value for the provided key does not exist") + +// ErrInvalidInfohash is returned when parsing a query encounters an infohash +// with invalid length. +var ErrInvalidInfohash = errors.New("query: invalid infohash") + +// QueryParams parses a URL Query and implements the Params interface with some +// additional helpers. +type QueryParams struct { + path string + query string + params map[string]string + infoHashes []InfoHash +} + +// ParseURLData parses a request URL or UDP URLData as defined in BEP41. +// It expects a concatenated string of the request's path and query parts as +// defined in RFC 3986. As both the udp: and http: scheme used by BitTorrent +// include an authority part the path part must always begin with a slash. +// An example of the expected URLData would be "/announce?port=1234&uploaded=0" +// or "/?auth=0x1337". +// HTTP servers should pass (*http.Request).RequestURI, UDP servers should +// pass the concatenated, unchanged URLData as defined in BEP41. +// +// Note that, in the case of a key occurring multiple times in the query, only +// the last value for that key is kept. +// The only exception to this rule is the key "info_hash" which will attempt to +// parse each value as an InfoHash and return an error if parsing fails. All +// InfoHashes are collected and can later be retrieved by calling the InfoHashes +// method. +func ParseURLData(urlData string) (*QueryParams, error) { + var path, query string + + queryDelim := strings.IndexAny(urlData, "?") + if queryDelim == -1 { + path = urlData + } else { + path = urlData[:queryDelim] + query = urlData[queryDelim+1:] + } + + q, err := parseQuery(query) + if err != nil { + return nil, err + } + q.path = path + return q, nil +} + +// parseQuery parses a URL query into QueryParams. +// The query is expected to exclude the delimiting '?'. +func parseQuery(rawQuery string) (*QueryParams, error) { + var ( + keyStart, keyEnd int + valStart, valEnd int + + onKey = true + + q = &QueryParams{ + query: rawQuery, + infoHashes: nil, + params: make(map[string]string), + } + ) + + for i, length := 0, len(rawQuery); i < length; i++ { + separator := rawQuery[i] == '&' || rawQuery[i] == ';' + last := i == length-1 + + if separator || last { + if onKey && !last { + keyStart = i + 1 + continue + } + + if last && !separator && !onKey { + valEnd = i + } + + keyStr, err := url.QueryUnescape(rawQuery[keyStart : keyEnd+1]) + if err != nil { + return nil, err + } + + var valStr string + + if valEnd > 0 { + valStr, err = url.QueryUnescape(rawQuery[valStart : valEnd+1]) + if err != nil { + return nil, err + } + } + + if keyStr == "info_hash" { + if len(valStr) != 20 { + return nil, ErrInvalidInfohash + } + q.infoHashes = append(q.infoHashes, InfoHashFromString(valStr)) + } else { + q.params[strings.ToLower(keyStr)] = valStr + } + + valEnd = 0 + onKey = true + keyStart = i + 1 + + } else if rawQuery[i] == '=' { + onKey = false + valStart = i + 1 + valEnd = 0 + } else if onKey { + keyEnd = i + } else { + valEnd = i + } + } + + return q, nil +} + +// String returns a string parsed from a query. Every key can be returned as a +// string because they are encoded in the URL as strings. +func (qp *QueryParams) String(key string) (string, bool) { + value, ok := qp.params[key] + return value, ok +} + +// Uint64 returns a uint parsed from a query. After being called, it is safe to +// cast the uint64 to your desired length. +func (qp *QueryParams) Uint64(key string) (uint64, error) { + str, exists := qp.params[key] + if !exists { + return 0, ErrKeyNotFound + } + + val, err := strconv.ParseUint(str, 10, 64) + if err != nil { + return 0, err + } + + return val, nil +} + +// InfoHashes returns a list of requested infohashes. +func (qp *QueryParams) InfoHashes() []InfoHash { + return qp.infoHashes +} + +// RawPath returns the raw path from the parsed URL. +func (qp *QueryParams) RawPath() string { + return qp.path +} + +// RawQuery returns the raw query from the parsed URL. +func (qp *QueryParams) RawQuery() string { + return qp.query +} diff --git a/frontend/http/query_params_test.go b/bittorrent/params_test.go similarity index 74% rename from frontend/http/query_params_test.go rename to bittorrent/params_test.go index ec9a0d0..36d0819 100644 --- a/frontend/http/query_params_test.go +++ b/bittorrent/params_test.go @@ -1,4 +1,4 @@ -package http +package bittorrent import ( "net/url" @@ -6,11 +6,10 @@ import ( ) var ( - baseAddr = "https://www.subdomain.tracker.com:80/" - testInfoHash = "01234567890123456789" - testPeerID = "-TEST01-6wfG2wk6wWLc" + testPeerID = "-TEST01-6wfG2wk6wWLc" ValidAnnounceArguments = []url.Values{ + {}, {"peer_id": {testPeerID}, "port": {"6881"}, "downloaded": {"1234"}, "left": {"4321"}}, {"peer_id": {testPeerID}, "ip": {"192.168.0.1"}, "port": {"6881"}, "downloaded": {"1234"}, "left": {"4321"}}, {"peer_id": {testPeerID}, "ip": {"192.168.0.1"}, "port": {"6881"}, "downloaded": {"1234"}, "left": {"4321"}, "numwant": {"28"}}, @@ -26,7 +25,7 @@ var ( } InvalidQueries = []string{ - baseAddr + "announce/?" + "info_hash=%0%a", + "/announce?" + "info_hash=%0%a", } ) @@ -45,28 +44,42 @@ func mapArrayEqual(boxed map[string][]string, unboxed map[string]string) bool { return true } -func TestValidQueries(t *testing.T) { +func TestParseEmptyURLData(t *testing.T) { + parsedQuery, err := ParseURLData("") + if err != nil { + t.Fatal(err) + } + if parsedQuery == nil { + t.Fatal("Parsed query must not be nil") + } +} + +func TestParseValidURLData(t *testing.T) { for parseIndex, parseVal := range ValidAnnounceArguments { - parsedQueryObj, err := NewQueryParams(baseAddr + "announce/?" + parseVal.Encode()) + parsedQueryObj, err := ParseURLData("/announce?" + parseVal.Encode()) if err != nil { - t.Error(err) + t.Fatal(err) } if !mapArrayEqual(parseVal, parsedQueryObj.params) { - t.Errorf("Incorrect parse at item %d.\n Expected=%v\n Recieved=%v\n", parseIndex, parseVal, parsedQueryObj.params) + t.Fatalf("Incorrect parse at item %d.\n Expected=%v\n Recieved=%v\n", parseIndex, parseVal, parsedQueryObj.params) + } + + if parsedQueryObj.path != "/announce" { + t.Fatalf("Incorrect path, expected %q, got %q", "/announce", parsedQueryObj.path) } } } -func TestInvalidQueries(t *testing.T) { +func TestParseInvalidURLData(t *testing.T) { for parseIndex, parseStr := range InvalidQueries { - parsedQueryObj, err := NewQueryParams(parseStr) + parsedQueryObj, err := ParseURLData(parseStr) if err == nil { - t.Error("Should have produced error", parseIndex) + t.Fatal("Should have produced error", parseIndex) } if parsedQueryObj != nil { - t.Error("Should be nil after error", parsedQueryObj, parseIndex) + t.Fatal("Should be nil after error", parsedQueryObj, parseIndex) } } } @@ -74,7 +87,7 @@ func TestInvalidQueries(t *testing.T) { func BenchmarkParseQuery(b *testing.B) { for bCount := 0; bCount < b.N; bCount++ { for parseIndex, parseStr := range ValidAnnounceArguments { - parsedQueryObj, err := NewQueryParams(baseAddr + "announce/?" + parseStr.Encode()) + parsedQueryObj, err := parseQuery(parseStr.Encode()) if err != nil { b.Error(err, parseIndex) b.Log(parsedQueryObj) @@ -86,7 +99,7 @@ func BenchmarkParseQuery(b *testing.B) { func BenchmarkURLParseQuery(b *testing.B) { for bCount := 0; bCount < b.N; bCount++ { for parseIndex, parseStr := range ValidAnnounceArguments { - parsedQueryObj, err := url.ParseQuery(baseAddr + "announce/?" + parseStr.Encode()) + parsedQueryObj, err := url.ParseQuery(parseStr.Encode()) if err != nil { b.Error(err, parseIndex) b.Log(parsedQueryObj) diff --git a/frontend/http/parser.go b/frontend/http/parser.go index 7e7674a..d873f40 100644 --- a/frontend/http/parser.go +++ b/frontend/http/parser.go @@ -13,7 +13,7 @@ import ( // If realIPHeader is not empty string, the first value of the HTTP Header with // that name will be used. func ParseAnnounce(r *http.Request, realIPHeader string, allowIPSpoofing bool) (*bittorrent.AnnounceRequest, error) { - qp, err := NewQueryParams(r.URL.RawQuery) + qp, err := bittorrent.ParseURLData(r.RequestURI) if err != nil { return nil, err } @@ -84,7 +84,7 @@ func ParseAnnounce(r *http.Request, realIPHeader string, allowIPSpoofing bool) ( // ParseScrape parses an bittorrent.ScrapeRequest from an http.Request. func ParseScrape(r *http.Request) (*bittorrent.ScrapeRequest, error) { - qp, err := NewQueryParams(r.URL.RawQuery) + qp, err := bittorrent.ParseURLData(r.RequestURI) if err != nil { return nil, err } diff --git a/frontend/http/query_params.go b/frontend/http/query_params.go deleted file mode 100644 index 415b4fc..0000000 --- a/frontend/http/query_params.go +++ /dev/null @@ -1,124 +0,0 @@ -package http - -import ( - "errors" - "net/url" - "strconv" - "strings" - - "github.com/chihaya/chihaya/bittorrent" -) - -// ErrKeyNotFound is returned when a provided key has no value associated with -// it. -var ErrKeyNotFound = errors.New("http: value for the provided key does not exist") - -// ErrInvalidInfohash is returned when parsing a query encounters an infohash -// with invalid length. -var ErrInvalidInfohash = errors.New("http: invalid infohash") - -// QueryParams parses an HTTP Query and implements the bittorrent.Params -// interface with some additional helpers. -type QueryParams struct { - query string - params map[string]string - infoHashes []bittorrent.InfoHash -} - -// NewQueryParams parses a raw URL query. -func NewQueryParams(query string) (*QueryParams, error) { - var ( - keyStart, keyEnd int - valStart, valEnd int - - onKey = true - - q = &QueryParams{ - query: query, - infoHashes: nil, - params: make(map[string]string), - } - ) - - for i, length := 0, len(query); i < length; i++ { - separator := query[i] == '&' || query[i] == ';' || query[i] == '?' - last := i == length-1 - - if separator || last { - if onKey && !last { - keyStart = i + 1 - continue - } - - if last && !separator && !onKey { - valEnd = i - } - - keyStr, err := url.QueryUnescape(query[keyStart : keyEnd+1]) - if err != nil { - return nil, err - } - - var valStr string - - if valEnd > 0 { - valStr, err = url.QueryUnescape(query[valStart : valEnd+1]) - if err != nil { - return nil, err - } - } - - if keyStr == "info_hash" { - if len(valStr) != 20 { - return nil, ErrInvalidInfohash - } - q.infoHashes = append(q.infoHashes, bittorrent.InfoHashFromString(valStr)) - } else { - q.params[strings.ToLower(keyStr)] = valStr - } - - valEnd = 0 - onKey = true - keyStart = i + 1 - - } else if query[i] == '=' { - onKey = false - valStart = i + 1 - valEnd = 0 - } else if onKey { - keyEnd = i - } else { - valEnd = i - } - } - - return q, nil -} - -// String returns a string parsed from a query. Every key can be returned as a -// string because they are encoded in the URL as strings. -func (qp *QueryParams) String(key string) (string, bool) { - value, ok := qp.params[key] - return value, ok -} - -// Uint64 returns a uint parsed from a query. After being called, it is safe to -// cast the uint64 to your desired length. -func (qp *QueryParams) Uint64(key string) (uint64, error) { - str, exists := qp.params[key] - if !exists { - return 0, ErrKeyNotFound - } - - val, err := strconv.ParseUint(str, 10, 64) - if err != nil { - return 0, err - } - - return val, nil -} - -// InfoHashes returns a list of requested infohashes. -func (qp *QueryParams) InfoHashes() []bittorrent.InfoHash { - return qp.infoHashes -} diff --git a/frontend/udp/parser.go b/frontend/udp/parser.go index 43dc329..3c0fed5 100644 --- a/frontend/udp/parser.go +++ b/frontend/udp/parser.go @@ -1,8 +1,11 @@ package udp import ( + "bytes" "encoding/binary" + "fmt" "net" + "sync" "github.com/chihaya/chihaya/bittorrent" ) @@ -37,11 +40,12 @@ var ( bittorrent.Stopped, } - errMalformedPacket = bittorrent.ClientError("malformed packet") - errMalformedIP = bittorrent.ClientError("malformed IP address") - errMalformedEvent = bittorrent.ClientError("malformed event ID") - errUnknownAction = bittorrent.ClientError("unknown action ID") - errBadConnectionID = bittorrent.ClientError("bad connection ID") + errMalformedPacket = bittorrent.ClientError("malformed packet") + errMalformedIP = bittorrent.ClientError("malformed IP address") + errMalformedEvent = bittorrent.ClientError("malformed event ID") + errUnknownAction = bittorrent.ClientError("unknown action ID") + errBadConnectionID = bittorrent.ClientError("bad connection ID") + errUnknownOptionType = bittorrent.ClientError("unknown option type") ) // ParseAnnounce parses an AnnounceRequest from a UDP request. @@ -76,7 +80,7 @@ func ParseAnnounce(r Request, allowIPSpoofing bool) (*bittorrent.AnnounceRequest numWant := binary.BigEndian.Uint32(r.Packet[92:96]) port := binary.BigEndian.Uint16(r.Packet[96:98]) - params, err := handleOptionalParameters(r.Packet) + params, err := handleOptionalParameters(r.Packet[98:]) if err != nil { return nil, err } @@ -97,43 +101,65 @@ func ParseAnnounce(r Request, allowIPSpoofing bool) (*bittorrent.AnnounceRequest }, nil } +type buffer struct { + bytes.Buffer +} + +var bufferFree = sync.Pool{ + New: func() interface{} { return new(buffer) }, +} + +func newBuffer() *buffer { + return bufferFree.Get().(*buffer) +} + +func (b *buffer) free() { + b.Reset() + bufferFree.Put(b) +} + // handleOptionalParameters parses the optional parameters as described in BEP // 41 and updates an announce with the values parsed. -func handleOptionalParameters(packet []byte) (params bittorrent.Params, err error) { - if len(packet) <= 98 { - return +func handleOptionalParameters(packet []byte) (bittorrent.Params, error) { + if len(packet) == 0 { + return bittorrent.ParseURLData("") } - optionStartIndex := 98 - for optionStartIndex < len(packet)-1 { - option := packet[optionStartIndex] + var buf = newBuffer() + defer buf.free() + + for i := 0; i < len(packet); { + option := packet[i] switch option { case optionEndOfOptions: - return - + return bittorrent.ParseURLData(buf.String()) case optionNOP: - optionStartIndex++ - + i++ case optionURLData: - if optionStartIndex+1 > len(packet)-1 { - return params, errMalformedPacket + if i+1 >= len(packet) { + return nil, errMalformedPacket } - length := int(packet[optionStartIndex+1]) - if optionStartIndex+1+length > len(packet)-1 { - return params, errMalformedPacket + length := int(packet[i+1]) + if i+2+length > len(packet) { + return nil, errMalformedPacket } - // TODO(chihaya): Actually parse the URL Data as described in BEP 41 - // into something that fulfills the bittorrent.Params interface. + n, err := buf.Write(packet[i+2 : i+2+length]) + if err != nil { + return nil, err + } + if n != length { + return nil, fmt.Errorf("expected to write %d bytes, wrote %d", length, n) + } - optionStartIndex += 1 + length + i += 2 + length default: - return + return nil, errUnknownOptionType } } - return + return bittorrent.ParseURLData(buf.String()) } // ParseScrape parses a ScrapeRequest from a UDP request. diff --git a/frontend/udp/parser_test.go b/frontend/udp/parser_test.go new file mode 100644 index 0000000..a6c6b92 --- /dev/null +++ b/frontend/udp/parser_test.go @@ -0,0 +1,71 @@ +package udp + +import "testing" + +var table = []struct { + data []byte + values map[string]string + err error +}{ + { + []byte{0x2, 0x5, '/', '?', 'a', '=', 'b'}, + map[string]string{"a": "b"}, + nil, + }, + { + []byte{0x2, 0x0}, + map[string]string{}, + nil, + }, + { + []byte{0x2, 0x1}, + nil, + errMalformedPacket, + }, + { + []byte{0x2}, + nil, + errMalformedPacket, + }, + { + []byte{0x2, 0x8, '/', 'c', '/', 'd', '?', 'a', '=', 'b'}, + map[string]string{"a": "b"}, + nil, + }, + { + []byte{0x2, 0x2, '/', '?', 0x2, 0x3, 'a', '=', 'b'}, + map[string]string{"a": "b"}, + nil, + }, + { + []byte{0x2, 0x9, '/', '?', 'a', '=', 'b', '%', '2', '0', 'c'}, + map[string]string{"a": "b c"}, + nil, + }, +} + +func TestHandleOptionalParameters(t *testing.T) { + for _, testCase := range table { + params, err := handleOptionalParameters(testCase.data) + if err != testCase.err { + if testCase.err == nil { + t.Fatalf("expected no parsing error for %x but got %s", testCase.data, err) + } else { + t.Fatalf("expected parsing error for %x", testCase.data) + } + } + if testCase.values != nil { + if params == nil { + t.Fatalf("expected values %v for %x", testCase.values, testCase.data) + } else { + for key, want := range testCase.values { + if got, ok := params.String(key); !ok { + t.Fatalf("params missing entry %s for data %x", key, testCase.data) + } else if got != want { + t.Fatalf("expected param %s=%s, but was %s for data %x", key, want, got, testCase.data) + } + } + } + } + } +}