diff --git a/DCO b/DCO new file mode 100644 index 0000000..716561d --- /dev/null +++ b/DCO @@ -0,0 +1,36 @@ +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. +660 York Street, Suite 102, +San Francisco, CA 94110 USA + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. diff --git a/MAINTAINERS b/MAINTAINERS new file mode 100644 index 0000000..6f09a91 --- /dev/null +++ b/MAINTAINERS @@ -0,0 +1 @@ +Jimmy Zelinskie (@jzelinskie) pkg:* diff --git a/bittorrent/bencode/bencode.go b/bittorrent/bencode/bencode.go new file mode 100644 index 0000000..7985adc --- /dev/null +++ b/bittorrent/bencode/bencode.go @@ -0,0 +1,33 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package bencode implements bencoding of data as defined in BEP 3 using +// type assertion over reflection for performance. +package bencode + +// Dict represents a bencode dictionary. +type Dict map[string]interface{} + +// NewDict allocates the memory for a Dict. +func NewDict() Dict { + return make(Dict) +} + +// List represents a bencode list. +type List []interface{} + +// NewList allocates the memory for a List. +func NewList() List { + return make(List, 0) +} diff --git a/bittorrent/bencode/decoder.go b/bittorrent/bencode/decoder.go new file mode 100644 index 0000000..dba087f --- /dev/null +++ b/bittorrent/bencode/decoder.go @@ -0,0 +1,145 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bencode + +import ( + "bufio" + "bytes" + "errors" + "io" + "strconv" +) + +// A Decoder reads bencoded objects from an input stream. +type Decoder struct { + r *bufio.Reader +} + +// NewDecoder returns a new decoder that reads from r. +func NewDecoder(r io.Reader) *Decoder { + return &Decoder{r: bufio.NewReader(r)} +} + +// Decode unmarshals the next bencoded value in the stream. +func (dec *Decoder) Decode() (interface{}, error) { + return unmarshal(dec.r) +} + +// Unmarshal deserializes and returns the bencoded value in buf. +func Unmarshal(buf []byte) (interface{}, error) { + r := bufio.NewReader(bytes.NewBuffer(buf)) + return unmarshal(r) +} + +// unmarshal reads bencoded values from a bufio.Reader +func unmarshal(r *bufio.Reader) (interface{}, error) { + tok, err := r.ReadByte() + if err != nil { + return nil, err + } + + switch tok { + case 'i': + return readTerminatedInt(r, 'e') + + case 'l': + list := NewList() + for { + ok, err := readTerminator(r, 'e') + if err != nil { + return nil, err + } else if ok { + break + } + + v, err := unmarshal(r) + if err != nil { + return nil, err + } + list = append(list, v) + } + return list, nil + + case 'd': + dict := NewDict() + for { + ok, err := readTerminator(r, 'e') + if err != nil { + return nil, err + } else if ok { + break + } + + v, err := unmarshal(r) + if err != nil { + return nil, err + } + + key, ok := v.(string) + if !ok { + return nil, errors.New("bencode: non-string map key") + } + + dict[key], err = unmarshal(r) + if err != nil { + return nil, err + } + } + return dict, nil + + default: + err = r.UnreadByte() + if err != nil { + return nil, err + } + + length, err := readTerminatedInt(r, ':') + if err != nil { + return nil, errors.New("bencode: unknown input sequence") + } + + buf := make([]byte, length) + n, err := r.Read(buf) + + if err != nil { + return nil, err + } else if int64(n) != length { + return nil, errors.New("bencode: short read") + } + + return string(buf), nil + } +} + +func readTerminator(r io.ByteScanner, term byte) (bool, error) { + tok, err := r.ReadByte() + if err != nil { + return false, err + } else if tok == term { + return true, nil + } + return false, r.UnreadByte() +} + +func readTerminatedInt(r *bufio.Reader, term byte) (int64, error) { + buf, err := r.ReadSlice(term) + if err != nil { + return 0, err + } else if len(buf) <= 1 { + return 0, errors.New("bencode: empty integer field") + } + + return strconv.ParseInt(string(buf[:len(buf)-1]), 10, 64) +} diff --git a/bittorrent/bencode/decoder_test.go b/bittorrent/bencode/decoder_test.go new file mode 100644 index 0000000..375b69a --- /dev/null +++ b/bittorrent/bencode/decoder_test.go @@ -0,0 +1,96 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bencode + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +var unmarshalTests = []struct { + input string + expected interface{} +}{ + {"i42e", int64(42)}, + {"i-42e", int64(-42)}, + + {"7:example", "example"}, + + {"l3:one3:twoe", List{"one", "two"}}, + {"le", List{}}, + + {"d3:one2:aa3:two2:bbe", Dict{"one": "aa", "two": "bb"}}, + {"de", Dict{}}, +} + +func TestUnmarshal(t *testing.T) { + for _, tt := range unmarshalTests { + got, err := Unmarshal([]byte(tt.input)) + assert.Nil(t, err, "unmarshal should not fail") + assert.Equal(t, got, tt.expected, "unmarshalled values should match the expected results") + } +} + +type bufferLoop struct { + val string +} + +func (r *bufferLoop) Read(b []byte) (int, error) { + n := copy(b, r.val) + return n, nil +} + +func BenchmarkUnmarshalScalar(b *testing.B) { + d1 := NewDecoder(&bufferLoop{"7:example"}) + d2 := NewDecoder(&bufferLoop{"i42e"}) + + for i := 0; i < b.N; i++ { + d1.Decode() + d2.Decode() + } +} + +func TestUnmarshalLarge(t *testing.T) { + data := Dict{ + "k1": List{"a", "b", "c"}, + "k2": int64(42), + "k3": "val", + "k4": int64(-42), + } + + buf, _ := Marshal(data) + dec := NewDecoder(&bufferLoop{string(buf)}) + + got, err := dec.Decode() + assert.Nil(t, err, "decode should not fail") + assert.Equal(t, got, data, "encoding and decoding should equal the original value") +} + +func BenchmarkUnmarshalLarge(b *testing.B) { + data := map[string]interface{}{ + "k1": []string{"a", "b", "c"}, + "k2": 42, + "k3": "val", + "k4": uint(42), + } + + buf, _ := Marshal(data) + dec := NewDecoder(&bufferLoop{string(buf)}) + + for i := 0; i < b.N; i++ { + dec.Decode() + } +} diff --git a/bittorrent/bencode/encoder.go b/bittorrent/bencode/encoder.go new file mode 100644 index 0000000..bd8701c --- /dev/null +++ b/bittorrent/bencode/encoder.go @@ -0,0 +1,173 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bencode + +import ( + "bytes" + "fmt" + "io" + "strconv" + "time" +) + +// An Encoder writes bencoded objects to an output stream. +type Encoder struct { + w io.Writer +} + +// NewEncoder returns a new encoder that writes to w. +func NewEncoder(w io.Writer) *Encoder { + return &Encoder{w: w} +} + +// Encode writes the bencoding of v to the stream. +func (enc *Encoder) Encode(v interface{}) error { + return marshal(enc.w, v) +} + +// Marshal returns the bencoding of v. +func Marshal(v interface{}) ([]byte, error) { + buf := &bytes.Buffer{} + err := marshal(buf, v) + return buf.Bytes(), err +} + +// Marshaler is the interface implemented by objects that can marshal +// themselves. +type Marshaler interface { + MarshalBencode() ([]byte, error) +} + +// marshal writes types bencoded to an io.Writer +func marshal(w io.Writer, data interface{}) error { + switch v := data.(type) { + case Marshaler: + bencoded, err := v.MarshalBencode() + if err != nil { + return err + } + _, err = w.Write(bencoded) + if err != nil { + return err + } + + case string: + marshalString(w, v) + + case int: + marshalInt(w, int64(v)) + + case uint: + marshalUint(w, uint64(v)) + + case int16: + marshalInt(w, int64(v)) + + case uint16: + marshalUint(w, uint64(v)) + + case int32: + marshalInt(w, int64(v)) + + case uint32: + marshalUint(w, uint64(v)) + + case int64: + marshalInt(w, v) + + case uint64: + marshalUint(w, v) + + case []byte: + marshalBytes(w, v) + + case time.Duration: // Assume seconds + marshalInt(w, int64(v/time.Second)) + + case Dict: + marshal(w, map[string]interface{}(v)) + + case []Dict: + w.Write([]byte{'l'}) + for _, val := range v { + err := marshal(w, val) + if err != nil { + return err + } + } + w.Write([]byte{'e'}) + + case map[string]interface{}: + w.Write([]byte{'d'}) + for key, val := range v { + marshalString(w, key) + err := marshal(w, val) + if err != nil { + return err + } + } + w.Write([]byte{'e'}) + + case []string: + w.Write([]byte{'l'}) + for _, val := range v { + err := marshal(w, val) + if err != nil { + return err + } + } + w.Write([]byte{'e'}) + + case List: + marshal(w, []interface{}(v)) + + case []interface{}: + w.Write([]byte{'l'}) + for _, val := range v { + err := marshal(w, val) + if err != nil { + return err + } + } + w.Write([]byte{'e'}) + + default: + return fmt.Errorf("attempted to marshal unsupported type:\n%t", v) + } + + return nil +} + +func marshalInt(w io.Writer, v int64) { + w.Write([]byte{'i'}) + w.Write([]byte(strconv.FormatInt(v, 10))) + w.Write([]byte{'e'}) +} + +func marshalUint(w io.Writer, v uint64) { + w.Write([]byte{'i'}) + w.Write([]byte(strconv.FormatUint(v, 10))) + w.Write([]byte{'e'}) +} + +func marshalBytes(w io.Writer, v []byte) { + w.Write([]byte(strconv.Itoa(len(v)))) + w.Write([]byte{':'}) + w.Write(v) +} + +func marshalString(w io.Writer, v string) { + marshalBytes(w, []byte(v)) +} diff --git a/bittorrent/bencode/encoder_test.go b/bittorrent/bencode/encoder_test.go new file mode 100644 index 0000000..c432208 --- /dev/null +++ b/bittorrent/bencode/encoder_test.go @@ -0,0 +1,81 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bencode + +import ( + "bytes" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var marshalTests = []struct { + input interface{} + expected []string +}{ + {int(42), []string{"i42e"}}, + {int(-42), []string{"i-42e"}}, + {uint(43), []string{"i43e"}}, + {int64(44), []string{"i44e"}}, + {uint64(45), []string{"i45e"}}, + {int16(44), []string{"i44e"}}, + {uint16(45), []string{"i45e"}}, + + {"example", []string{"7:example"}}, + {[]byte("example"), []string{"7:example"}}, + {30 * time.Minute, []string{"i1800e"}}, + + {[]string{"one", "two"}, []string{"l3:one3:twoe", "l3:two3:onee"}}, + {[]interface{}{"one", "two"}, []string{"l3:one3:twoe", "l3:two3:onee"}}, + {[]string{}, []string{"le"}}, + + {map[string]interface{}{"one": "aa", "two": "bb"}, []string{"d3:one2:aa3:two2:bbe", "d3:two2:bb3:one2:aae"}}, + {map[string]interface{}{}, []string{"de"}}, +} + +func TestMarshal(t *testing.T) { + for _, test := range marshalTests { + got, err := Marshal(test.input) + assert.Nil(t, err, "marshal should not fail") + assert.Contains(t, test.expected, string(got), "the marshaled result should be one of the expected permutations") + } +} + +func BenchmarkMarshalScalar(b *testing.B) { + buf := &bytes.Buffer{} + encoder := NewEncoder(buf) + + for i := 0; i < b.N; i++ { + encoder.Encode("test") + encoder.Encode(123) + } +} + +func BenchmarkMarshalLarge(b *testing.B) { + data := map[string]interface{}{ + "k1": []string{"a", "b", "c"}, + "k2": 42, + "k3": "val", + "k4": uint(42), + } + + buf := &bytes.Buffer{} + encoder := NewEncoder(buf) + + for i := 0; i < b.N; i++ { + encoder.Encode(data) + } +} diff --git a/bittorrent/bittorrent.go b/bittorrent/bittorrent.go new file mode 100644 index 0000000..b7f400f --- /dev/null +++ b/bittorrent/bittorrent.go @@ -0,0 +1,177 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bittorrent + +import ( + "net" + "time" +) + +// PeerID represents a peer ID. +type PeerID [20]byte + +// PeerIDFromBytes creates a PeerID from a byte slice. +// +// It panics if b is not 20 bytes long. +func PeerIDFromBytes(b []byte) PeerID { + if len(b) != 20 { + panic("peer ID must be 20 bytes") + } + + var buf [20]byte + copy(buf[:], b) + return PeerID(buf) +} + +// PeerIDFromString creates a PeerID from a string. +// +// It panics if s is not 20 bytes long. +func PeerIDFromString(s string) PeerID { + if len(s) != 20 { + panic("peer ID must be 20 bytes") + } + + var buf [20]byte + copy(buf[:], s) + return PeerID(buf) +} + +// InfoHash represents an infohash. +type InfoHash [20]byte + +// InfoHashFromBytes creates an InfoHash from a byte slice. +// +// It panics if b is not 20 bytes long. +func InfoHashFromBytes(b []byte) InfoHash { + if len(b) != 20 { + panic("infohash must be 20 bytes") + } + + var buf [20]byte + copy(buf[:], b) + return InfoHash(buf) +} + +// InfoHashFromString creates an InfoHash from a string. +// +// It panics if s is not 20 bytes long. +func InfoHashFromString(s string) InfoHash { + if len(s) != 20 { + panic("infohash must be 20 bytes") + } + + var buf [20]byte + copy(buf[:], s) + return InfoHash(buf) +} + +// AnnounceRequest represents the parsed parameters from an announce request. +type AnnounceRequest struct { + Event Event + InfoHash InfoHash + Compact bool + NumWant uint32 + Left uint64 + Downloaded uint64 + Uploaded uint64 + + Peer + Params +} + +// AnnounceResponse represents the parameters used to create an announce +// response. +type AnnounceResponse struct { + Compact bool + Complete int32 + Incomplete int32 + Interval time.Duration + MinInterval time.Duration + IPv4Peers []Peer + IPv6Peers []Peer +} + +// AnnounceHandler is a function that generates a response for an Announce. +type AnnounceHandler func(*AnnounceRequest) *AnnounceResponse + +// AnnounceCallback is a function that does something with the results of an +// Announce after it has been completed. +type AnnounceCallback func(*AnnounceRequest, *AnnounceResponse) + +// ScrapeRequest represents the parsed parameters from a scrape request. +type ScrapeRequest struct { + InfoHashes []InfoHash + Params Params +} + +// ScrapeResponse represents the parameters used to create a scrape response. +type ScrapeResponse struct { + Files map[InfoHash]Scrape +} + +// Scrape represents the state of a swarm that is returned in a scrape response. +type Scrape struct { + Snatches uint32 + Complete uint32 + Incomplete uint32 +} + +// ScrapeHandler is a function that generates a response for a Scrape. +type ScrapeHandler func(*ScrapeRequest) *ScrapeResponse + +// ScrapeCallback is a function that does something with the results of a +// Scrape after it has been completed. +type ScrapeCallback func(*ScrapeRequest, *ScrapeResponse) + +// Peer represents the connection details of a peer that is returned in an +// announce response. +type Peer struct { + ID PeerID + IP net.IP + Port uint16 +} + +// Equal reports whether p and x are the same. +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. +type Params interface { + String(key string) (string, error) +} + +// ClientError represents an error that should be exposed to the client over +// the BitTorrent protocol implementation. +type ClientError string + +// Error implements the error interface for ClientError. +func (c ClientError) Error() string { return string(c) } + +// Server represents an implementation of the BitTorrent tracker protocol. +type Server interface { + ListenAndServe() error + Stop() +} + +// ServerFuncs are the collection of protocol-agnostic functions used to handle +// requests in a Server. +type ServerFuncs struct { + HandleAnnounce AnnounceHandler + HandleScrape ScrapeHandler + AfterAnnounce AnnounceCallback + AfterScrape ScrapeCallback +} diff --git a/bittorrent/client_id.go b/bittorrent/client_id.go new file mode 100644 index 0000000..4089639 --- /dev/null +++ b/bittorrent/client_id.go @@ -0,0 +1,32 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bittorrent + +// NewClientID returns the part of a PeerID that identifies a peer's client +// software. +func NewClientID(peerID string) (clientID string) { + length := len(peerID) + if length >= 6 { + if peerID[0] == '-' { + if length >= 7 { + clientID = peerID[1:7] + } + } else { + clientID = peerID[:6] + } + } + + return +} diff --git a/bittorrent/client_id_test.go b/bittorrent/client_id_test.go new file mode 100644 index 0000000..699da3e --- /dev/null +++ b/bittorrent/client_id_test.go @@ -0,0 +1,72 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bittorrent + +import "testing" + +func TestClientID(t *testing.T) { + var clientTable = []struct { + peerID string + clientID string + }{ + {"-AZ3034-6wfG2wk6wWLc", "AZ3034"}, + {"-AZ3042-6ozMq5q6Q3NX", "AZ3042"}, + {"-BS5820-oy4La2MWGEFj", "BS5820"}, + {"-AR6360-6oZyyMWoOOBe", "AR6360"}, + {"-AG2083-s1hiF8vGAAg0", "AG2083"}, + {"-AG3003-lEl2Mm4NEO4n", "AG3003"}, + {"-MR1100-00HS~T7*65rm", "MR1100"}, + {"-LK0140-ATIV~nbEQAMr", "LK0140"}, + {"-KT2210-347143496631", "KT2210"}, + {"-TR0960-6ep6svaa61r4", "TR0960"}, + {"-XX1150-dv220cotgj4d", "XX1150"}, + {"-AZ2504-192gwethivju", "AZ2504"}, + {"-KT4310-3L4UvarKuqIu", "KT4310"}, + {"-AZ2060-0xJQ02d4309O", "AZ2060"}, + {"-BD0300-2nkdf08Jd890", "BD0300"}, + {"-A~0010-a9mn9DFkj39J", "A~0010"}, + {"-UT2300-MNu93JKnm930", "UT2300"}, + {"-UT2300-KT4310KT4301", "UT2300"}, + + {"T03A0----f089kjsdf6e", "T03A0-"}, + {"S58B-----nKl34GoNb75", "S58B--"}, + {"M4-4-0--9aa757Efd5Bl", "M4-4-0"}, + + {"AZ2500BTeYUzyabAfo6U", "AZ2500"}, // BitTyrant + {"exbc0JdSklm834kj9Udf", "exbc0J"}, // Old BitComet + {"FUTB0L84j542mVc84jkd", "FUTB0L"}, // Alt BitComet + {"XBT054d-8602Jn83NnF9", "XBT054"}, // XBT + {"OP1011affbecbfabeefb", "OP1011"}, // Opera + {"-ML2.7.2-kgjjfkd9762", "ML2.7."}, // MLDonkey + {"-BOWA0C-SDLFJWEIORNM", "BOWA0C"}, // Bits on Wheels + {"Q1-0-0--dsn34DFn9083", "Q1-0-0"}, // Queen Bee + {"Q1-10-0-Yoiumn39BDfO", "Q1-10-"}, // Queen Bee Alt + {"346------SDFknl33408", "346---"}, // TorreTopia + {"QVOD0054ABFFEDCCDEDB", "QVOD00"}, // Qvod + + {"", ""}, + {"-", ""}, + {"12345", ""}, + {"-12345", ""}, + {"123456", "123456"}, + {"-123456", "123456"}, + } + + for _, tt := range clientTable { + if parsedID := NewClientID(tt.peerID); parsedID != tt.clientID { + t.Error("Incorrectly parsed peer ID", tt.peerID, "as", parsedID) + } + } +} diff --git a/bittorrent/event.go b/bittorrent/event.go new file mode 100644 index 0000000..e5991e6 --- /dev/null +++ b/bittorrent/event.go @@ -0,0 +1,78 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bittorrent + +import ( + "errors" + "strings" +) + +// ErrUnknownEvent is returned when New fails to return an event. +var ErrUnknownEvent = errors.New("unknown event") + +// Event represents an event done by a BitTorrent client. +type Event uint8 + +const ( + // None is the event when a BitTorrent client announces due to time lapsed + // since the previous announce. + None Event = iota + + // Started is the event sent by a BitTorrent client when it joins a swarm. + Started + + // Stopped is the event sent by a BitTorrent client when it leaves a swarm. + Stopped + + // Completed is the event sent by a BitTorrent client when it finishes + // downloading all of the required chunks. + Completed +) + +var ( + eventToString = make(map[Event]string) + stringToEvent = make(map[string]Event) +) + +func init() { + eventToString[None] = "none" + eventToString[Started] = "started" + eventToString[Stopped] = "stopped" + eventToString[Completed] = "completed" + + stringToEvent[""] = None + + for k, v := range eventToString { + stringToEvent[v] = k + } +} + +// NewEvent returns the proper Event given a string. +func NewEvent(eventStr string) (Event, error) { + if e, ok := stringToEvent[strings.ToLower(eventStr)]; ok { + return e, nil + } + + return None, ErrUnknownEvent +} + +// String implements Stringer for an event. +func (e Event) String() string { + if name, ok := eventToString[e]; ok { + return name + } + + panic("bittorrent: event has no associated name") +} diff --git a/bittorrent/event_test.go b/bittorrent/event_test.go new file mode 100644 index 0000000..0ce7944 --- /dev/null +++ b/bittorrent/event_test.go @@ -0,0 +1,43 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bittorrent + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNew(t *testing.T) { + var table = []struct { + data string + expected Event + expectedErr error + }{ + {"", None, nil}, + {"NONE", None, nil}, + {"none", None, nil}, + {"started", Started, nil}, + {"stopped", Stopped, nil}, + {"completed", Completed, nil}, + {"notAnEvent", None, ErrUnknownEvent}, + } + + for _, tt := range table { + got, err := NewEvent(tt.data) + assert.Equal(t, err, tt.expectedErr, "errors should equal the expected value") + assert.Equal(t, got, tt.expected, "events should equal the expected value") + } +} diff --git a/bittorrent/http/parser.go b/bittorrent/http/parser.go new file mode 100644 index 0000000..cbf6ba9 --- /dev/null +++ b/bittorrent/http/parser.go @@ -0,0 +1,168 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "net" + "net/http" + + "github.com/jzelinskie/trakr/bittorrent" +) + +// ParseAnnounce parses an bittorrent.AnnounceRequest from an http.Request. +// +// If allowIPSpoofing is true, IPs provided via params will be used. +// 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) + if err != nil { + return nil, err + } + + request := &bittorrent.AnnounceRequest{Params: q} + + eventStr, err := qp.String("event") + if err == query.ErrKeyNotFound { + eventStr = "" + } else if err != nil { + return nil, bittorrent.ClientError("failed to parse parameter: event") + } + request.Event, err = bittorrent.NewEvent(eventStr) + if err != nil { + return nil, bittorrent.ClientError("failed to provide valid client event") + } + + compactStr, _ := qp.String("compact") + request.Compact = compactStr != "" && compactStr != "0" + + infoHashes := qp.InfoHashes() + if len(infoHashes) < 1 { + return nil, bittorrent.ClientError("no info_hash parameter supplied") + } + if len(infoHashes) > 1 { + return nil, bittorrent.ClientError("multiple info_hash parameters supplied") + } + request.InfoHash = infoHashes[0] + + peerID, err := qp.String("peer_id") + if err != nil { + return nil, bittorrent.ClientError("failed to parse parameter: peer_id") + } + if len(peerID) != 20 { + return nil, bittorrent.ClientError("failed to provide valid peer_id") + } + request.PeerID = bittorrent.PeerIDFromString(peerID) + + request.Left, err = qp.Uint64("left") + if err != nil { + return nil, bittorrent.ClientError("failed to parse parameter: left") + } + + request.Downloaded, err = qp.Uint64("downloaded") + if err != nil { + return nil, bittorrent.ClientError("failed to parse parameter: downloaded") + } + + request.Uploaded, err = qp.Uint64("uploaded") + if err != nil { + return nil, bittorrent.ClientError("failed to parse parameter: uploaded") + } + + numwant, _ := qp.Uint64("numwant") + request.NumWant = int32(numwant) + + port, err := qp.Uint64("port") + if err != nil { + return nil, bittorrent.ClientError("failed to parse parameter: port") + } + request.Port = uint16(port) + + request.IP, err = requestedIP(q, r, realIPHeader, allowIPSpoofing) + if err != nil { + return nil, bittorrent.ClientError("failed to parse peer IP address: " + err.Error()) + } + + return request, nil +} + +// ParseScrape parses an bittorrent.ScrapeRequest from an http.Request. +func ParseScrape(r *http.Request) (*bittorent.ScrapeRequest, error) { + qp, err := NewQueryParams(r.URL.RawQuery) + if err != nil { + return nil, err + } + + infoHashes := qp.InfoHashes() + if len(infoHashes) < 1 { + return nil, bittorrent.ClientError("no info_hash parameter supplied") + } + + request := &bittorrent.ScrapeRequest{ + InfoHashes: infoHashes, + Params: q, + } + + return request, nil +} + +// requestedIP determines the IP address for a BitTorrent client request. +// +// If allowIPSpoofing is true, IPs provided via params will be used. +// If realIPHeader is not empty string, the first value of the HTTP Header with +// that name will be used. +func requestedIP(r *http.Request, p bittorent.Params, realIPHeader string, allowIPSpoofing bool) (net.IP, error) { + if allowIPSpoofing { + if ipstr, err := p.String("ip"); err == nil { + ip, err := net.ParseIP(str) + if err != nil { + return nil, err + } + + return ip, nil + } + + if ipstr, err := p.String("ipv4"); err == nil { + ip, err := net.ParseIP(str) + if err != nil { + return nil, err + } + + return ip, nil + } + + if ipstr, err := p.String("ipv6"); err == nil { + ip, err := net.ParseIP(str) + if err != nil { + return nil, err + } + + return ip, nil + } + } + + if realIPHeader != "" { + if ips, ok := r.Header[realIPHeader]; ok && len(ips) > 0 { + ip, err := net.ParseIP(ips[0]) + if err != nil { + return nil, err + } + + return ip, nil + } + } + + return r.RemoteAddr +} diff --git a/bittorrent/http/query_params.go b/bittorrent/http/query_params.go new file mode 100644 index 0000000..b3fc62c --- /dev/null +++ b/bittorrent/http/query_params.go @@ -0,0 +1,141 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "errors" + "net/url" + "strconv" + "strings" + + "github.com/jzelinskie/trakr/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) (*Query, error) { + var ( + keyStart, keyEnd int + valStart, valEnd int + + onKey = true + + q = &Query{ + 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 (q *Query) String(key string) (string, error) { + val, exists := q.params[key] + if !exists { + return "", ErrKeyNotFound + } + return val, nil +} + +// Uint64 returns a uint parsed from a query. After being called, it is safe to +// cast the uint64 to your desired length. +func (q *Query) Uint64(key string) (uint64, error) { + str, exists := q.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 (q *Query) InfoHashes() []bittorrent.InfoHash { + return q.infoHashes +} diff --git a/bittorrent/http/query_params_test.go b/bittorrent/http/query_params_test.go new file mode 100644 index 0000000..0d96fa5 --- /dev/null +++ b/bittorrent/http/query_params_test.go @@ -0,0 +1,110 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "net/url" + "testing" +) + +var ( + baseAddr = "https://www.subdomain.tracker.com:80/" + testInfoHash = "01234567890123456789" + 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"}}, + {"peer_id": {testPeerID}, "ip": {"192.168.0.1"}, "port": {"6881"}, "downloaded": {"1234"}, "left": {"4321"}, "event": {"stopped"}}, + {"peer_id": {testPeerID}, "ip": {"192.168.0.1"}, "port": {"6881"}, "downloaded": {"1234"}, "left": {"4321"}, "event": {"started"}, "numwant": {"13"}}, + {"peer_id": {testPeerID}, "port": {"6881"}, "downloaded": {"1234"}, "left": {"4321"}, "no_peer_id": {"1"}}, + {"peer_id": {testPeerID}, "port": {"6881"}, "downloaded": {"1234"}, "left": {"4321"}, "compact": {"0"}, "no_peer_id": {"1"}}, + {"peer_id": {testPeerID}, "port": {"6881"}, "downloaded": {"1234"}, "left": {"4321"}, "compact": {"0"}, "no_peer_id": {"1"}, "key": {"peerKey"}}, + {"peer_id": {testPeerID}, "port": {"6881"}, "downloaded": {"1234"}, "left": {"4321"}, "compact": {"0"}, "no_peer_id": {"1"}, "key": {"peerKey"}, "trackerid": {"trackerId"}}, + {"peer_id": {"%3Ckey%3A+0x90%3E"}, "port": {"6881"}, "downloaded": {"1234"}, "left": {"4321"}, "compact": {"0"}, "no_peer_id": {"1"}, "key": {"peerKey"}, "trackerid": {"trackerId"}}, + {"peer_id": {"%3Ckey%3A+0x90%3E"}, "compact": {"1"}}, + {"peer_id": {""}, "compact": {""}}, + } + + InvalidQueries = []string{ + baseAddr + "announce/?" + "info_hash=%0%a", + } +) + +func mapArrayEqual(boxed map[string][]string, unboxed map[string]string) bool { + if len(boxed) != len(unboxed) { + return false + } + + for mapKey, mapVal := range boxed { + // Always expect box to hold only one element + if len(mapVal) != 1 || mapVal[0] != unboxed[mapKey] { + return false + } + } + + return true +} + +func TestValidQueries(t *testing.T) { + for parseIndex, parseVal := range ValidAnnounceArguments { + parsedQueryObj, err := NewQueryParams(baseAddr + "announce/?" + parseVal.Encode()) + if err != nil { + t.Error(err) + } + + if !mapArrayEqual(parseVal, parsedQueryObj.params) { + t.Errorf("Incorrect parse at item %d.\n Expected=%v\n Recieved=%v\n", parseIndex, parseVal, parsedQueryObj.params) + } + } +} + +func TestInvalidQueries(t *testing.T) { + for parseIndex, parseStr := range InvalidQueries { + parsedQueryObj, err := NewQueryParams(parseStr) + if err == nil { + t.Error("Should have produced error", parseIndex) + } + + if parsedQueryObj != nil { + t.Error("Should be nil after error", parsedQueryObj, parseIndex) + } + } +} + +func BenchmarkParseQuery(b *testing.B) { + for bCount := 0; bCount < b.N; bCount++ { + for parseIndex, parseStr := range ValidAnnounceArguments { + parsedQueryObj, err := NewQueryParams(baseAddr + "announce/?" + parseStr.Encode()) + if err != nil { + b.Error(err, parseIndex) + b.Log(parsedQueryObj) + } + } + } +} + +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()) + if err != nil { + b.Error(err, parseIndex) + b.Log(parsedQueryObj) + } + } + } +} diff --git a/bittorrent/http/server.go b/bittorrent/http/server.go new file mode 100644 index 0000000..fb6ec74 --- /dev/null +++ b/bittorrent/http/server.go @@ -0,0 +1,136 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +type Config struct { + Addr string + ReadTimeout time.Duration + WriteTimeout time.Duration + RequestTimeout time.Duration + AllowIPSpoofing bool + RealIPHeader string +} + +type Server struct { + grace *graceful.Server + + bittorrent.ServerFuncs + Config +} + +func NewServer(funcs bittorrent.ServerFuncs, cfg Config) { + return &Server{ + ServerFuncs: funcs, + Config: cfg, + } +} + +func (s *Server) Stop() { + s.grace.Stop(s.grace.Timeout) + <-s.grace.StopChan() +} + +func (s *Server) handler() { + router := httprouter.New() + router.GET("/announce", s.announceRoute) + router.GET("/scrape", s.scrapeRoute) + return server +} + +func (s *Server) ListenAndServe() error { + s.grace = &graceful.Server{ + Server: &http.Server{ + Addr: s.Addr, + Handler: s.handler(), + ReadTimeout: s.ReadTimeout, + WriteTimeout: s.WriteTimeout, + }, + Timeout: s.RequestTimeout, + NoSignalHandling: true, + ConnState: func(conn net.Conn, state http.ConnState) { + switch state { + case http.StateNew: + //stats.RecordEvent(stats.AcceptedConnection) + + case http.StateClosed: + //stats.RecordEvent(stats.ClosedConnection) + + case http.StateHijacked: + panic("http: connection impossibly hijacked") + + // Ignore the following cases. + case http.StateActive, http.StateIdle: + + default: + panic("http: connection transitioned to unknown state") + } + }, + } + s.grace.SetKeepAlivesEnabled(false) + + if err := s.grace.ListenAndServe(); err != nil { + if opErr, ok := err.(*net.OpError); !ok || (ok && opErr.Op != "accept") { + panic("http: failed to gracefully run HTTP server: " + err.Error()) + } + } +} + +func (s *Server) announceRoute(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + req, err := ParseAnnounce(r, s.RealIPHeader, s.AllowIPSpoofing) + if err != nil { + WriteError(w, err) + return + } + + resp, err := s.HandleAnnounce(req) + if err != nil { + WriteError(w, err) + return + } + + err = WriteAnnounceResponse(w, resp) + if err != nil { + WriteError(w, err) + return + } + + if s.AfterAnnounce != nil { + s.AfterAnnounce(req, resp) + } +} + +func (s *Server) scrapeRoute(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + req, err := ParseScrape(r) + if err != nil { + WriteError(w, err) + return + } + + resp, err := s.HandleScrape(req) + if err != nil { + WriteError(w, err) + return + } + + err = WriteScrapeResponse(w, resp) + if err != nil { + WriteError(w, err) + return + } + + if s.AfterScrape != nil { + s.AfterScrape(req, resp) + } +} diff --git a/bittorrent/http/writer.go b/bittorrent/http/writer.go new file mode 100644 index 0000000..a0da645 --- /dev/null +++ b/bittorrent/http/writer.go @@ -0,0 +1,111 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "net/http" + + "github.com/jzelinskie/trakr/bittorrent" +) + +// WriteError communicates an error to a BitTorrent client over HTTP. +func WriteError(w http.ResponseWriter, err error) error { + message := "internal server error" + if _, clientErr := err.(bittorrent.ClientError); clientErr { + message = err.Error() + } + + w.WriteHeader(http.StatusOK) + return bencode.NewEncoder(w).Encode(bencode.Dict{ + "failure reason": message, + }) +} + +// WriteAnnounceResponse communicates the results of an Announce to a +// BitTorrent client over HTTP. +func WriteAnnounceResponse(w http.ResponseWriter, resp *bittorrent.AnnounceResponse) error { + bdict := bencode.Dict{ + "complete": resp.Complete, + "incomplete": resp.Incomplete, + "interval": resp.Interval, + "min interval": resp.MinInterval, + } + + // Add the peers to the dictionary in the compact format. + if resp.Compact { + var IPv4CompactDict, IPv6CompactDict []byte + + // Add the IPv4 peers to the dictionary. + for _, peer := range resp.IPv4Peers { + IPv4CompactDict = append(IPv4CompactDict, compact(peer)...) + } + if len(IPv4CompactDict) > 0 { + bdict["peers"] = IPv4CompactDict + } + + // Add the IPv6 peers to the dictionary. + for _, peer := range resp.IPv6Peers { + IPv6CompactDict = append(IPv6CompactDict, compact(peer)...) + } + if len(IPv6CompactDict) > 0 { + bdict["peers6"] = IPv6CompactDict + } + + return bencode.NewEncoder(w).Encode(bdict) + } + + // Add the peers to the dictionary. + var peers []bencode.Dict + for _, peer := range resp.IPv4Peers { + peers = append(peers, dict(peer)) + } + for _, peer := range resp.IPv6Peers { + peers = append(peers, dict(peer)) + } + bdict["peers"] = peers + + return bencode.NewEncoder(w).Encode(bdict) +} + +// WriteScrapeResponse communicates the results of a Scrape to a BitTorrent +// client over HTTP. +func WriteScrapeResponse(w http.ResponseWriter, resp *bittorrent.ScrapeResponse) error { + filesDict := bencode.NewDict() + for infohash, scrape := range resp.Files { + filesDict[string(infohash[:])] = bencode.Dict{ + "complete": scrape.Complete, + "incomplete": scrape.Incomplete, + } + } + + return bencode.NewEncoder(w).Encode(bencode.Dict{ + "files": filesDict, + }) +} + +func compact(peer bittorrent.Peer) (buf []byte) { + buf = []byte(peer.IP) + buf = append(buf, byte(peer.Port>>8)) + buf = append(buf, byte(peer.Port&0xff)) + return +} + +func dict(peer bittorrent.Peer) bencode.Dict { + return bencode.Dict{ + "peer id": string(peer.ID[:]), + "ip": peer.IP.String(), + "port": peer.Port, + } +} diff --git a/bittorrent/http/writer_test.go b/bittorrent/http/writer_test.go new file mode 100644 index 0000000..4c9b185 --- /dev/null +++ b/bittorrent/http/writer_test.go @@ -0,0 +1,46 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "net/http/httptest" + "testing" + + "github.com/jzelinskie/trakr/bittorrent" + "github.com/stretchr/testify/assert" +) + +func TestWriteError(t *testing.T) { + var table = []struct { + reason, expected string + }{ + {"hello world", "d14:failure reason11:hello worlde"}, + {"what's up", "d14:failure reason9:what's upe"}, + } + + for _, tt := range table { + r := httptest.NewRecorder() + err := writeError(r, bittorrent.ClientError(tt.reason)) + assert.Nil(t, err) + assert.Equal(t, r.Body.String(), tt.expected) + } +} + +func TestWriteStatus(t *testing.T) { + r := httptest.NewRecorder() + err := writeError(r, bittorrent.ClientError("something is missing")) + assert.Nil(t, err) + assert.Equal(t, r.Body.String(), "d14:failure reason20:something is missinge") +} diff --git a/bittorrent/udp/connection_id.go b/bittorrent/udp/connection_id.go new file mode 100644 index 0000000..944f4d8 --- /dev/null +++ b/bittorrent/udp/connection_id.go @@ -0,0 +1,64 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package udp + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "net" + "time" +) + +// ttl is the number of seconds a connection ID should be valid according to +// BEP 15. +const ttl = 2 * time.Minute + +// NewConnectionID creates a new 8 byte connection identifier for UDP packets +// as described by BEP 15. +// +// The first 4 bytes of the connection identifier is a unix timestamp and the +// last 4 bytes are a truncated HMAC token created from the aforementioned +// unix timestamp and the source IP address of the UDP packet. +// +// Truncated HMAC is known to be safe for 2^(-n) where n is the size in bits +// of the truncated HMAC token. In this use case we have 32 bits, thus a +// forgery probability of approximately 1 in 4 billion. +func NewConnectionID(ip net.IP, now time.Time, key string) []byte { + buf := make([]byte, 8) + binary.BigEndian.PutUint32(buf, uint32(now.UTC().Unix())) + + mac := hmac.New(sha256.New, []byte(key)) + mac.Write(buf[:4]) + mac.Write(ip) + macBytes := mac.Sum(nil)[:4] + copy(buf[4:], macBytes) + + return buf +} + +// ValidConnectionID determines whether a connection identifier is legitimate. +func ValidConnectionID(connectionID []byte, ip net.IP, now time.Time, maxClockSkew time.Duration, key string) bool { + ts := time.Unix(int64(binary.BigEndian.Uint32(connectionID[:4])), 0) + if now.After(ts.Add(ttl)) || ts.After(now.Add(maxClockSkew)) { + return false + } + + mac := hmac.New(sha256.New, []byte(key)) + mac.Write(connectionID[:4]) + mac.Write(ip) + expectedMAC := mac.Sum(nil)[:4] + return hmac.Equal(expectedMAC, connectionID[4:]) +} diff --git a/bittorrent/udp/connection_id_test.go b/bittorrent/udp/connection_id_test.go new file mode 100644 index 0000000..776b61f --- /dev/null +++ b/bittorrent/udp/connection_id_test.go @@ -0,0 +1,43 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package udp + +import ( + "net" + "testing" + "time" +) + +var golden = []struct { + createdAt int64 + now int64 + ip string + key string + valid bool +}{ + {0, 1, "127.0.0.1", "", true}, + {0, 420420, "127.0.0.1", "", false}, + {0, 0, "[::]", "", true}, +} + +func TestVerification(t *testing.T) { + for _, tt := range golden { + cid := NewConnectionID(net.ParseIP(tt.ip), time.Unix(tt.createdAt, 0), tt.key) + got := ValidConnectionID(cid, net.ParseIP(tt.ip), time.Unix(tt.now, 0), time.Minute, tt.key) + if got != tt.valid { + t.Errorf("expected validity: %t got validity: %t", tt.valid, got) + } + } +} diff --git a/bittorrent/udp/parser.go b/bittorrent/udp/parser.go new file mode 100644 index 0000000..85e4469 --- /dev/null +++ b/bittorrent/udp/parser.go @@ -0,0 +1,178 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package udp + +import ( + "encoding/binary" + "net" + + "github.com/jzelinskie/trakr/bittorrent" +) + +const ( + connectActionID uint32 = iota + announceActionID + scrapeActionID + errorActionID + announceDualStackActionID +) + +// Option-Types as described in BEP 41 and BEP 45. +const ( + optionEndOfOptions byte = 0x0 + optionNOP = 0x1 + optionURLData = 0x2 +) + +var ( + // initialConnectionID is the magic initial connection ID specified by BEP 15. + initialConnectionID = []byte{0, 0, 0x04, 0x17, 0x27, 0x10, 0x19, 0x80} + + // emptyIPs are the value of an IP field that has been left blank. + emptyIPv4 = []byte{0, 0, 0, 0} + emptyIPv6 = []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} + + // eventIDs map values described in BEP 15 to Events. + eventIDs = []bittorrent.Event{ + bittorrent.None, + bittorrent.Completed, + bittorrent.Started, + 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") +) + +// ParseAnnounce parses an AnnounceRequest from a UDP request. +// +// If allowIPSpoofing is true, IPs provided via params will be used. +func ParseAnnounce(r Request, allowIPSpoofing bool) (*bittorrent.AnnounceRequest, error) { + if len(r.packet) < 98 { + return nil, errMalformedPacket + } + + infohash := r.packet[16:36] + peerID := r.packet[36:56] + downloaded := binary.BigEndian.Uint64(r.packet[56:64]) + left := binary.BigEndian.Uint64(r.packet[64:72]) + uploaded := binary.BigEndian.Uint64(r.packet[72:80]) + + eventID := int(r.packet[83]) + if eventID >= len(eventIDs) { + return nil, errMalformedEvent + } + + ip := r.IP + ipbytes := r.packet[84:88] + if allowIPSpoofing { + ip = net.IP(ipbytes) + } + if !allowIPSpoofing && r.ip == nil { + // We have no IP address to fallback on. + return nil, errMalformedIP + } + + numWant := binary.BigEndian.Uint32(r.packet[92:96]) + port := binary.BigEndian.Uint16(r.packet[96:98]) + + params, err := handleOptionalParameters(r.packet) + if err != nil { + return nil, err + } + + return &bittorrent.AnnounceRequest{ + Event: eventIDs[eventID], + InfoHash: bittorrent.InfoHashFromBytes(infohash), + NumWant: uint32(numWant), + Left: left, + Downloaded: downloaded, + Uploaded: uploaded, + Peer: bittorrent.Peer{ + ID: bittorrent.PeerIDFromBytes(peerID), + IP: ip, + Port: port, + }, + Params: params, + }, nil +} + +// 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 + } + + optionStartIndex := 98 + for optionStartIndex < len(packet)-1 { + option := packet[optionStartIndex] + switch option { + case optionEndOfOptions: + return + + case optionNOP: + optionStartIndex++ + + case optionURLData: + if optionStartIndex+1 > len(packet)-1 { + return params, errMalformedPacket + } + + length := int(packet[optionStartIndex+1]) + if optionStartIndex+1+length > len(packet)-1 { + return params, errMalformedPacket + } + + // TODO(jzelinskie): Actually parse the URL Data as described in BEP 41 + // into something that fulfills the bittorrent.Params interface. + + optionStartIndex += 1 + length + default: + return + } + } + + return +} + +// ParseScrape parses a ScrapeRequest from a UDP request. +func parseScrape(r Request) (*bittorrent.ScrapeRequest, error) { + // If a scrape isn't at least 36 bytes long, it's malformed. + if len(r.packet) < 36 { + return nil, errMalformedPacket + } + + // Skip past the initial headers and check that the bytes left equal the + // length of a valid list of infohashes. + r.packet = r.packet[16:] + if len(r.packet)%20 != 0 { + return nil, errMalformedPacket + } + + // Allocate a list of infohashes and append it to the list until we're out. + var infohashes []bittorrent.InfoHash + for len(r.packet) >= 20 { + infohashes = append(infohashes, bittorrent.InfoHashFromBytes(r.packet[:20])) + r.packet = r.packet[20:] + } + + return &bittorrent.ScrapeRequest{ + InfoHashes: infohashes, + }, nil +} diff --git a/bittorrent/udp/server.go b/bittorrent/udp/server.go new file mode 100644 index 0000000..5ea4a36 --- /dev/null +++ b/bittorrent/udp/server.go @@ -0,0 +1,234 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package udp + +import ( + "bytes" + "encoding/binary" + "net" + "time" + + "github.com/jzelinskie/trakr/bittorrent" +) + +var promResponseDurationMilliseconds = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "trakr_udp_response_duration_milliseconds", + Help: "The duration of time it takes to receive and write a response to an API request", + Buckets: prometheus.ExponentialBuckets(9.375, 2, 10), + }, + []string{"action", "error"}, +) + +type Config struct { + Addr string + PrivateKey string + AllowIPSpoofing bool +} + +type Server struct { + sock *net.UDPConn + closing chan struct{} + wg sync.WaitGroup + + bittorrent.ServerFuncs + Config +} + +func NewServer(funcs bittorrent.ServerFuncs, cfg Config) { + return &Server{ + closing: make(chan struct{}), + ServerFuncs: funcs, + Config: cfg, + } +} + +func (s *udpServer) Stop() { + close(s.closing) + s.sock.SetReadDeadline(time.Now()) + s.wg.Wait() +} + +func (s *Server) ListenAndServe() error { + udpAddr, err := net.ResolveUDPAddr("udp", s.Addr) + if err != nil { + return err + } + + s.sock, err = net.ListenUDP("udp", udpAddr) + if err != nil { + return err + } + defer s.sock.Close() + + pool := bytepool.New(256, 2048) + + for { + // Check to see if we need to shutdown. + select { + case <-s.closing: + s.wg.Wait() + return nil + default: + } + + // Read a UDP packet into a reusable buffer. + buffer := pool.Get() + s.sock.SetReadDeadline(time.Now().Add(time.Second)) + n, addr, err := s.sock.ReadFromUDP(buffer) + if err != nil { + pool.Put(buffer) + if netErr, ok := err.(net.Error); ok && netErr.Temporary() { + // A temporary failure is not fatal; just pretend it never happened. + continue + } + return err + } + + // We got nothin' + if n == 0 { + pool.Put(buffer) + continue + } + + log.Println("Got UDP packet") + start := time.Now() + s.wg.Add(1) + go func(start time.Time) { + defer s.wg.Done() + defer pool.Put(buffer) + + // Handle the response. + response, action, err := s.handlePacket(buffer[:n], addr) + log.Printf("Handled UDP packet: %s, %s, %s\n", response, action, err) + + // Record to Prometheus the time in milliseconds to receive, handle, and + // respond to the request. + duration := time.Since(start) + if err != nil { + promResponseDurationMilliseconds.WithLabelValues(action, err.Error()).Observe(float64(duration.Nanoseconds()) / float64(time.Millisecond)) + } else { + promResponseDurationMilliseconds.WithLabelValues(action, "").Observe(float64(duration.Nanoseconds()) / float64(time.Millisecond)) + } + }(start) + } +} + +type Request struct { + Packet []byte + IP net.IP +} + +type ResponseWriter struct { + socket net.UDPConn + addr net.UDPAddr +} + +func (w *ResponseWriter) Write(b []byte) (int, error) { + w.socket.WriteToUDP(b, w.addr) + return len(b), nil +} + +func (s *Server) handlePacket(r *Request, w *ResponseWriter) (response []byte, actionName string, err error) { + if len(r.packet) < 16 { + // Malformed, no client packets are less than 16 bytes. + // We explicitly return nothing in case this is a DoS attempt. + err = errMalformedPacket + return + } + + // Parse the headers of the UDP packet. + connID := r.packet[0:8] + actionID := binary.BigEndian.Uint32(r.packet[8:12]) + txID := r.packet[12:16] + + // If this isn't requesting a new connection ID and the connection ID is + // invalid, then fail. + if actionID != connectActionID && !ValidConnectionID(connID, r.IP, time.Now(), s.PrivateKey) { + err = errBadConnectionID + WriteError(w, txID, err) + return + } + + // Handle the requested action. + switch actionID { + case connectActionID: + actionName = "connect" + + if !bytes.Equal(connID, initialConnectionID) { + err = errMalformedPacket + return + } + + WriteConnectionID(w, txID, NewConnectionID(r.IP, time.Now(), s.PrivateKey)) + return + + case announceActionID: + actionName = "announce" + + var req *bittorrent.AnnounceRequest + req, err = ParseAnnounce(r, s.AllowIPSpoofing) + if err != nil { + WriteError(w, txID, err) + return + } + + var resp *bittorrent.AnnounceResponse + resp, err = s.HandleAnnounce(req) + if err != nil { + WriteError(w, txID, err) + return + } + + WriteAnnounce(w, txID, resp) + + if s.AfterAnnounce != nil { + s.AfterAnnounce(req, resp) + } + + return + + case scrapeActionID: + actionName = "scrape" + + var req *bittorrent.ScrapeRequest + req, err = ParseScrape(r) + if err != nil { + WriteError(w, txID, err) + return + } + + var resp *bittorrent.ScrapeResponse + ctx := context.TODO() + resp, err = s.HandleScrape(ctx, req) + if err != nil { + WriteError(w, txID, err) + return + } + + WriteScrape(w, txID, resp) + + if s.AfterScrape != nil { + s.AfterScrape(req, resp) + } + + return + + default: + err = errUnknownAction + WriteError(w, txID, err) + return + } +} diff --git a/bittorrent/udp/writer.go b/bittorrent/udp/writer.go new file mode 100644 index 0000000..068741a --- /dev/null +++ b/bittorrent/udp/writer.go @@ -0,0 +1,75 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package udp + +import ( + "bytes" + "encoding/binary" + "fmt" + "time" + + "github.com/jzelinskie/trakr/bittorrent" +) + +// WriteError writes the failure reason as a null-terminated string. +func WriteError(writer io.Writer, txID []byte, err error) { + // If the client wasn't at fault, acknowledge it. + if _, ok := err.(bittorrent.ClientError); !ok { + err = fmt.Errorf("internal error occurred: %s", err.Error()) + } + + var buf bytes.Buffer + writeHeader(buf, txID, errorActionID) + buf.WriteString(err.Error()) + buf.WriteRune('\000') + writer.Write(buf.Bytes()) +} + +// WriteAnnounce encodes an announce response according to BEP 15. +func WriteAnnounce(respBuf *bytes.Buffer, txID []byte, resp *bittorrent.AnnounceResponse) { + writeHeader(respBuf, txID, announceActionID) + binary.Write(respBuf, binary.BigEndian, uint32(resp.Interval/time.Second)) + binary.Write(respBuf, binary.BigEndian, uint32(resp.Incomplete)) + binary.Write(respBuf, binary.BigEndian, uint32(resp.Complete)) + + for _, peer := range resp.IPv4Peers { + respBuf.Write(peer.IP) + binary.Write(respBuf, binary.BigEndian, peer.Port) + } +} + +// WriteScrape encodes a scrape response according to BEP 15. +func WriteScrape(respBuf *bytes.Buffer, txID []byte, resp *bittorrent.ScrapeResponse) { + writeHeader(respBuf, txID, scrapeActionID) + + for _, scrape := range resp.Files { + binary.Write(respBuf, binary.BigEndian, scrape.Complete) + binary.Write(respBuf, binary.BigEndian, scrape.Snatches) + binary.Write(respBuf, binary.BigEndian, scrape.Incomplete) + } +} + +// WriteConnectionID encodes a new connection response according to BEP 15. +func WriteConnectionID(respBuf *bytes.Buffer, txID, connID []byte) { + writeHeader(respBuf, txID, connectActionID) + respBuf.Write(connID) +} + +// writeHeader writes the action and transaction ID to the provided response +// buffer. +func writeHeader(respBuf *bytes.Buffer, txID []byte, action uint32) { + binary.Write(respBuf, binary.BigEndian, action) + respBuf.Write(txID) +} diff --git a/cmd/trakr/config.go b/cmd/trakr/config.go new file mode 100644 index 0000000..e69de29 diff --git a/cmd/trakr/main.go b/cmd/trakr/main.go new file mode 100644 index 0000000..e69de29 diff --git a/hook.go b/hook.go new file mode 100644 index 0000000..8707d0e --- /dev/null +++ b/hook.go @@ -0,0 +1,77 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package trakr + +import "github.com/jzelinskie/trakr/bittorrent" + +// HookConstructor is a function used to create a new instance of a Hook. +type HookConstructor func(interface{}) (Hook, error) + +// Hook abstracts the concept of anything that needs to interact with a +// BitTorrent client's request and response to a BitTorrent tracker. +type Hook interface { + HandleAnnounce(context.Context, bittorrent.AnnounceRequest, bittorrent.AnnounceResponse) error + HandleScrape(context.Context, bittorrent.ScrapeRequest, bittorrent.ScrapeResponse) error +} + +var preHooks = make(map[string]HookConstructor) + +// RegisterPreHook makes a HookConstructor available by the provided name. +// +// If this function is called twice with the same name or if the +// HookConstructor is nil, it panics. +func RegisterPreHook(name string, con HookConstructor) { + if con == nil { + panic("trakr: could not register nil HookConstructor") + } + if _, dup := constructors[name]; dup { + panic("trakr: could not register duplicate HookConstructor: " + name) + } + preHooks[name] = con +} + +// NewPreHook creates an instance of the given PreHook by name. +func NewPreHook(name string, config interface{}) (Hook, error) { + con := preHooks[name] + if !ok { + return nil, fmt.Errorf("trakr: unknown PreHook %q (forgotten import?)", name) + } + return con(config) +} + +var postHooks = make(map[string]HookConstructor) + +// RegisterPostHook makes a HookConstructor available by the provided name. +// +// If this function is called twice with the same name or if the +// HookConstructor is nil, it panics. +func RegisterPostHook(name string, con HookConstructor) { + if con == nil { + panic("trakr: could not register nil HookConstructor") + } + if _, dup := constructors[name]; dup { + panic("trakr: could not register duplicate HookConstructor: " + name) + } + preHooks[name] = con +} + +// NewPostHook creates an instance of the given PostHook by name. +func NewPostHook(name string, config interface{}) (Hook, error) { + con := preHooks[name] + if !ok { + return nil, fmt.Errorf("trakr: unknown PostHook %q (forgotten import?)", name) + } + return con(config) +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..8361a6b --- /dev/null +++ b/server.go @@ -0,0 +1,28 @@ +// Copyright 2016 Jimmy Zelinskie +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package trakr + +type Server struct { + HTTPConfig http.Config + UDPConfig udp.Config + Interval time.Duration + PreHooks []string + PostHooks []string + + udpserver +} + +func (s *Server) ListenAndServe() error { +}