├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── assets ├── demo.gif └── how-it-works.png ├── bencode ├── bencode.go ├── bencode_test.go └── torrent_files_test │ └── debian-12.0.0-amd64-DVD-1.iso.torrent ├── emulation ├── emulation.go ├── emulation_test.go └── static │ ├── qbit-4.0.3.json │ └── qbit-4.3.3.json ├── generator ├── key.go ├── key_test.go ├── peerId.go ├── rouding.go └── rounding_test.go ├── go.mod ├── go.sum ├── input ├── input.go └── input_test.go ├── main.go ├── printer ├── printer.go └── printer_test.go ├── ratiospoof ├── ratiospoof.go └── ratiospoof_test.go └── tracker ├── tracker.go └── tracker_test.go /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: '1.20' 20 | 21 | - name: Build 22 | run: go build -v ./... 23 | 24 | - name: Test 25 | run: go test -v ./... 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | #vscode folder 132 | /.vscode 133 | 134 | out/ 135 | .idea/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 ap-pauloafonso 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | go test ./... -count=1 --cover 3 | 4 | torrent-test: 5 | go run main.go -c qbit-4.3.3 -t bencode/torrent_files_test/debian-12.0.0-amd64-DVD-1.iso.torrent -d 0% -ds 100kbps -u 0% -us 100kbps 6 | 7 | release: 8 | @if test -z "$(rsversion)"; then echo "usage: make release rsversion=v1.2"; exit 1; fi 9 | rm -rf ./out 10 | 11 | env GOOS=darwin GOARCH=amd64 go build -v -o ./out/mac/ratio-spoof . 12 | env GOOS=linux GOARCH=amd64 go build -v -o ./out/linux/ratio-spoof . 13 | env GOOS=windows GOARCH=amd64 go build -v -o ./out/windows/ratio-spoof.exe . 14 | 15 | cd out/ ; zip ratio-spoof-$(rsversion)\(linux-mac-windows\).zip -r . -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ratio-spoof 2 | Ratio-spoof is a cross-platform, free and open source tool to spoof the download/upload amount on private bittorrent trackers. 3 | 4 | ![](./assets/demo.gif) 5 | 6 | ## Motivation 7 | Here in Brazil, not everybody has a great upload speed, and most private trackers require a ratio greater than or equal to 1. For example, if you downloaded 1GB, you must also upload 1GB in order to survive. Additionally, I have always been fascinated by the BitTorrent protocol. In fact, [I even made a BitTorrent web client to learn more about it](https://github.com/ap-pauloafonso/rwTorrent). So, if you have a bad internet connection, feel free to use this tool. Otherwise, please consider seeding the files with a real torrent client. 8 | 9 | ## How does it work? 10 | ![Diagram](./assets/how-it-works.png) 11 | Bittorrent protocol works in such a way that there is no way that a tracker knows how much certain peer have downloaded or uploaded, so the tracker depends on the peer itself telling the amounts. 12 | 13 | Ratio-spoof acts like a normal bittorrent client but without downloading or uploading anything, in fact it just tricks the tracker pretending that. 14 | 15 | ## Usage 16 | ``` 17 | usage: 18 | ./ratio-spoof -t -d -ds -u -us 19 | 20 | optional arguments: 21 | -h show this help message and exit 22 | -p [PORT] change the port number, default: 8999 23 | -c [CLIENT_CODE] change the client emulation, default: qbit-4.0.3 24 | 25 | required arguments: 26 | -t 27 | -d 28 | -ds 29 | -u 30 | -us 31 | 32 | and must be in %, b, kb, mb, gb, tb 33 | and must be in kbps, mbps 34 | [CLIENT_CODE] options: qbit-4.0.3, qbit-4.3.3 35 | ``` 36 | 37 | ``` 38 | ./ratio-spoof -d 90% -ds 100kbps -u 0% -us 1024kbps -t (torrentfile_path) 39 | ``` 40 | * Will start "downloading" with the initial value of 90% of the torrent total size at 100 kbps speed until it reaches 100% mark. 41 | * Will start "uploading" with the initial value of 0% of the torrent total size at 1024kbps (aka 1mb/s) indefinitely. 42 | 43 | ``` 44 | ./ratio-spoof -d 2gb -ds 500kbps -u 1gb -us 1024kbps -t (torrentfile_path) 45 | ``` 46 | * Will start "downloading" with the initial value of 2gb downloaded if possible at 500kbps speed until it reaches 100% mark. 47 | * Will start "uploading" with the initial value of 1gb uplodead at 1024kbps (aka 1mb/s) indefinitely. 48 | 49 | ## Will I get caught using it ? 50 | Depends on whether you use it carefully, It's a hard task to catch cheaters, but if you start uploading crazy amounts out of nowhere or seeding something with no active leecher on the swarm you may be in risk. 51 | 52 | ## Bittorrent client supported 53 | The default client emulation is qbittorrent v4.0.3, however you can change it by using the -c argument 54 | 55 | ## Resources 56 | http://www.bittorrent.org/beps/bep_0003.html 57 | 58 | https://wiki.theory.org/BitTorrentSpecification 59 | 60 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ap-pauloafonso/ratio-spoof/032536eda4e66758f7a8a42cc965132bffa345be/assets/demo.gif -------------------------------------------------------------------------------- /assets/how-it-works.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ap-pauloafonso/ratio-spoof/032536eda4e66758f7a8a42cc965132bffa345be/assets/how-it-works.png -------------------------------------------------------------------------------- /bencode/bencode.go: -------------------------------------------------------------------------------- 1 | package bencode 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha1" 6 | "fmt" 7 | "regexp" 8 | "strconv" 9 | ) 10 | 11 | const ( 12 | dictToken = byte('d') 13 | numberToken = byte('i') 14 | listToken = byte('l') 15 | endOfCollectionToken = byte('e') 16 | lengthValueStringSeparatorToken = byte(':') 17 | 18 | torrentInfoKey = "info" 19 | torrentNameKey = "name" 20 | torrentPieceLengthKey = "piece length" 21 | torrentLengthKey = "length" 22 | torrentFilesKey = "files" 23 | mainAnnounceKey = "announce" 24 | announceListKey = "announce-list" 25 | torrentDictOffsetsKey = "byte_offsets" 26 | ) 27 | 28 | // TorrentInfo contains all relevant information extracted from a bencode file 29 | type TorrentInfo struct { 30 | Name string 31 | PieceSize int 32 | TotalSize int 33 | TrackerInfo *TrackerInfo 34 | InfoHashURLEncoded string 35 | } 36 | 37 | //TrackerInfo contains http urls from the tracker 38 | type TrackerInfo struct { 39 | Main string 40 | Urls []string 41 | } 42 | 43 | type torrentDict struct { 44 | resultMap map[string]interface{} 45 | } 46 | 47 | //TorrentDictParse decodes the bencoded bytes and builds the torrentInfo file 48 | func TorrentDictParse(dat []byte) (torrent *TorrentInfo, err error) { 49 | defer func() { 50 | if e := recover(); e != nil { 51 | err = e.(error) 52 | } 53 | }() 54 | 55 | dict, _ := mapParse(0, &dat) 56 | torrentMap := torrentDict{resultMap: dict} 57 | return &TorrentInfo{ 58 | Name: torrentMap.resultMap[torrentInfoKey].(map[string]interface{})[torrentNameKey].(string), 59 | PieceSize: torrentMap.resultMap[torrentInfoKey].(map[string]interface{})[torrentPieceLengthKey].(int), 60 | TotalSize: torrentMap.extractTotalSize(), 61 | TrackerInfo: torrentMap.extractTrackerInfo(), 62 | InfoHashURLEncoded: torrentMap.extractInfoHashURLEncoded(dat), 63 | }, err 64 | } 65 | 66 | func (t *torrentDict) extractInfoHashURLEncoded(rawData []byte) string { 67 | byteOffsets := t.resultMap["info"].(map[string]interface{})["byte_offsets"].([]int) 68 | h := sha1.New() 69 | h.Write([]byte(rawData[byteOffsets[0]:byteOffsets[1]])) 70 | ret := h.Sum(nil) 71 | var buf bytes.Buffer 72 | re := regexp.MustCompile(`[a-zA-Z0-9\.\-\_\~]`) 73 | for _, b := range ret { 74 | if re.Match([]byte{b}) { 75 | buf.WriteByte(b) 76 | } else { 77 | buf.WriteString(fmt.Sprintf("%%%02x", b)) 78 | } 79 | } 80 | return buf.String() 81 | } 82 | 83 | func (t *torrentDict) extractTotalSize() int { 84 | if value, ok := t.resultMap[torrentInfoKey].(map[string]interface{})[torrentLengthKey]; ok { 85 | return value.(int) 86 | } 87 | var total int 88 | 89 | for _, file := range t.resultMap[torrentInfoKey].(map[string]interface{})[torrentFilesKey].([]interface{}) { 90 | total += file.(map[string]interface{})[torrentLengthKey].(int) 91 | } 92 | return total 93 | } 94 | 95 | func (t *torrentDict) extractTrackerInfo() *TrackerInfo { 96 | uniqueUrls := make(map[string]int) 97 | currentCount := 0 98 | if main, ok := t.resultMap[mainAnnounceKey]; ok { 99 | if _, found := uniqueUrls[main.(string)]; !found { 100 | uniqueUrls[main.(string)] = currentCount 101 | currentCount++ 102 | } 103 | } 104 | if list, ok := t.resultMap[announceListKey]; ok { 105 | for _, innerList := range list.([]interface{}) { 106 | for _, item := range innerList.([]interface{}) { 107 | if _, found := uniqueUrls[item.(string)]; !found { 108 | uniqueUrls[item.(string)] = currentCount 109 | currentCount++ 110 | } 111 | } 112 | } 113 | 114 | } 115 | trackerInfo := TrackerInfo{Urls: make([]string, len(uniqueUrls))} 116 | for key, value := range uniqueUrls { 117 | trackerInfo.Urls[value] = key 118 | } 119 | 120 | trackerInfo.Main = trackerInfo.Urls[0] 121 | return &trackerInfo 122 | } 123 | 124 | //Decode accepts a byte slice and returns a map with information parsed. 125 | func Decode(data []byte) (dataMap map[string]interface{}, err error) { 126 | defer func() { 127 | if e := recover(); e != nil { 128 | err = e.(error) 129 | } 130 | }() 131 | 132 | result, _ := findParse(0, &data) 133 | return result.(map[string]interface{}), err 134 | } 135 | 136 | func findParse(currentIdx int, data *[]byte) (result interface{}, nextIdx int) { 137 | token := (*data)[currentIdx : currentIdx+1][0] 138 | switch { 139 | case token == dictToken: 140 | return mapParse(currentIdx, data) 141 | case token == numberToken: 142 | return numberParse(currentIdx, data) 143 | case token == listToken: 144 | return listParse(currentIdx, data) 145 | case token >= byte('0') || token <= byte('9'): 146 | return stringParse(currentIdx, data) 147 | default: 148 | panic("Error decoding bencode") 149 | } 150 | } 151 | 152 | func mapParse(startIdx int, data *[]byte) (result map[string]interface{}, nextIdx int) { 153 | result = make(map[string]interface{}) 154 | initialMapIndex := startIdx 155 | current := startIdx + 1 156 | for (*data)[current : current+1][0] != endOfCollectionToken { 157 | mapKey, next := findParse(current, data) 158 | current = next 159 | mapValue, next := findParse(current, data) 160 | current = next 161 | result[mapKey.(string)] = mapValue 162 | } 163 | current++ 164 | result["byte_offsets"] = []int{initialMapIndex, current} 165 | nextIdx = current 166 | return 167 | } 168 | 169 | func listParse(startIdx int, data *[]byte) (result []interface{}, nextIdx int) { 170 | current := startIdx + 1 171 | for (*data)[current : current+1][0] != endOfCollectionToken { 172 | value, next := findParse(current, data) 173 | result = append(result, value) 174 | current = next 175 | } 176 | current++ 177 | nextIdx = current 178 | return 179 | } 180 | 181 | func numberParse(startIdx int, data *[]byte) (result int, nextIdx int) { 182 | current := startIdx 183 | for (*data)[current : current+1][0] != endOfCollectionToken { 184 | current++ 185 | } 186 | value, _ := strconv.Atoi(string((*data)[startIdx+1 : current])) 187 | result = value 188 | nextIdx = current + 1 189 | return 190 | } 191 | 192 | func stringParse(startIdx int, data *[]byte) (result string, nextIdx int) { 193 | current := startIdx 194 | for (*data)[current : current+1][0] != lengthValueStringSeparatorToken { 195 | current++ 196 | } 197 | sizeStr, _ := strconv.Atoi(string(((*data)[startIdx:current]))) 198 | result = string((*data)[current+1 : current+1+int(sizeStr)]) 199 | nextIdx = current + 1 + int(sizeStr) 200 | return 201 | } 202 | -------------------------------------------------------------------------------- /bencode/bencode_test.go: -------------------------------------------------------------------------------- 1 | package bencode 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | func assertAreEqual(t *testing.T, got, want interface{}) { 11 | t.Helper() 12 | if got != want { 13 | t.Errorf("got: %v want: %v", got, want) 14 | } 15 | } 16 | func assertAreEqualDeep(t *testing.T, got, want interface{}) { 17 | t.Helper() 18 | if !reflect.DeepEqual(got, want) { 19 | t.Errorf("got: %v want: %v", got, want) 20 | } 21 | } 22 | 23 | func TestNumberParse(T *testing.T) { 24 | 25 | T.Run("Positive number", func(t *testing.T) { 26 | input := []byte("i322ed:5:") 27 | gotValue, gotNextIdx := numberParse(0, &input) 28 | wantValue, wantNextIdx := 322, 5 29 | 30 | assertAreEqual(t, gotValue, wantValue) 31 | assertAreEqual(t, gotNextIdx, wantNextIdx) 32 | 33 | }) 34 | T.Run("Negative number", func(t *testing.T) { 35 | input := []byte("i-322ed:5:") 36 | gotValue, gotNextIdx := numberParse(0, &input) 37 | wantValue, wantNextIdx := -322, 6 38 | 39 | assertAreEqual(t, gotValue, wantValue) 40 | assertAreEqual(t, gotNextIdx, wantNextIdx) 41 | }) 42 | } 43 | 44 | func TestStringParse(T *testing.T) { 45 | 46 | T.Run("String test 1", func(t *testing.T) { 47 | input := []byte("5:color4:blue") 48 | gotValue, gotNextIdx := stringParse(0, &input) 49 | wantValue, wantNextIdx := "color", 7 50 | 51 | assertAreEqual(t, gotValue, wantValue) 52 | assertAreEqual(t, gotNextIdx, wantNextIdx) 53 | 54 | }) 55 | T.Run("String test 2", func(t *testing.T) { 56 | input := []byte("15:metallica_rocksd:4:color") 57 | gotValue, gotNextIdx := stringParse(0, &input) 58 | wantValue, wantNextIdx := "metallica_rocks", 18 59 | 60 | assertAreEqual(t, gotValue, wantValue) 61 | assertAreEqual(t, gotNextIdx, wantNextIdx) 62 | }) 63 | } 64 | 65 | func TestListParse(T *testing.T) { 66 | T.Run("list of strings", func(t *testing.T) { 67 | input := []byte("l4:spam4:eggsed:5color") 68 | gotValue, gotNextIdx := listParse(0, &input) 69 | var wantValue []interface{} 70 | wantValue = append(wantValue, "spam", "eggs") 71 | wantNextIdx := 14 72 | assertAreEqualDeep(t, gotValue, wantValue) 73 | assertAreEqual(t, gotNextIdx, wantNextIdx) 74 | }) 75 | T.Run("list of numbers", func(t *testing.T) { 76 | input := []byte("li322ei400eed:5color") 77 | gotValue, gotNextIdx := listParse(0, &input) 78 | var wantValue []interface{} 79 | wantValue = append(wantValue, 322, 400) 80 | wantNextIdx := 12 81 | assertAreEqualDeep(t, gotValue, wantValue) 82 | assertAreEqual(t, gotNextIdx, wantNextIdx) 83 | }) 84 | } 85 | 86 | func TestMapParse(T *testing.T) { 87 | T.Run("map with string and list inside", func(t *testing.T) { 88 | input := []byte("d13:favorite_band4:tool6:othersl5:qotsaee5:color") 89 | gotValue, gotNextIdx := mapParse(0, &input) 90 | wantValue := make(map[string]interface{}) 91 | wantValue["favorite_band"] = "tool" 92 | wantValue["others"] = []interface{}{"qotsa"} 93 | wantValue["byte_offsets"] = []int{0, 41} 94 | wantNextIdx := 41 95 | assertAreEqualDeep(t, gotValue, wantValue) 96 | assertAreEqual(t, gotNextIdx, wantNextIdx) 97 | }) 98 | } 99 | 100 | func TestDecode(T *testing.T) { 101 | 102 | files, err := os.ReadDir("./torrent_files_test") 103 | if err != nil { 104 | log.Fatal(err) 105 | } 106 | for _, f := range files { 107 | T.Run(f.Name(), func(t *testing.T) { 108 | data, _ := os.ReadFile("./torrent_files_test/" + f.Name()) 109 | result, _ := Decode(data) 110 | t.Log(result["info"].(map[string]interface{})["name"]) 111 | }) 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /bencode/torrent_files_test/debian-12.0.0-amd64-DVD-1.iso.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ap-pauloafonso/ratio-spoof/032536eda4e66758f7a8a42cc965132bffa345be/bencode/torrent_files_test/debian-12.0.0-amd64-DVD-1.iso.torrent -------------------------------------------------------------------------------- /emulation/emulation.go: -------------------------------------------------------------------------------- 1 | package emulation 2 | 3 | import ( 4 | "embed" 5 | "encoding/json" 6 | generator2 "github.com/ap-pauloafonso/ratio-spoof/generator" 7 | "io" 8 | ) 9 | 10 | type ClientInfo struct { 11 | Name string `json:"name"` 12 | PeerID struct { 13 | Generator string `json:"generator"` 14 | Regex string `json:"regex"` 15 | } `json:"peerId"` 16 | Key struct { 17 | Generator string `json:"generator"` 18 | Regex string `json:"regex"` 19 | } `json:"key"` 20 | Rounding struct { 21 | Generator string `json:"generator"` 22 | Regex string `json:"regex"` 23 | } `json:"rounding"` 24 | Query string `json:"query"` 25 | Headers map[string]string `json:"headers"` 26 | } 27 | 28 | type KeyGenerator interface { 29 | Key() string 30 | } 31 | 32 | type PeerIdGenerator interface { 33 | PeerId() string 34 | } 35 | type RoundingGenerator interface { 36 | Round(downloadCandidateNextAmount, uploadCandidateNextAmount, leftCandidateNextAmount, pieceSize int) (downloaded, uploaded, left int) 37 | } 38 | 39 | type Emulation struct { 40 | PeerIdGenerator 41 | KeyGenerator 42 | Query string 43 | Name string 44 | Headers map[string]string 45 | RoundingGenerator 46 | } 47 | 48 | func NewEmulation(code string) (*Emulation, error) { 49 | c, err := extractClient(code) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | peerG, err := generator2.NewRegexPeerIdGenerator(c.PeerID.Regex) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | keyG, err := generator2.NewDefaultKeyGenerator() 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | roudingG, err := generator2.NewDefaultRoudingGenerator() 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | return &Emulation{PeerIdGenerator: peerG, KeyGenerator: keyG, RoundingGenerator: roudingG, 70 | Headers: c.Headers, Name: c.Name, Query: c.Query}, nil 71 | 72 | } 73 | 74 | //go:embed static 75 | var staticFiles embed.FS 76 | 77 | func extractClient(code string) (*ClientInfo, error) { 78 | 79 | f, err := staticFiles.Open("static/" + code + ".json") 80 | if err != nil { 81 | return nil, err 82 | } 83 | defer f.Close() 84 | 85 | bytes, err := io.ReadAll(f) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | var client ClientInfo 91 | 92 | json.Unmarshal(bytes, &client) 93 | 94 | return &client, nil 95 | } 96 | -------------------------------------------------------------------------------- /emulation/emulation_test.go: -------------------------------------------------------------------------------- 1 | package emulation 2 | 3 | import ( 4 | "io/fs" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestNewEmulation(t *testing.T) { 10 | var counter int 11 | fs.WalkDir(staticFiles, ".", func(path string, d fs.DirEntry, err error) error { 12 | if counter > 1 { 13 | code := strings.TrimRight(strings.TrimLeft(path, "static/"), ".json") 14 | e, err := NewEmulation(code) 15 | if err != nil { 16 | t.Error("should not return error ") 17 | } 18 | 19 | peerId := e.PeerId() 20 | key := e.Key() 21 | 22 | d, u, l := e.Round(2*1024*1024*1024, 1024*1024*1024, 3*1024*1024*1024, 1024) 23 | 24 | if peerId == "" { 25 | t.Errorf("%s.json should be able to generate PeerId", code) 26 | } 27 | if key == "" { 28 | t.Errorf("%s.json should be able to generate Key", code) 29 | } 30 | if d <= 0 || u <= 0 || l <= 0 { 31 | t.Errorf("%s.json should be able to round candidates", code) 32 | } 33 | } 34 | counter++ 35 | return nil 36 | }) 37 | 38 | } 39 | func TestExtractClient(t *testing.T) { 40 | var counter int 41 | fs.WalkDir(staticFiles, ".", func(path string, d fs.DirEntry, err error) error { 42 | if counter > 1 { 43 | code := strings.TrimRight(strings.TrimLeft(path, "static/"), ".json") 44 | c, e := extractClient(code) 45 | if e != nil || err != nil { 46 | t.Error("should not return error") 47 | } 48 | 49 | if c.Key.Generator == "" && c.Key.Regex == "" { 50 | t.Errorf("%s.json should have key generator properties", code) 51 | } 52 | if c.PeerID.Generator == "" && c.PeerID.Regex == "" { 53 | t.Errorf("%s.json should have PeerId generator properties", code) 54 | } 55 | 56 | if c.Rounding.Generator == "" && c.Rounding.Regex == "" { 57 | t.Errorf("%s.json should have rouding generator properties", code) 58 | } 59 | 60 | if c.Name == "" { 61 | t.Errorf("%s.json should have a name", code) 62 | } 63 | if c.Query == "" { 64 | t.Errorf("%s.json should have a query", code) 65 | } 66 | if len(c.Headers) == 0 { 67 | t.Errorf("%s.json should have headers", code) 68 | } 69 | } 70 | counter++ 71 | return nil 72 | }) 73 | 74 | } 75 | -------------------------------------------------------------------------------- /emulation/static/qbit-4.0.3.json: -------------------------------------------------------------------------------- 1 | { 2 | "name":"qBittorrent v4.0.3", 3 | "peerId":{ 4 | "regex":"-qB4030-[A-Za-z0-9_~\\(\\)\\!\\.\\*-]{12}" 5 | }, 6 | "key": { 7 | "generator":"defaultKeyGenerator" 8 | }, 9 | "rounding": { 10 | "generator":"defaultRoudingGenerator" 11 | }, 12 | "query":"info_hash={infohash}&peer_id={peerid}&port={port}&uploaded={uploaded}&downloaded={downloaded}&left={left}&corrupt=0&key={key}&event={event}&numwant={numwant}&compact=1&no_peer_id=1&supportcrypto=1&redundant=0", 13 | "headers":{ 14 | "User-Agent" :"qBittorrent/4.0.3", 15 | "Accept-Encoding": "gzip" 16 | } 17 | } -------------------------------------------------------------------------------- /emulation/static/qbit-4.3.3.json: -------------------------------------------------------------------------------- 1 | { 2 | "name":"qBittorrent v4.3.3", 3 | "peerId":{ 4 | "regex":"-qB4330-[A-Za-z0-9_~\\(\\)\\!\\.\\*-]{12}" 5 | }, 6 | "key": { 7 | "generator":"defaultKeyGenerator" 8 | }, 9 | "rounding": { 10 | "generator":"defaultRoudingGenerator" 11 | }, 12 | "query":"info_hash={infohash}&peer_id={peerid}&port={port}&uploaded={uploaded}&downloaded={downloaded}&left={left}&corrupt=0&key={key}&event={event}&numwant={numwant}&compact=1&no_peer_id=1&supportcrypto=1&redundant=0", 13 | "headers":{ 14 | "User-Agent" :"qBittorrent/4.3.3", 15 | "Accept-Encoding": "gzip" 16 | } 17 | } -------------------------------------------------------------------------------- /generator/key.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "strings" 7 | ) 8 | 9 | func NewDefaultKeyGenerator() (*DefaultKeyGenerator, error) { 10 | randomBytes := make([]byte, 4) 11 | rand.Read(randomBytes) 12 | str := hex.EncodeToString(randomBytes) 13 | result := strings.ToUpper(str) 14 | return &DefaultKeyGenerator{generated: result}, nil 15 | } 16 | 17 | type DefaultKeyGenerator struct { 18 | generated string 19 | } 20 | 21 | func (d *DefaultKeyGenerator) Key() string { 22 | return d.generated 23 | } 24 | -------------------------------------------------------------------------------- /generator/key_test.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import "testing" 4 | 5 | func TestDeaultKeyGenerator(t *testing.T) { 6 | t.Run("Key has 8 length", func(t *testing.T) { 7 | obj, _ := NewDefaultKeyGenerator() 8 | key := obj.Key() 9 | if len(key) != 8 { 10 | t.Error("Keys must have length of 8") 11 | } 12 | 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /generator/peerId.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import ( 4 | regen "github.com/zach-klippenstein/goregen" 5 | ) 6 | 7 | type RegexPeerIdGenerator struct { 8 | generated string 9 | } 10 | 11 | func NewRegexPeerIdGenerator(pattern string) (*RegexPeerIdGenerator, error) { 12 | result, err := regen.Generate(pattern) 13 | if err != nil { 14 | return nil, err 15 | } 16 | return &RegexPeerIdGenerator{generated: result}, nil 17 | } 18 | 19 | func (d *RegexPeerIdGenerator) PeerId() string { 20 | return d.generated 21 | } 22 | -------------------------------------------------------------------------------- /generator/rouding.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | type DefaultRoundingGenerator struct{} 4 | 5 | func NewDefaultRoudingGenerator() (*DefaultRoundingGenerator, error) { 6 | return &DefaultRoundingGenerator{}, nil 7 | 8 | } 9 | 10 | func (d *DefaultRoundingGenerator) Round(downloadCandidateNextAmount, uploadCandidateNextAmount, leftCandidateNextAmount, pieceSize int) (downloaded, uploaded, left int) { 11 | 12 | down := downloadCandidateNextAmount 13 | up := uploadCandidateNextAmount - (uploadCandidateNextAmount % (16 * 1024)) 14 | l := leftCandidateNextAmount - (leftCandidateNextAmount % pieceSize) 15 | return down, up, l 16 | } 17 | -------------------------------------------------------------------------------- /generator/rounding_test.go: -------------------------------------------------------------------------------- 1 | package generator 2 | 3 | import "testing" 4 | 5 | func TestDefaultRounding(t *testing.T) { 6 | r, _ := NewDefaultRoudingGenerator() 7 | 8 | d, u, l := r.Round(656497856, 46479878, 7879879, 1024) 9 | //same 10 | if d != 656497856 { 11 | t.Errorf("[download]got %v want %v", d, 656497856) 12 | } 13 | //16kb round 14 | if u != 46465024 { 15 | t.Errorf("[upload]got %v want %v", u, 46465024) 16 | } 17 | //piece size round 18 | if l != 7879680 { 19 | t.Errorf("[left]got %v want %v", l, 7879680) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ap-pauloafonso/ratio-spoof 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/gammazero/deque v0.0.0-20201010052221-3932da5530cc 7 | github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0 8 | github.com/zach-klippenstein/goregen v0.0.0-20160303162051-795b5e3961ea 9 | ) 10 | 11 | require ( 12 | github.com/google/gxui v0.0.0-20151028112939-f85e0a97b3a4 // indirect 13 | github.com/smartystreets/goconvey v1.6.4 // indirect 14 | github.com/stretchr/testify v1.7.0 // indirect 15 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/gammazero/deque v0.0.0-20201010052221-3932da5530cc h1:F7BbnLACph7UYiz9ZHi6npcROwKaZUyviDjsNERsoMM= 4 | github.com/gammazero/deque v0.0.0-20201010052221-3932da5530cc/go.mod h1:IlBLfYXnuw9sspy1XS6ctu5exGb6WHGKQsyo4s7bOEA= 5 | github.com/google/gxui v0.0.0-20151028112939-f85e0a97b3a4 h1:OL2d27ueTKnlQJoqLW2fc9pWYulFnJYLWzomGV7HqZo= 6 | github.com/google/gxui v0.0.0-20151028112939-f85e0a97b3a4/go.mod h1:Pw1H1OjSNHiqeuxAduB1BKYXIwFtsyrY47nEqSgEiCM= 7 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 8 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 9 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 10 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 11 | github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0 h1:LiZB1h0GIcudcDci2bxbqI6DXV8bF8POAnArqvRrIyw= 12 | github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0/go.mod h1:F/7q8/HZz+TXjlsoZQQKVYvXTZaFH4QRa3y+j1p7MS0= 13 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 15 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= 16 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 17 | github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= 18 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 19 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 20 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 21 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 22 | github.com/zach-klippenstein/goregen v0.0.0-20160303162051-795b5e3961ea h1:CyhwejzVGvZ3Q2PSbQ4NRRYn+ZWv5eS1vlaEusT+bAI= 23 | github.com/zach-klippenstein/goregen v0.0.0-20160303162051-795b5e3961ea/go.mod h1:eNr558nEUjP8acGw8FFjTeWvSgU1stO7FAO6eknhHe4= 24 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 25 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 26 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 27 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= 28 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 29 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 30 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 31 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 32 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 33 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 34 | -------------------------------------------------------------------------------- /input/input.go: -------------------------------------------------------------------------------- 1 | package input 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/ap-pauloafonso/ratio-spoof/bencode" 7 | "math" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | const ( 13 | minPortNumber = 1 14 | maxPortNumber = 65535 15 | speedSuffixLength = 4 16 | ) 17 | 18 | type InputArgs struct { 19 | TorrentPath string 20 | InitialDownloaded string 21 | DownloadSpeed string 22 | InitialUploaded string 23 | Client string 24 | UploadSpeed string 25 | Port int 26 | Debug bool 27 | } 28 | 29 | type InputParsed struct { 30 | TorrentPath string 31 | InitialDownloaded int 32 | DownloadSpeed int 33 | InitialUploaded int 34 | UploadSpeed int 35 | Port int 36 | Debug bool 37 | } 38 | 39 | var validInitialSufixes = [...]string{"%", "b", "kb", "mb", "gb", "tb"} 40 | var validSpeedSufixes = [...]string{"kbps", "mbps"} 41 | 42 | func (i *InputArgs) ParseInput(torrentInfo *bencode.TorrentInfo) (*InputParsed, error) { 43 | downloaded, err := extractInputInitialByteCount(i.InitialDownloaded, torrentInfo.TotalSize, true) 44 | if err != nil { 45 | return nil, err 46 | } 47 | uploaded, err := extractInputInitialByteCount(i.InitialUploaded, torrentInfo.TotalSize, false) 48 | if err != nil { 49 | return nil, err 50 | } 51 | downloadSpeed, err := extractInputByteSpeed(i.DownloadSpeed) 52 | if err != nil { 53 | return nil, err 54 | } 55 | uploadSpeed, err := extractInputByteSpeed(i.UploadSpeed) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | if i.Port < minPortNumber || i.Port > maxPortNumber { 61 | return nil, errors.New(fmt.Sprint("port number must be between %i and %i", minPortNumber, maxPortNumber)) 62 | } 63 | 64 | return &InputParsed{InitialDownloaded: downloaded, 65 | DownloadSpeed: downloadSpeed, 66 | InitialUploaded: uploaded, 67 | UploadSpeed: uploadSpeed, 68 | Debug: i.Debug, 69 | Port: i.Port, 70 | }, nil 71 | } 72 | 73 | func checkSpeedSufix(input string) (valid bool, suffix string) { 74 | for _, v := range validSpeedSufixes { 75 | 76 | if strings.HasSuffix(strings.ToLower(input), v) { 77 | return true, input[len(input)-4:] 78 | } 79 | } 80 | return false, "" 81 | } 82 | 83 | func extractInputInitialByteCount(initialSizeInput string, totalBytes int, errorIfHigher bool) (int, error) { 84 | byteCount, err := strSize2ByteSize(initialSizeInput, totalBytes) 85 | if err != nil { 86 | return 0, err 87 | } 88 | if errorIfHigher && byteCount > totalBytes { 89 | return 0, errors.New("initial downloaded can not be higher than the torrent size") 90 | } 91 | if byteCount < 0 { 92 | return 0, errors.New("initial value can not be negative") 93 | } 94 | return byteCount, nil 95 | } 96 | 97 | // Takes an dirty speed input and returns the bytes per second based on the suffixes 98 | // example 1kbps(string) > 1024 bytes per second (int) 99 | func extractInputByteSpeed(initialSpeedInput string) (int, error) { 100 | ok, suffix := checkSpeedSufix(initialSpeedInput) 101 | if !ok { 102 | return 0, fmt.Errorf("speed must be in %v", validSpeedSufixes) 103 | } 104 | speedVal, err := strconv.ParseFloat(initialSpeedInput[:len(initialSpeedInput)-speedSuffixLength], 64) 105 | if err != nil { 106 | return 0, errors.New("invalid speed number") 107 | } 108 | if speedVal < 0 { 109 | return 0, errors.New("speed can not be negative") 110 | } 111 | 112 | if suffix == "kbps" { 113 | speedVal *= 1024 114 | } else { 115 | speedVal = speedVal * 1024 * 1024 116 | } 117 | ret := int(speedVal) 118 | return ret, nil 119 | } 120 | 121 | func extractByteSizeNumber(strWithSufix string, sufixLength, power int) (int, error) { 122 | v, err := strconv.ParseFloat(strWithSufix[:len(strWithSufix)-sufixLength], 64) 123 | if err != nil { 124 | return 0, err 125 | } 126 | result := v * math.Pow(1024, float64(power)) 127 | return int(result), nil 128 | } 129 | 130 | func strSize2ByteSize(input string, totalSize int) (int, error) { 131 | lowerInput := strings.ToLower(input) 132 | invalidSizeError := errors.New("invalid input size") 133 | switch { 134 | case strings.HasSuffix(lowerInput, "kb"): 135 | { 136 | v, err := extractByteSizeNumber(lowerInput, 2, 1) 137 | if err != nil { 138 | return 0, invalidSizeError 139 | } 140 | return v, nil 141 | } 142 | case strings.HasSuffix(lowerInput, "mb"): 143 | { 144 | v, err := extractByteSizeNumber(lowerInput, 2, 2) 145 | if err != nil { 146 | return 0, invalidSizeError 147 | } 148 | return v, nil 149 | } 150 | case strings.HasSuffix(lowerInput, "gb"): 151 | { 152 | v, err := extractByteSizeNumber(lowerInput, 2, 3) 153 | if err != nil { 154 | return 0, invalidSizeError 155 | } 156 | return v, nil 157 | } 158 | case strings.HasSuffix(lowerInput, "tb"): 159 | { 160 | v, err := extractByteSizeNumber(lowerInput, 2, 4) 161 | if err != nil { 162 | return 0, invalidSizeError 163 | } 164 | return v, nil 165 | } 166 | case strings.HasSuffix(lowerInput, "b"): 167 | { 168 | v, err := extractByteSizeNumber(lowerInput, 1, 0) 169 | if err != nil { 170 | return 0, invalidSizeError 171 | } 172 | return v, nil 173 | } 174 | case strings.HasSuffix(lowerInput, "%"): 175 | { 176 | v, err := strconv.ParseFloat(lowerInput[:len(lowerInput)-1], 64) 177 | if v < 0 || v > 100 || err != nil { 178 | return 0, errors.New("percent value must be in (0-100)") 179 | } 180 | result := int(float64(v/100) * float64(totalSize)) 181 | 182 | return result, nil 183 | } 184 | 185 | default: 186 | return 0, errors.New("Size not found") 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /input/input_test.go: -------------------------------------------------------------------------------- 1 | package input 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func CheckError(out error, want error, t *testing.T) { 9 | t.Helper() 10 | if out == nil && want == nil { 11 | return 12 | } 13 | if out != nil && want == nil { 14 | t.Errorf("got %v, want %v", out.Error(), "") 15 | } 16 | if out == nil && want != nil { 17 | t.Errorf("got %v, want %v", "", want.Error()) 18 | } 19 | if out != nil && want != nil && out.Error() != want.Error() { 20 | t.Errorf("got %v, want %v", out.Error(), want.Error()) 21 | } 22 | 23 | } 24 | func TestExtractInputInitialByteCount(T *testing.T) { 25 | data := []struct { 26 | name string 27 | inSize string 28 | inTotal int 29 | inErrorIfHigher bool 30 | err error 31 | }{ 32 | { 33 | name: "[Donwloaded - error if higher]100kb input with 200kb limit shouldn't return error test", 34 | inSize: "100kb", 35 | inTotal: 204800, 36 | inErrorIfHigher: true, 37 | }, 38 | { 39 | name: "[Donwloaded - error if higher]300kb input with 200kb limit should return error test", 40 | inSize: "300kb", 41 | inTotal: 204800, 42 | inErrorIfHigher: true, 43 | err: errors.New("initial downloaded can not be higher than the torrent size"), 44 | }, 45 | { 46 | name: "[Uploaded]100kb input with 200kb limit shouldn't return error test", 47 | inSize: "100kb", 48 | inTotal: 204800, 49 | inErrorIfHigher: false, 50 | }, 51 | { 52 | name: "[Uploaded]300kb input with 200kb limit shouldn't return error test", 53 | inSize: "300kb", 54 | inTotal: 204800, 55 | inErrorIfHigher: false, 56 | }, 57 | { 58 | name: "[Donwloaded] -100kb should return negative number error test", 59 | inSize: "-100kb", 60 | inTotal: 204800, 61 | inErrorIfHigher: true, 62 | err: errors.New("initial value can not be negative"), 63 | }, 64 | { 65 | name: "[Uploaded] -100kb should return negative number error test", 66 | inSize: "-100kb", 67 | inTotal: 204800, 68 | inErrorIfHigher: false, 69 | err: errors.New("initial value can not be negative"), 70 | }, 71 | } 72 | 73 | for _, td := range data { 74 | T.Run(td.name, func(t *testing.T) { 75 | _, err := extractInputInitialByteCount(td.inSize, td.inTotal, td.inErrorIfHigher) 76 | CheckError(err, td.err, t) 77 | }) 78 | } 79 | } 80 | func TestStrSize2ByteSize(T *testing.T) { 81 | 82 | data := []struct { 83 | name string 84 | in string 85 | inTotalSize int 86 | out int 87 | err error 88 | }{ 89 | { 90 | name: "100kb test", 91 | in: "100kb", 92 | inTotalSize: 100, 93 | out: 102400, 94 | }, 95 | { 96 | name: "1kb test", 97 | in: "1kb", 98 | inTotalSize: 0, 99 | out: 1024, 100 | }, 101 | { 102 | name: "1mb test", 103 | in: "1mb", 104 | inTotalSize: 0, 105 | out: 1048576, 106 | }, 107 | { 108 | name: "1gb test", 109 | in: "1gb", 110 | inTotalSize: 0, 111 | out: 1073741824, 112 | }, 113 | { 114 | name: "1.5gb test", 115 | in: "1.5gb", 116 | inTotalSize: 0, 117 | out: 1610612736, 118 | }, 119 | { 120 | name: "1tb test", 121 | in: "1tb", 122 | inTotalSize: 0, 123 | out: 1099511627776, 124 | }, 125 | { 126 | name: "1b test", 127 | in: "1b", 128 | inTotalSize: 0, 129 | out: 1, 130 | }, 131 | { 132 | name: "10xb test", 133 | in: "10xb", 134 | inTotalSize: 0, 135 | err: errors.New("invalid input size"), 136 | }, 137 | { 138 | name: `100% test`, 139 | in: "100%", 140 | inTotalSize: 10737418240, 141 | out: 10737418240, 142 | }, 143 | { 144 | name: `55% test`, 145 | in: "55%", 146 | inTotalSize: 943718400, 147 | out: 519045120, 148 | }, 149 | { 150 | name: `5kg test`, 151 | in: "5kg", 152 | err: errors.New("Size not found"), 153 | }, 154 | { 155 | name: `-1% test`, 156 | in: "-1%", 157 | err: errors.New("percent value must be in (0-100)"), 158 | }, 159 | { 160 | name: `101% test`, 161 | in: "101%", 162 | err: errors.New("percent value must be in (0-100)"), 163 | }, 164 | { 165 | name: `a% test`, 166 | in: "a%", 167 | err: errors.New("percent value must be in (0-100)"), 168 | }, 169 | } 170 | 171 | for _, td := range data { 172 | T.Run(td.name, func(t *testing.T) { 173 | got, err := strSize2ByteSize(td.in, td.inTotalSize) 174 | if td.err != nil { 175 | if td.err.Error() != err.Error() { 176 | t.Errorf("got %v, want %v", err.Error(), td.err.Error()) 177 | } 178 | } 179 | if got != td.out { 180 | t.Errorf("got %v, want %v", got, td.out) 181 | } 182 | }) 183 | } 184 | } 185 | 186 | func TestExtractInputByteSpeed(T *testing.T) { 187 | 188 | data := []struct { 189 | name string 190 | speed string 191 | expected int 192 | err error 193 | }{ 194 | { 195 | name: "1kbps test", 196 | speed: "1kbps", 197 | expected: 1024, 198 | }, 199 | { 200 | name: "1024kbps test", 201 | speed: "1024kbps", 202 | expected: 1048576, 203 | }, 204 | { 205 | name: "1mbps test", 206 | speed: "1mbps", 207 | expected: 1048576, 208 | }, 209 | { 210 | name: "2.5mbps test", 211 | speed: "2.5mbps", 212 | expected: 2621440, 213 | }, 214 | { 215 | name: "2.5tbps test", 216 | speed: "2.5tbps", 217 | err: errors.New("speed must be in [kbps mbps]"), 218 | }, 219 | { 220 | name: "-akbps test", 221 | speed: "-akbps", 222 | err: errors.New("invalid speed number"), 223 | }, 224 | { 225 | name: "-10kbps test", 226 | speed: "-10kbps", 227 | err: errors.New("speed can not be negative"), 228 | }, 229 | } 230 | 231 | for _, td := range data { 232 | T.Run(td.name, func(t *testing.T) { 233 | got, err := extractInputByteSpeed(td.speed) 234 | if td.err != nil { 235 | if td.err.Error() != err.Error() { 236 | t.Errorf("got %v, want %v", err.Error(), td.err.Error()) 237 | } 238 | } 239 | 240 | if got != td.expected { 241 | t.Errorf("got %v, want %v", got, td.expected) 242 | } 243 | }) 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "github.com/ap-pauloafonso/ratio-spoof/input" 7 | "github.com/ap-pauloafonso/ratio-spoof/printer" 8 | "github.com/ap-pauloafonso/ratio-spoof/ratiospoof" 9 | "log" 10 | "os" 11 | ) 12 | 13 | func main() { 14 | 15 | //required 16 | torrentPath := flag.String("t", "", "torrent path") 17 | initialDownload := flag.String("d", "", "a INITIAL_DOWNLOADED") 18 | downloadSpeed := flag.String("ds", "", "a DOWNLOAD_SPEED") 19 | initialUpload := flag.String("u", "", "a INITIAL_UPLOADED") 20 | uploadSpeed := flag.String("us", "", "a UPLOAD_SPEED") 21 | 22 | //optional 23 | port := flag.Int("p", 8999, "a PORT") 24 | debug := flag.Bool("debug", false, "") 25 | client := flag.String("c", "qbit-4.0.3", "emulated client") 26 | 27 | flag.Usage = func() { 28 | fmt.Printf("usage: %s -t -d -ds -u -us \n", os.Args[0]) 29 | fmt.Print(` 30 | optional arguments: 31 | -h show this help message and exit 32 | -p [PORT] change the port number, default: 8999 33 | -c [CLIENT_CODE] change the client emulation, default: qbit-4.0.3 34 | 35 | required arguments: 36 | -t 37 | -d 38 | -ds 39 | -u 40 | -us 41 | 42 | and must be in %, b, kb, mb, gb, tb 43 | and must be in kbps, mbps 44 | [CLIENT_CODE] options: qbit-4.0.3, qbit-4.3.3 45 | `) 46 | } 47 | 48 | flag.Parse() 49 | 50 | if *torrentPath == "" || *initialDownload == "" || *downloadSpeed == "" || *initialUpload == "" || *uploadSpeed == "" { 51 | flag.Usage() 52 | return 53 | } 54 | 55 | r, err := ratiospoof.NewRatioSpoofState( 56 | input.InputArgs{ 57 | TorrentPath: *torrentPath, 58 | InitialDownloaded: *initialDownload, 59 | DownloadSpeed: *downloadSpeed, 60 | InitialUploaded: *initialUpload, 61 | UploadSpeed: *uploadSpeed, 62 | Port: *port, 63 | Debug: *debug, 64 | Client: *client, 65 | }) 66 | 67 | if err != nil { 68 | log.Fatalln(err) 69 | } 70 | 71 | go printer.PrintState(r) 72 | r.Run() 73 | 74 | } 75 | -------------------------------------------------------------------------------- /printer/printer.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ap-pauloafonso/ratio-spoof/ratiospoof" 6 | "os" 7 | "os/exec" 8 | "runtime" 9 | "strings" 10 | "time" 11 | 12 | "github.com/olekukonko/ts" 13 | ) 14 | 15 | func PrintState(state *ratiospoof.RatioSpoof) { 16 | for { 17 | if !state.Print { 18 | break 19 | } 20 | width := terminalSize() 21 | clear() 22 | 23 | if state.AnnounceCount == 1 { 24 | println("Trying to connect to the tracker...") 25 | time.Sleep(1 * time.Second) 26 | continue 27 | } 28 | if state.AnnounceHistory.Len() > 0 { 29 | seedersStr := fmt.Sprint(state.Seeders) 30 | leechersStr := fmt.Sprint(state.Leechers) 31 | if state.Seeders == 0 { 32 | seedersStr = "not informed" 33 | } 34 | 35 | if state.Leechers == 0 { 36 | leechersStr = "not informed" 37 | } 38 | var retryStr string 39 | if state.Tracker.RetryAttempt > 0 { 40 | retryStr = fmt.Sprintf("(*Retry %v - check your connection)", state.Tracker.RetryAttempt) 41 | } 42 | fmt.Printf("%s\n", center(" RATIO-SPOOF ", width-len(" RATIO-SPOOF "), "#")) 43 | fmt.Printf(` 44 | Torrent: %v 45 | Tracker: %v 46 | Seeders: %v 47 | Leechers:%v 48 | Download Speed: %v/s 49 | Upload Speed: %v/s 50 | Size: %v 51 | Emulation: %v | Port: %v`, state.TorrentInfo.Name, state.TorrentInfo.TrackerInfo.Main, seedersStr, leechersStr, humanReadableSize(float64(state.Input.DownloadSpeed)), 52 | humanReadableSize(float64(state.Input.UploadSpeed)), humanReadableSize(float64(state.TorrentInfo.TotalSize)), state.BitTorrentClient.Name, state.Input.Port) 53 | fmt.Printf("\n\n%s\n\n", center(" GITHUB.COM/AP-PAULOAFONSO/RATIO-SPOOF ", width-len(" GITHUB.COM/AP-PAULOAFONSO/RATIO-SPOOF "), "#")) 54 | for i := 0; i <= state.AnnounceHistory.Len()-2; i++ { 55 | dequeItem := state.AnnounceHistory.At(i).(ratiospoof.AnnounceEntry) 56 | fmt.Printf("#%v downloaded: %v(%.2f%%) | left: %v | uploaded: %v | announced\n", dequeItem.Count, humanReadableSize(float64(dequeItem.Downloaded)), dequeItem.PercentDownloaded, humanReadableSize(float64(dequeItem.Left)), humanReadableSize(float64(dequeItem.Uploaded))) 57 | } 58 | lastDequeItem := state.AnnounceHistory.At(state.AnnounceHistory.Len() - 1).(ratiospoof.AnnounceEntry) 59 | 60 | remaining := time.Until(state.Tracker.EstimatedTimeToAnnounce) 61 | fmt.Printf("#%v downloaded: %v(%.2f%%) | left: %v | uploaded: %v | next announce in: %v %v\n", lastDequeItem.Count, 62 | humanReadableSize(float64(lastDequeItem.Downloaded)), 63 | lastDequeItem.PercentDownloaded, 64 | humanReadableSize(float64(lastDequeItem.Left)), 65 | humanReadableSize(float64(lastDequeItem.Uploaded)), 66 | fmtDuration(remaining), 67 | retryStr) 68 | 69 | if state.Input.Debug { 70 | fmt.Printf("\n%s\n", center(" DEBUG ", width-len(" DEBUG "), "#")) 71 | fmt.Printf("\n%s\n\n%s", state.Tracker.LastAnounceRequest, state.Tracker.LastTackerResponse) 72 | } 73 | time.Sleep(1 * time.Second) 74 | } 75 | } 76 | } 77 | 78 | func terminalSize() int { 79 | size, _ := ts.GetSize() 80 | width := size.Col() 81 | if width < 40 { 82 | width = 40 83 | } 84 | return width 85 | } 86 | func clear() { 87 | if runtime.GOOS == "windows" { 88 | cmd := exec.Command("cmd", "/c", "cls") 89 | cmd.Stdout = os.Stdout 90 | cmd.Run() 91 | } else { 92 | fmt.Print("\033c") 93 | } 94 | } 95 | 96 | func center(s string, n int, fill string) string { 97 | div := n / 2 98 | return strings.Repeat(fill, div) + s + strings.Repeat(fill, div) 99 | } 100 | 101 | func humanReadableSize(byteSize float64) string { 102 | var unitFound string 103 | for _, unit := range []string{"B", "KiB", "MiB", "GiB", "TiB"} { 104 | if byteSize < 1024.0 { 105 | unitFound = unit 106 | break 107 | } 108 | byteSize /= 1024.0 109 | } 110 | return fmt.Sprintf("%.2f%v", byteSize, unitFound) 111 | } 112 | 113 | func fmtDuration(d time.Duration) string { 114 | if d.Seconds() < 0 { 115 | return fmt.Sprintf("%s", 0*time.Second) 116 | } 117 | return fmt.Sprintf("%s", time.Duration(int(d.Seconds()))*time.Second) 118 | } 119 | -------------------------------------------------------------------------------- /printer/printer_test.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestHumanReadableSize(T *testing.T) { 9 | data := []struct { 10 | in float64 11 | out string 12 | }{ 13 | {1536, "1.50KiB"}, 14 | {379040563, "361.48MiB"}, 15 | {6291456, "6.00MiB"}, 16 | {372749107, "355.48MiB"}, 17 | {10485760, "10.00MiB"}, 18 | {15728640, "15.00MiB"}, 19 | {363311923, "346.48MiB"}, 20 | {16777216, "16.00MiB"}, 21 | {379040563, "361.48MiB"}, 22 | } 23 | for idx, td := range data { 24 | T.Run(fmt.Sprint(idx), func(t *testing.T) { 25 | got := humanReadableSize(td.in) 26 | if got != td.out { 27 | t.Errorf("got %q, want %q", got, td.out) 28 | } 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ratiospoof/ratiospoof.go: -------------------------------------------------------------------------------- 1 | package ratiospoof 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/ap-pauloafonso/ratio-spoof/bencode" 7 | "github.com/ap-pauloafonso/ratio-spoof/emulation" 8 | "github.com/ap-pauloafonso/ratio-spoof/input" 9 | "github.com/ap-pauloafonso/ratio-spoof/tracker" 10 | "log" 11 | "math/rand" 12 | "os" 13 | "os/signal" 14 | "strings" 15 | "syscall" 16 | "time" 17 | 18 | "github.com/gammazero/deque" 19 | ) 20 | 21 | const ( 22 | maxAnnounceHistory = 10 23 | ) 24 | 25 | type RatioSpoof struct { 26 | TorrentInfo *bencode.TorrentInfo 27 | Input *input.InputParsed 28 | Tracker *tracker.HttpTracker 29 | BitTorrentClient *emulation.Emulation 30 | AnnounceInterval int 31 | NumWant int 32 | Seeders int 33 | Leechers int 34 | AnnounceCount int 35 | Status string 36 | AnnounceHistory announceHistory 37 | Print bool 38 | } 39 | 40 | type AnnounceEntry struct { 41 | Count int 42 | Downloaded int 43 | PercentDownloaded float32 44 | Uploaded int 45 | Left int 46 | } 47 | 48 | type announceHistory struct { 49 | deque.Deque 50 | } 51 | 52 | func NewRatioSpoofState(input input.InputArgs) (*RatioSpoof, error) { 53 | dat, err := os.ReadFile(input.TorrentPath) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | client, err := emulation.NewEmulation(input.Client) 59 | if err != nil { 60 | return nil, errors.New("Error building the emulated client with the code") 61 | } 62 | 63 | torrentInfo, err := bencode.TorrentDictParse(dat) 64 | if err != nil { 65 | return nil, errors.New("failed to parse the torrent file") 66 | } 67 | 68 | httpTracker, err := tracker.NewHttpTracker(torrentInfo) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | inputParsed, err := input.ParseInput(torrentInfo) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | return &RatioSpoof{ 79 | BitTorrentClient: client, 80 | TorrentInfo: torrentInfo, 81 | Tracker: httpTracker, 82 | Input: inputParsed, 83 | NumWant: 200, 84 | Status: "started", 85 | Print: true, 86 | }, nil 87 | } 88 | 89 | func (a *announceHistory) pushValueHistory(value AnnounceEntry) { 90 | if a.Len() >= maxAnnounceHistory { 91 | a.PopFront() 92 | } 93 | a.PushBack(value) 94 | } 95 | 96 | func (r *RatioSpoof) gracefullyExit() { 97 | fmt.Printf("\nGracefully exiting...\n") 98 | r.Status = "stopped" 99 | r.NumWant = 0 100 | r.fireAnnounce(false) 101 | fmt.Printf("Gracefully exited successfully.\n") 102 | 103 | } 104 | 105 | func (r *RatioSpoof) Run() { 106 | sigCh := make(chan os.Signal) 107 | 108 | signal.Notify(sigCh, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) 109 | r.firstAnnounce() 110 | go func() { 111 | for { 112 | r.generateNextAnnounce() 113 | time.Sleep(time.Duration(r.AnnounceInterval) * time.Second) 114 | r.fireAnnounce(true) 115 | } 116 | }() 117 | <-sigCh 118 | r.Print = false 119 | r.gracefullyExit() 120 | } 121 | func (r *RatioSpoof) firstAnnounce() { 122 | r.addAnnounce(r.Input.InitialDownloaded, r.Input.InitialUploaded, calculateBytesLeft(r.Input.InitialDownloaded, r.TorrentInfo.TotalSize), (float32(r.Input.InitialDownloaded)/float32(r.TorrentInfo.TotalSize))*100) 123 | r.fireAnnounce(false) 124 | } 125 | 126 | func (r *RatioSpoof) updateSeedersAndLeechers(resp tracker.TrackerResponse) { 127 | r.Seeders = resp.Seeders 128 | r.Leechers = resp.Leechers 129 | } 130 | func (r *RatioSpoof) addAnnounce(currentDownloaded, currentUploaded, currentLeft int, percentDownloaded float32) { 131 | r.AnnounceCount++ 132 | r.AnnounceHistory.pushValueHistory(AnnounceEntry{Count: r.AnnounceCount, Downloaded: currentDownloaded, Uploaded: currentUploaded, Left: currentLeft, PercentDownloaded: percentDownloaded}) 133 | } 134 | func (r *RatioSpoof) fireAnnounce(retry bool) error { 135 | lastAnnounce := r.AnnounceHistory.Back().(AnnounceEntry) 136 | replacer := strings.NewReplacer("{infohash}", r.TorrentInfo.InfoHashURLEncoded, 137 | "{port}", fmt.Sprint(r.Input.Port), 138 | "{peerid}", r.BitTorrentClient.PeerId(), 139 | "{uploaded}", fmt.Sprint(lastAnnounce.Uploaded), 140 | "{downloaded}", fmt.Sprint(lastAnnounce.Downloaded), 141 | "{left}", fmt.Sprint(lastAnnounce.Left), 142 | "{key}", r.BitTorrentClient.Key(), 143 | "{event}", r.Status, 144 | "{numwant}", fmt.Sprint(r.NumWant)) 145 | query := replacer.Replace(r.BitTorrentClient.Query) 146 | trackerResp, err := r.Tracker.Announce(query, r.BitTorrentClient.Headers, retry) 147 | if err != nil { 148 | log.Fatalf("failed to reach the tracker:\n%s ", err.Error()) 149 | } 150 | 151 | if trackerResp != nil { 152 | r.updateSeedersAndLeechers(*trackerResp) 153 | r.AnnounceInterval = trackerResp.Interval 154 | } 155 | return nil 156 | } 157 | func (r *RatioSpoof) generateNextAnnounce() { 158 | lastAnnounce := r.AnnounceHistory.Back().(AnnounceEntry) 159 | currentDownloaded := lastAnnounce.Downloaded 160 | var downloadCandidate int 161 | 162 | if currentDownloaded < r.TorrentInfo.TotalSize { 163 | randomPiecesDownload := rand.Intn(10-1) + 1 164 | downloadCandidate = calculateNextTotalSizeByte(r.Input.DownloadSpeed, currentDownloaded, r.TorrentInfo.PieceSize, r.AnnounceInterval, r.TorrentInfo.TotalSize, randomPiecesDownload) 165 | } else { 166 | downloadCandidate = r.TorrentInfo.TotalSize 167 | } 168 | 169 | currentUploaded := lastAnnounce.Uploaded 170 | randomPiecesUpload := rand.Intn(10-1) + 1 171 | uploadCandidate := calculateNextTotalSizeByte(r.Input.UploadSpeed, currentUploaded, r.TorrentInfo.PieceSize, r.AnnounceInterval, 0, randomPiecesUpload) 172 | 173 | leftCandidate := calculateBytesLeft(downloadCandidate, r.TorrentInfo.TotalSize) 174 | 175 | d, u, l := r.BitTorrentClient.Round(downloadCandidate, uploadCandidate, leftCandidate, r.TorrentInfo.PieceSize) 176 | 177 | r.addAnnounce(d, u, l, (float32(d)/float32(r.TorrentInfo.TotalSize))*100) 178 | } 179 | 180 | func calculateNextTotalSizeByte(speedBytePerSecond, currentByte, pieceSizeByte, seconds, limitTotalBytes, randomPieces int) int { 181 | if speedBytePerSecond == 0 { 182 | return currentByte 183 | } 184 | totalCandidate := currentByte + (speedBytePerSecond * seconds) 185 | totalCandidate = totalCandidate + (pieceSizeByte * randomPieces) 186 | 187 | if limitTotalBytes != 0 && totalCandidate > limitTotalBytes { 188 | return limitTotalBytes 189 | } 190 | return totalCandidate 191 | } 192 | 193 | func calculateBytesLeft(currentBytes, totalBytes int) int { 194 | return totalBytes - currentBytes 195 | } 196 | -------------------------------------------------------------------------------- /ratiospoof/ratiospoof_test.go: -------------------------------------------------------------------------------- 1 | package ratiospoof 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCalculateNextTotalSizeByte(t *testing.T) { 8 | randomPieces := 8 9 | got := calculateNextTotalSizeByte(100*1024, 0, 512, 30, 87979879, randomPieces) 10 | want := 3076096 11 | 12 | if got != want { 13 | t.Errorf("\ngot : %v\nwant: %v", got, want) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tracker/tracker.go: -------------------------------------------------------------------------------- 1 | package tracker 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "errors" 7 | "github.com/ap-pauloafonso/ratio-spoof/bencode" 8 | "io" 9 | "net/http" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | type HttpTracker struct { 15 | Urls []string 16 | RetryAttempt int 17 | LastAnounceRequest string 18 | LastTackerResponse string 19 | EstimatedTimeToAnnounce time.Time 20 | } 21 | 22 | type TrackerResponse struct { 23 | MinInterval int 24 | Interval int 25 | Seeders int 26 | Leechers int 27 | } 28 | 29 | func NewHttpTracker(torrentInfo *bencode.TorrentInfo) (*HttpTracker, error) { 30 | 31 | var result []string 32 | for _, url := range torrentInfo.TrackerInfo.Urls { 33 | if strings.HasPrefix(url, "http") { 34 | result = append(result, url) 35 | } 36 | } 37 | if len(result) == 0 { 38 | return nil, errors.New("No tcp/http tracker url announce found") 39 | } 40 | return &HttpTracker{Urls: torrentInfo.TrackerInfo.Urls}, nil 41 | } 42 | 43 | func (t *HttpTracker) swapFirst(currentIdx int) { 44 | aux := t.Urls[0] 45 | t.Urls[0] = t.Urls[currentIdx] 46 | t.Urls[currentIdx] = aux 47 | } 48 | 49 | func (t *HttpTracker) updateEstimatedTimeToAnnounce(interval int) { 50 | t.EstimatedTimeToAnnounce = time.Now().Add(time.Duration(interval) * time.Second) 51 | } 52 | func (t *HttpTracker) handleSuccessfulResponse(resp *TrackerResponse) { 53 | if resp.Interval <= 0 { 54 | resp.Interval = 1800 55 | } 56 | 57 | t.updateEstimatedTimeToAnnounce(resp.Interval) 58 | } 59 | 60 | func (t *HttpTracker) Announce(query string, headers map[string]string, retry bool) (*TrackerResponse, error) { 61 | defer func() { 62 | t.RetryAttempt = 0 63 | }() 64 | if retry { 65 | retryDelay := 30 66 | for { 67 | trackerResp, err := t.tryMakeRequest(query, headers) 68 | if err != nil { 69 | t.updateEstimatedTimeToAnnounce(retryDelay) 70 | t.RetryAttempt++ 71 | time.Sleep(time.Duration(retryDelay) * time.Second) 72 | retryDelay *= 2 73 | if retryDelay > 900 { 74 | retryDelay = 900 75 | } 76 | continue 77 | } 78 | t.handleSuccessfulResponse(trackerResp) 79 | return trackerResp, nil 80 | } 81 | 82 | } else { 83 | resp, err := t.tryMakeRequest(query, headers) 84 | if err != nil { 85 | return nil, err 86 | } 87 | t.handleSuccessfulResponse(resp) 88 | return resp, nil 89 | } 90 | } 91 | 92 | func (t *HttpTracker) tryMakeRequest(query string, headers map[string]string) (*TrackerResponse, error) { 93 | for idx, baseUrl := range t.Urls { 94 | completeURL := buildFullUrl(baseUrl, query) 95 | t.LastAnounceRequest = completeURL 96 | req, _ := http.NewRequest("GET", completeURL, nil) 97 | for header, value := range headers { 98 | req.Header.Add(header, value) 99 | } 100 | resp, err := http.DefaultClient.Do(req) 101 | if err == nil { 102 | if resp.StatusCode == http.StatusOK { 103 | bytesR, _ := io.ReadAll(resp.Body) 104 | if len(bytesR) == 0 { 105 | continue 106 | } 107 | mimeType := http.DetectContentType(bytesR) 108 | if mimeType == "application/x-gzip" { 109 | gzipReader, _ := gzip.NewReader(bytes.NewReader(bytesR)) 110 | bytesR, _ = io.ReadAll(gzipReader) 111 | gzipReader.Close() 112 | } 113 | t.LastTackerResponse = string(bytesR) 114 | decodedResp, err := bencode.Decode(bytesR) 115 | if err != nil { 116 | continue 117 | } 118 | ret, err := extractTrackerResponse(decodedResp) 119 | if err != nil { 120 | continue 121 | } 122 | if idx != 0 { 123 | t.swapFirst(idx) 124 | } 125 | 126 | return &ret, nil 127 | } 128 | resp.Body.Close() 129 | } 130 | } 131 | return nil, errors.New("Connection error with the tracker") 132 | 133 | } 134 | 135 | func buildFullUrl(baseurl, query string) string { 136 | if len(strings.Split(baseurl, "?")) > 1 { 137 | return baseurl + "&" + strings.TrimLeft(query, "&") 138 | } 139 | return baseurl + "?" + strings.TrimLeft(query, "?") 140 | } 141 | 142 | func extractTrackerResponse(datatrackerResponse map[string]interface{}) (TrackerResponse, error) { 143 | var result TrackerResponse 144 | if v, ok := datatrackerResponse["failure reason"].(string); ok && len(v) > 0 { 145 | return result, errors.New(v) 146 | } 147 | result.MinInterval, _ = datatrackerResponse["min interval"].(int) 148 | result.Interval, _ = datatrackerResponse["interval"].(int) 149 | result.Seeders, _ = datatrackerResponse["complete"].(int) 150 | result.Leechers, _ = datatrackerResponse["incomplete"].(int) 151 | return result, nil 152 | 153 | } 154 | -------------------------------------------------------------------------------- /tracker/tracker_test.go: -------------------------------------------------------------------------------- 1 | package tracker 2 | 3 | import ( 4 | "github.com/ap-pauloafonso/ratio-spoof/bencode" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestNewHttpTracker(t *testing.T) { 10 | _, err := NewHttpTracker(&bencode.TorrentInfo{TrackerInfo: &bencode.TrackerInfo{Urls: []string{"udp://url1", "udp://url2"}}}) 11 | got := err.Error() 12 | want := "No tcp/http tracker url announce found" 13 | 14 | if got != want { 15 | t.Errorf("got: %v want %v", got, want) 16 | } 17 | } 18 | 19 | func TestSwapFirst(t *testing.T) { 20 | tracker, _ := NewHttpTracker(&bencode.TorrentInfo{TrackerInfo: &bencode.TrackerInfo{Urls: []string{"http://url1", "http://url2", "http://url3", "http://url4"}}}) 21 | tracker.swapFirst(3) 22 | 23 | got := tracker.Urls 24 | want := []string{"http://url4", "http://url2", "http://url3", "http://url1"} 25 | 26 | if !reflect.DeepEqual(got, want) { 27 | t.Errorf("got: %v want %v", got, want) 28 | } 29 | } 30 | 31 | func TestHandleSuccessfulResponse(t *testing.T) { 32 | 33 | t.Run("Empty interval should be overided with 1800 ", func(t *testing.T) { 34 | tracker, _ := NewHttpTracker(&bencode.TorrentInfo{TrackerInfo: &bencode.TrackerInfo{Urls: []string{"http://url1", "http://url2", "http://url3", "http://url4"}}}) 35 | r := TrackerResponse{} 36 | tracker.handleSuccessfulResponse(&r) 37 | got := r.Interval 38 | want := 1800 39 | if !reflect.DeepEqual(got, want) { 40 | t.Errorf("got: %v want %v", got, want) 41 | } 42 | 43 | }) 44 | 45 | t.Run("Valid interval shouldn't be overwritten", func(t *testing.T) { 46 | tracker, _ := NewHttpTracker(&bencode.TorrentInfo{TrackerInfo: &bencode.TrackerInfo{Urls: []string{"http://url1", "http://url2", "http://url3", "http://url4"}}}) 47 | r := TrackerResponse{Interval: 900} 48 | tracker.handleSuccessfulResponse(&r) 49 | got := r.Interval 50 | want := 900 51 | if !reflect.DeepEqual(got, want) { 52 | t.Errorf("got: %v want %v", got, want) 53 | } 54 | 55 | }) 56 | 57 | } 58 | --------------------------------------------------------------------------------