This commit is contained in:
ap-pauloafonso 2021-02-09 20:32:32 -03:00
parent 41b4ac9757
commit fa01ae1241
16 changed files with 591 additions and 547 deletions

View file

@ -3,11 +3,12 @@ package main
import (
"flag"
"fmt"
"net/http"
"os"
"github.com/ap-pauloafonso/ratio-spoof/emulation"
"github.com/ap-pauloafonso/ratio-spoof/ratiospoof"
"github.com/ap-pauloafonso/ratio-spoof/internal/emulation"
"github.com/ap-pauloafonso/ratio-spoof/internal/input"
"github.com/ap-pauloafonso/ratio-spoof/internal/printer"
"github.com/ap-pauloafonso/ratio-spoof/internal/ratiospoof"
)
func main() {
@ -50,8 +51,8 @@ required arguments:
}
qbit := emulation.NewQbitTorrent()
r, err := ratiospoof.NewRatioSPoofState(
ratiospoof.InputArgs{
r, err := ratiospoof.NewRatioSpoofState(
input.InputArgs{
TorrentPath: *torrentPath,
InitialDownloaded: *initialDownload,
DownloadSpeed: *downloadSpeed,
@ -60,12 +61,13 @@ required arguments:
Port: *port,
Debug: *debug,
},
qbit,
http.DefaultClient)
qbit)
if err != nil {
panic(err)
}
go printer.PrintState(r)
r.Run()
}

149
internal/input/input.go Normal file
View file

@ -0,0 +1,149 @@
package input
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
"github.com/ap-pauloafonso/ratio-spoof/internal/bencode"
)
type InputArgs struct {
TorrentPath string
InitialDownloaded string
DownloadSpeed string
InitialUploaded string
UploadSpeed string
Port int
Debug bool
}
type InputParsed struct {
TorrentPath string
InitialDownloaded int
DownloadSpeed int
InitialUploaded int
UploadSpeed int
Port int
Debug bool
}
var validInitialSufixes = [...]string{"%", "b", "kb", "mb", "gb", "tb"}
var validSpeedSufixes = [...]string{"kbps", "mbps"}
func (I *InputArgs) ParseInput(torrentInfo *bencode.TorrentInfo) (*InputParsed, error) {
downloaded, err := extractInputInitialByteCount(I.InitialDownloaded, torrentInfo.TotalSize, true)
if err != nil {
return nil, err
}
uploaded, err := extractInputInitialByteCount(I.InitialUploaded, torrentInfo.TotalSize, false)
if err != nil {
return nil, err
}
downloadSpeed, err := extractInputByteSpeed(I.DownloadSpeed)
if err != nil {
return nil, err
}
uploadSpeed, err := extractInputByteSpeed(I.UploadSpeed)
if err != nil {
return nil, err
}
if I.Port < 1 || I.Port > 65535 {
return nil, errors.New("port number must be between 1 and 65535")
}
return &InputParsed{InitialDownloaded: downloaded,
DownloadSpeed: downloadSpeed,
InitialUploaded: uploaded,
UploadSpeed: uploadSpeed,
Debug: I.Debug,
Port: I.Port,
}, nil
}
func checkSpeedSufix(input string) (valid bool, suffix string) {
for _, v := range validSpeedSufixes {
if strings.HasSuffix(strings.ToLower(input), v) {
return true, input[len(input)-4:]
}
}
return false, ""
}
func extractInputInitialByteCount(initialSizeInput string, totalBytes int, errorIfHigher bool) (int, error) {
byteCount := strSize2ByteSize(initialSizeInput, totalBytes)
if errorIfHigher && byteCount > totalBytes {
return 0, errors.New("initial downloaded can not be higher than the torrent size")
}
if byteCount < 0 {
return 0, errors.New("initial value can not be negative")
}
return byteCount, nil
}
func extractInputByteSpeed(initialSpeedInput string) (int, error) {
ok, suffix := checkSpeedSufix(initialSpeedInput)
if !ok {
return 0, fmt.Errorf("speed must be in %v", validSpeedSufixes)
}
number, _ := strconv.ParseFloat(initialSpeedInput[:len(initialSpeedInput)-4], 64)
if number < 0 {
return 0, errors.New("speed can not be negative")
}
if suffix == "kbps" {
number *= 1024
} else {
number = number * 1024 * 1024
}
ret := int(number)
return ret, nil
}
func strSize2ByteSize(input string, totalSize int) int {
lowerInput := strings.ToLower(input)
parseStrNumberFn := func(strWithSufix string, sufixLength, n int) int {
v, _ := strconv.ParseFloat(strWithSufix[:len(lowerInput)-sufixLength], 64)
result := v * math.Pow(1024, float64(n))
return int(result)
}
switch {
case strings.HasSuffix(lowerInput, "kb"):
{
return parseStrNumberFn(lowerInput, 2, 1)
}
case strings.HasSuffix(lowerInput, "mb"):
{
return parseStrNumberFn(lowerInput, 2, 2)
}
case strings.HasSuffix(lowerInput, "gb"):
{
return parseStrNumberFn(lowerInput, 2, 3)
}
case strings.HasSuffix(lowerInput, "tb"):
{
return parseStrNumberFn(lowerInput, 2, 4)
}
case strings.HasSuffix(lowerInput, "b"):
{
return parseStrNumberFn(lowerInput, 1, 0)
}
case strings.HasSuffix(lowerInput, "%"):
{
v, _ := strconv.ParseFloat(lowerInput[:len(lowerInput)-1], 64)
if v < 0 || v > 100 {
panic("percent value must be in (0-100)")
}
result := int(float64(v/100) * float64(totalSize))
return result
}
default:
panic("Size not found")
}
}

View file

@ -0,0 +1,34 @@
package input
import (
"fmt"
"testing"
)
func TestStrSize2ByteSize(T *testing.T) {
data := []struct {
in string
inTotalSize int
out int
}{
{"100kb", 100, 102400},
{"1kb", 0, 1024},
{"1mb", 0, 1048576},
{"1gb", 0, 1073741824},
{"1.5gb", 0, 1610612736},
{"1tb", 0, 1099511627776},
{"1b", 0, 1},
{"100%", 10737418240, 10737418240},
{"55%", 943718400, 519045120},
}
for idx, td := range data {
T.Run(fmt.Sprint(idx), func(t *testing.T) {
got := strSize2ByteSize(td.in, td.inTotalSize)
if got != td.out {
t.Errorf("got %v, want %v", got, td.out)
}
})
}
}

View file

@ -1,4 +1,4 @@
package ratiospoof
package printer
import (
"fmt"
@ -8,13 +8,14 @@ import (
"strings"
"time"
"github.com/ap-pauloafonso/ratio-spoof/internal/ratiospoof"
"github.com/olekukonko/ts"
)
func (R *ratioSpoofState) PrintState(exitedCH <-chan string) {
func PrintState(state *ratiospoof.RatioSpoof) {
exit := false
go func() {
_ = <-exitedCH
_ = <-state.StopPrintCH
exit = true
}()
@ -24,19 +25,25 @@ func (R *ratioSpoofState) PrintState(exitedCH <-chan string) {
}
width := terminalSize()
clear()
if R.announceHistory.Len() > 0 {
seedersStr := fmt.Sprint(R.seeders)
leechersStr := fmt.Sprint(R.leechers)
if R.seeders == 0 {
if state.AnnounceCount == 1 {
println("Trying to connect to the tracker...")
time.Sleep(1 * time.Second)
continue
}
if state.AnnounceHistory.Len() > 0 {
seedersStr := fmt.Sprint(state.Seeders)
leechersStr := fmt.Sprint(state.Leechers)
if state.Seeders == 0 {
seedersStr = "not informed"
}
if R.leechers == 0 {
if state.Leechers == 0 {
leechersStr = "not informed"
}
var retryStr string
if R.retryAttempt > 0 {
retryStr = fmt.Sprintf("(*Retry %v - check your connection)", R.retryAttempt)
if state.Tracker.RetryAttempt > 0 {
retryStr = fmt.Sprintf("(*Retry %v - check your connection)", state.Tracker.RetryAttempt)
}
fmt.Printf("%s\n", center(" RATIO-SPOOF ", width-len(" RATIO-SPOOF "), "#"))
fmt.Printf(`
@ -47,25 +54,25 @@ func (R *ratioSpoofState) PrintState(exitedCH <-chan string) {
Download Speed: %v/s
Upload Speed: %v/s
Size: %v
Emulation: %v | Port: %v`, R.torrentInfo.Name, R.torrentInfo.TrackerInfo.Main, seedersStr, leechersStr, humanReadableSize(float64(R.input.downloadSpeed)),
humanReadableSize(float64(R.input.uploadSpeed)), humanReadableSize(float64(R.torrentInfo.TotalSize)), R.bitTorrentClient.Name(), R.input.port)
Emulation: %v | Port: %v`, state.TorrentInfo.Name, state.TorrentInfo.TrackerInfo.Main, seedersStr, leechersStr, humanReadableSize(float64(state.Input.DownloadSpeed)),
humanReadableSize(float64(state.Input.UploadSpeed)), humanReadableSize(float64(state.TorrentInfo.TotalSize)), state.BitTorrentClient.Name(), state.Input.Port)
fmt.Printf("\n\n%s\n\n", center(" GITHUB.COM/AP-PAULOAFONSO/RATIO-SPOOF ", width-len(" GITHUB.COM/AP-PAULOAFONSO/RATIO-SPOOF "), "#"))
for i := 0; i <= R.announceHistory.Len()-2; i++ {
dequeItem := R.announceHistory.At(i).(announceEntry)
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)))
for i := 0; i <= state.AnnounceHistory.Len()-2; i++ {
dequeItem := state.AnnounceHistory.At(i).(ratiospoof.AnnounceEntry)
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)))
}
lastDequeItem := R.announceHistory.At(R.announceHistory.Len() - 1).(announceEntry)
fmt.Printf("#%v downloaded: %v(%.2f%%) | left: %v | uploaded: %v | next announce in: %v %v\n", lastDequeItem.count,
humanReadableSize(float64(lastDequeItem.downloaded)),
lastDequeItem.percentDownloaded,
humanReadableSize(float64(lastDequeItem.left)),
humanReadableSize(float64(lastDequeItem.uploaded)),
fmtDuration(R.currentAnnounceTimer),
lastDequeItem := state.AnnounceHistory.At(state.AnnounceHistory.Len() - 1).(ratiospoof.AnnounceEntry)
fmt.Printf("#%v downloaded: %v(%.2f%%) | left: %v | uploaded: %v | next announce in: %v %v\n", lastDequeItem.Count,
humanReadableSize(float64(lastDequeItem.Downloaded)),
lastDequeItem.PercentDownloaded,
humanReadableSize(float64(lastDequeItem.Left)),
humanReadableSize(float64(lastDequeItem.Uploaded)),
fmtDuration(state.CurrentAnnounceTimer),
retryStr)
if R.input.debug {
if state.Input.Debug {
fmt.Printf("\n%s\n", center(" DEBUG ", width-len(" DEBUG "), "#"))
fmt.Printf("\n%s\n\n%s", R.lastAnounceRequest, R.lastTackerResponse)
fmt.Printf("\n%s\n\n%s", state.Tracker.LastAnounceRequest, state.Tracker.LastTackerResponse)
}
time.Sleep(1 * time.Second)
}

View file

@ -1,4 +1,4 @@
package ratiospoof
package printer
import (
"fmt"

View file

@ -0,0 +1,229 @@
package ratiospoof
import (
"fmt"
"io/ioutil"
"math/rand"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"github.com/ap-pauloafonso/ratio-spoof/internal/bencode"
"github.com/ap-pauloafonso/ratio-spoof/internal/input"
"github.com/ap-pauloafonso/ratio-spoof/internal/tracker"
"github.com/gammazero/deque"
)
const (
maxAnnounceHistory = 10
)
type RatioSpoof struct {
mutex *sync.Mutex
TorrentInfo *bencode.TorrentInfo
Input *input.InputParsed
Tracker *tracker.HttpTracker
BitTorrentClient TorrentClientEmulation
CurrentAnnounceTimer int
AnnounceInterval int
NumWant int
Seeders int
Leechers int
AnnounceCount int
Status string
AnnounceHistory announceHistory
timerUpdateCh chan int
StopPrintCH chan interface{}
}
type TorrentClientEmulation interface {
PeerID() string
Key() string
Query() string
Name() string
Headers() map[string]string
NextAmountReport(DownloadCandidateNextAmount, UploadCandidateNextAmount, leftCandidateNextAmount, pieceSize int) (downloaded, uploaded, left int)
}
type AnnounceEntry struct {
Count int
Downloaded int
PercentDownloaded float32
Uploaded int
Left int
}
type announceHistory struct {
deque.Deque
}
func NewRatioSpoofState(input input.InputArgs, torrentClient TorrentClientEmulation) (*RatioSpoof, error) {
changeTimerCh := make(chan int)
stopPrintCh := make(chan interface{})
dat, err := ioutil.ReadFile(input.TorrentPath)
if err != nil {
return nil, err
}
torrentInfo, err := bencode.TorrentDictParse(dat)
if err != nil {
panic(err)
}
httpTracker, err := tracker.NewHttpTracker(torrentInfo, changeTimerCh)
if err != nil {
panic(err)
}
inputParsed, err := input.ParseInput(torrentInfo)
if err != nil {
panic(err)
}
return &RatioSpoof{
BitTorrentClient: torrentClient,
TorrentInfo: torrentInfo,
Tracker: httpTracker,
Input: inputParsed,
NumWant: 200,
Status: "started",
mutex: &sync.Mutex{},
timerUpdateCh: changeTimerCh,
StopPrintCH: stopPrintCh,
}, nil
}
func (A *announceHistory) pushValueHistory(value AnnounceEntry) {
if A.Len() >= maxAnnounceHistory {
A.PopFront()
}
A.PushBack(value)
}
func (R *RatioSpoof) gracefullyExit() {
fmt.Printf("\nGracefully exiting...\n")
R.Status = "stopped"
R.NumWant = 0
R.fireAnnounce(false)
}
func (R *RatioSpoof) Run() {
rand.Seed(time.Now().UnixNano())
sigCh := make(chan os.Signal)
signal.Notify(sigCh, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
R.firstAnnounce()
go R.decreaseTimer()
go R.updateTimer()
go func() {
for {
R.generateNextAnnounce()
time.Sleep(time.Duration(R.AnnounceInterval) * time.Second)
R.fireAnnounce(true)
}
}()
<-sigCh
R.StopPrintCH <- "exit print"
R.gracefullyExit()
}
func (R *RatioSpoof) firstAnnounce() {
R.addAnnounce(R.Input.InitialDownloaded, R.Input.InitialUploaded, calculateBytesLeft(R.Input.InitialDownloaded, R.TorrentInfo.TotalSize), (float32(R.Input.InitialDownloaded)/float32(R.TorrentInfo.TotalSize))*100)
R.fireAnnounce(false)
}
func (R *RatioSpoof) updateInterval(resp tracker.TrackerResponse) {
if resp.Interval > 0 {
R.AnnounceInterval = resp.Interval
} else {
R.AnnounceInterval = 1800
}
}
func (R *RatioSpoof) updateSeedersAndLeechers(resp tracker.TrackerResponse) {
R.Seeders = resp.Seeders
R.Leechers = resp.Leechers
}
func (R *RatioSpoof) addAnnounce(currentDownloaded, currentUploaded, currentLeft int, percentDownloaded float32) {
R.AnnounceCount++
R.AnnounceHistory.pushValueHistory(AnnounceEntry{Count: R.AnnounceCount, Downloaded: currentDownloaded, Uploaded: currentUploaded, Left: currentLeft, PercentDownloaded: percentDownloaded})
}
func (R *RatioSpoof) fireAnnounce(retry bool) {
lastAnnounce := R.AnnounceHistory.Back().(AnnounceEntry)
replacer := strings.NewReplacer("{infohash}", R.TorrentInfo.InfoHashURLEncoded,
"{port}", fmt.Sprint(R.Input.Port),
"{peerid}", R.BitTorrentClient.PeerID(),
"{uploaded}", fmt.Sprint(lastAnnounce.Uploaded),
"{downloaded}", fmt.Sprint(lastAnnounce.Downloaded),
"{left}", fmt.Sprint(lastAnnounce.Left),
"{key}", R.BitTorrentClient.Key(),
"{event}", R.Status,
"{numwant}", fmt.Sprint(R.NumWant))
query := replacer.Replace(R.BitTorrentClient.Query())
trackerResp := R.Tracker.Announce(query, R.BitTorrentClient.Headers(), retry, R.timerUpdateCh)
if trackerResp != nil {
R.updateSeedersAndLeechers(*trackerResp)
R.updateInterval(*trackerResp)
}
}
func (R *RatioSpoof) generateNextAnnounce() {
R.timerUpdateCh <- R.AnnounceInterval
lastAnnounce := R.AnnounceHistory.Back().(AnnounceEntry)
currentDownloaded := lastAnnounce.Downloaded
var downloadCandidate int
if currentDownloaded < R.TorrentInfo.TotalSize {
downloadCandidate = calculateNextTotalSizeByte(R.Input.DownloadSpeed, currentDownloaded, R.TorrentInfo.PieceSize, R.AnnounceInterval, R.TorrentInfo.TotalSize)
} else {
downloadCandidate = R.TorrentInfo.TotalSize
}
currentUploaded := lastAnnounce.Uploaded
uploadCandidate := calculateNextTotalSizeByte(R.Input.UploadSpeed, currentUploaded, R.TorrentInfo.PieceSize, R.AnnounceInterval, 0)
leftCandidate := calculateBytesLeft(downloadCandidate, R.TorrentInfo.TotalSize)
d, u, l := R.BitTorrentClient.NextAmountReport(downloadCandidate, uploadCandidate, leftCandidate, R.TorrentInfo.PieceSize)
R.addAnnounce(d, u, l, (float32(d)/float32(R.TorrentInfo.TotalSize))*100)
}
func (R *RatioSpoof) decreaseTimer() {
for {
time.Sleep(1 * time.Second)
R.mutex.Lock()
if R.CurrentAnnounceTimer > 0 {
R.CurrentAnnounceTimer--
}
R.mutex.Unlock()
}
}
func (R *RatioSpoof) updateTimer() {
for {
newValue := <-R.timerUpdateCh
R.mutex.Lock()
R.CurrentAnnounceTimer = newValue
R.mutex.Unlock()
}
}
func calculateNextTotalSizeByte(speedBytePerSecond, currentByte, pieceSizeByte, seconds, limitTotalBytes int) int {
if speedBytePerSecond == 0 {
return currentByte
}
totalCandidate := currentByte + (speedBytePerSecond * seconds)
randomPieces := rand.Intn(10-1) + 1
totalCandidate = totalCandidate + (pieceSizeByte * randomPieces)
if limitTotalBytes != 0 && totalCandidate > limitTotalBytes {
return limitTotalBytes
}
return totalCandidate
}
func calculateBytesLeft(currentBytes, totalBytes int) int {
return totalBytes - currentBytes
}

View file

@ -1,7 +1,6 @@
package ratiospoof
import (
"fmt"
"testing"
)
@ -12,34 +11,6 @@ func assertAreEqual(t *testing.T, got, want interface{}) {
}
}
func TestStrSize2ByteSize(T *testing.T) {
data := []struct {
in string
inTotalSize int
out int
}{
{"100kb", 100, 102400},
{"1kb", 0, 1024},
{"1mb", 0, 1048576},
{"1gb", 0, 1073741824},
{"1.5gb", 0, 1610612736},
{"1tb", 0, 1099511627776},
{"1b", 0, 1},
{"100%", 10737418240, 10737418240},
{"55%", 943718400, 519045120},
}
for idx, td := range data {
T.Run(fmt.Sprint(idx), func(t *testing.T) {
got := strSize2ByteSize(td.in, td.inTotalSize)
if got != td.out {
t.Errorf("got %v, want %v", got, td.out)
}
})
}
}
func TestClculateNextTotalSizeByte(T *testing.T) {
got := calculateNextTotalSizeByte(100*1024, 0, 512, 30, 87979879)

138
internal/tracker/tracker.go Normal file
View file

@ -0,0 +1,138 @@
package tracker
import (
"bytes"
"compress/gzip"
"errors"
"io/ioutil"
"net/http"
"strings"
"time"
"github.com/ap-pauloafonso/ratio-spoof/internal/bencode"
)
type HttpTracker struct {
Urls []string
RetryAttempt int
LastAnounceRequest string
LastTackerResponse string
}
type TrackerResponse struct {
MinInterval int
Interval int
Seeders int
Leechers int
}
func NewHttpTracker(torrentInfo *bencode.TorrentInfo, timerChangeChannel chan<- int) (*HttpTracker, error) {
var result []string
for _, url := range torrentInfo.TrackerInfo.Urls {
if strings.HasPrefix(url, "http") {
result = append(result, url)
}
}
if len(result) == 0 {
return nil, errors.New("No tcp/http tracker url announce found")
}
return &HttpTracker{Urls: torrentInfo.TrackerInfo.Urls}, nil
}
func (T *HttpTracker) SwapFirst(currentIdx int) {
aux := T.Urls[0]
T.Urls[0] = T.Urls[currentIdx]
T.Urls[currentIdx] = aux
}
func (T *HttpTracker) Announce(query string, headers map[string]string, retry bool, timerUpdateChannel chan<- int) *TrackerResponse {
var trackerResp *TrackerResponse
if retry {
retryDelay := 30 * time.Second
for {
exit := false
func() {
defer func() {
if err := recover(); err != nil {
timerUpdateChannel <- int(retryDelay.Seconds())
T.RetryAttempt++
time.Sleep(retryDelay)
retryDelay *= 2
if retryDelay.Seconds() > 900 {
retryDelay = 900
}
}
}()
trackerResp = T.tryMakeRequest(query, headers)
exit = true
}()
if exit {
break
}
}
} else {
trackerResp = T.tryMakeRequest(query, headers)
}
T.RetryAttempt = 0
return trackerResp
}
func (t *HttpTracker) tryMakeRequest(query string, headers map[string]string) *TrackerResponse {
for idx, baseUrl := range t.Urls {
completeURL := buildFullUrl(baseUrl, query)
t.LastAnounceRequest = completeURL
req, _ := http.NewRequest("GET", completeURL, nil)
for header, value := range headers {
req.Header.Add(header, value)
}
resp, err := http.DefaultClient.Do(req)
if err == nil {
if resp.StatusCode == http.StatusOK {
bytesR, _ := ioutil.ReadAll(resp.Body)
if len(bytesR) == 0 {
return nil
}
mimeType := http.DetectContentType(bytesR)
if mimeType == "application/x-gzip" {
gzipReader, _ := gzip.NewReader(bytes.NewReader(bytesR))
bytesR, _ = ioutil.ReadAll(gzipReader)
gzipReader.Close()
}
t.LastTackerResponse = string(bytesR)
decodedResp := bencode.Decode(bytesR)
if idx != 0 {
t.SwapFirst(idx)
}
ret := extractTrackerResponse(decodedResp)
return &ret
}
resp.Body.Close()
}
}
panic("Connection error with the tracker")
}
func buildFullUrl(baseurl, query string) string {
if len(strings.Split(baseurl, "?")) > 1 {
return baseurl + "&" + strings.TrimLeft(query, "&")
}
return baseurl + "?" + strings.TrimLeft(query, "?")
}
func extractTrackerResponse(datatrackerResponse map[string]interface{}) TrackerResponse {
var result TrackerResponse
if v, ok := datatrackerResponse["failure reason"].(string); ok && len(v) > 0 {
panic(errors.New(v))
}
result.MinInterval, _ = datatrackerResponse["min interval"].(int)
result.Interval, _ = datatrackerResponse["interval"].(int)
result.Seeders, _ = datatrackerResponse["complete"].(int)
result.Leechers, _ = datatrackerResponse["incomplete"].(int)
return result
}

View file

@ -1,486 +0,0 @@
package ratiospoof
import (
"bytes"
"compress/gzip"
"errors"
"fmt"
"io/ioutil"
"math"
"math/rand"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/ap-pauloafonso/ratio-spoof/bencode"
"github.com/gammazero/deque"
)
const (
maxAnnounceHistory = 10
)
var validInitialSufixes = [...]string{"%", "b", "kb", "mb", "gb", "tb"}
var validSpeedSufixes = [...]string{"kbps", "mbps"}
type ratioSpoofState struct {
mutex *sync.Mutex
httpClient HttpClient
torrentInfo *bencode.TorrentInfo
input *inputParsed
trackerState *httpTracker
bitTorrentClient TorrentClientEmulation
currentAnnounceTimer int
announceInterval int
numWant int
seeders int
leechers int
announceCount int
status string
announceHistory announceHistory
lastAnounceRequest string
lastTackerResponse string
retryAttempt int
}
type httpTracker struct {
urls []string
}
func newHttpTracker(torrentInfo *bencode.TorrentInfo) (*httpTracker, error) {
var result []string
for _, url := range torrentInfo.TrackerInfo.Urls {
if strings.HasPrefix(url, "http") {
result = append(result, url)
}
}
if len(result) == 0 {
return nil, errors.New("No tcp/http tracker url announce found")
}
return &httpTracker{urls: torrentInfo.TrackerInfo.Urls}, nil
}
func (T *httpTracker) SwapFirst(currentIdx int) {
aux := T.urls[0]
T.urls[0] = T.urls[currentIdx]
T.urls[currentIdx] = aux
}
type InputArgs struct {
TorrentPath string
InitialDownloaded string
DownloadSpeed string
InitialUploaded string
UploadSpeed string
Port int
Debug bool
}
type inputParsed struct {
torrentPath string
initialDownloaded int
downloadSpeed int
initialUploaded int
uploadSpeed int
port int
debug bool
}
func (I *InputArgs) parseInput(torrentInfo *bencode.TorrentInfo) (*inputParsed, error) {
downloaded, err := extractInputInitialByteCount(I.InitialDownloaded, torrentInfo.TotalSize, true)
if err != nil {
return nil, err
}
uploaded, err := extractInputInitialByteCount(I.InitialUploaded, torrentInfo.TotalSize, false)
if err != nil {
return nil, err
}
downloadSpeed, err := extractInputByteSpeed(I.DownloadSpeed)
if err != nil {
return nil, err
}
uploadSpeed, err := extractInputByteSpeed(I.UploadSpeed)
if err != nil {
return nil, err
}
if I.Port < 1 || I.Port > 65535 {
return nil, errors.New("port number must be between 1 and 65535")
}
return &inputParsed{initialDownloaded: downloaded,
downloadSpeed: downloadSpeed,
initialUploaded: uploaded,
uploadSpeed: uploadSpeed,
debug: I.Debug,
port: I.Port,
}, nil
}
func NewRatioSPoofState(input InputArgs, torrentClient TorrentClientEmulation, httpclient HttpClient) (*ratioSpoofState, error) {
dat, err := ioutil.ReadFile(input.TorrentPath)
if err != nil {
return nil, err
}
torrentInfo, err := bencode.TorrentDictParse(dat)
if err != nil {
panic(err)
}
httpTracker, err := newHttpTracker(torrentInfo)
if err != nil {
panic(err)
}
inputParsed, err := input.parseInput(torrentInfo)
if err != nil {
panic(err)
}
return &ratioSpoofState{
bitTorrentClient: torrentClient,
httpClient: httpclient,
torrentInfo: torrentInfo,
trackerState: httpTracker,
input: inputParsed,
numWant: 200,
status: "started",
mutex: &sync.Mutex{},
}, nil
}
func checkSpeedSufix(input string) (valid bool, suffix string) {
for _, v := range validSpeedSufixes {
if strings.HasSuffix(strings.ToLower(input), v) {
return true, input[len(input)-4:]
}
}
return false, ""
}
func extractInputInitialByteCount(initialSizeInput string, totalBytes int, errorIfHigher bool) (int, error) {
byteCount := strSize2ByteSize(initialSizeInput, totalBytes)
if errorIfHigher && byteCount > totalBytes {
return 0, errors.New("initial downloaded can not be higher than the torrent size")
}
if byteCount < 0 {
return 0, errors.New("initial value can not be negative")
}
return byteCount, nil
}
func extractInputByteSpeed(initialSpeedInput string) (int, error) {
ok, suffix := checkSpeedSufix(initialSpeedInput)
if !ok {
return 0, fmt.Errorf("speed must be in %v", validSpeedSufixes)
}
number, _ := strconv.ParseFloat(initialSpeedInput[:len(initialSpeedInput)-4], 64)
if number < 0 {
return 0, errors.New("speed can not be negative")
}
if suffix == "kbps" {
number *= 1024
} else {
number = number * 1024 * 1024
}
ret := int(number)
return ret, nil
}
type trackerResponse struct {
minInterval int
interval int
seeders int
leechers int
}
type TorrentClientEmulation interface {
PeerID() string
Key() string
Query() string
Name() string
Headers() map[string]string
NextAmountReport(DownloadCandidateNextAmount, UploadCandidateNextAmount, leftCandidateNextAmount, pieceSize int) (downloaded, uploaded, left int)
}
type HttpClient interface {
Do(req *http.Request) (*http.Response, error)
}
type announceEntry struct {
count int
downloaded int
percentDownloaded float32
uploaded int
left int
}
type announceHistory struct {
deque.Deque
}
func (A *announceHistory) pushValueHistory(value announceEntry) {
if A.Len() >= maxAnnounceHistory {
A.PopFront()
}
A.PushBack(value)
}
func (R *ratioSpoofState) gracefullyExit() {
fmt.Printf("\nGracefully exiting...\n")
R.status = "stopped"
R.numWant = 0
R.fireAnnounce(false)
}
func (R *ratioSpoofState) Run() {
rand.Seed(time.Now().UnixNano())
sigCh := make(chan os.Signal)
stopPrintCh := make(chan string)
signal.Notify(sigCh, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
R.firstAnnounce()
go R.decreaseTimer()
go R.PrintState(stopPrintCh)
go func() {
for {
R.generateNextAnnounce()
time.Sleep(time.Duration(R.announceInterval) * time.Second)
R.fireAnnounce(true)
}
}()
<-sigCh
stopPrintCh <- "exit print"
R.gracefullyExit()
}
func (R *ratioSpoofState) firstAnnounce() {
println("Trying to connect to the tracker...")
R.addAnnounce(R.input.initialDownloaded, R.input.initialUploaded, calculateBytesLeft(R.input.initialDownloaded, R.torrentInfo.TotalSize), (float32(R.input.initialDownloaded)/float32(R.torrentInfo.TotalSize))*100)
R.fireAnnounce(false)
}
func (R *ratioSpoofState) updateInterval(resp trackerResponse) {
if resp.interval > 0 {
R.announceInterval = resp.interval
} else {
R.announceInterval = 1800
}
}
func (R *ratioSpoofState) updateSeedersAndLeechers(resp trackerResponse) {
R.seeders = resp.seeders
R.leechers = resp.leechers
}
func (R *ratioSpoofState) addAnnounce(currentDownloaded, currentUploaded, currentLeft int, percentDownloaded float32) {
R.announceCount++
R.announceHistory.pushValueHistory(announceEntry{count: R.announceCount, downloaded: currentDownloaded, uploaded: currentUploaded, left: currentLeft, percentDownloaded: percentDownloaded})
}
func (R *ratioSpoofState) fireAnnounce(retry bool) {
lastAnnounce := R.announceHistory.Back().(announceEntry)
replacer := strings.NewReplacer("{infohash}", R.torrentInfo.InfoHashURLEncoded,
"{port}", fmt.Sprint(R.input.port),
"{peerid}", R.bitTorrentClient.PeerID(),
"{uploaded}", fmt.Sprint(lastAnnounce.uploaded),
"{downloaded}", fmt.Sprint(lastAnnounce.downloaded),
"{left}", fmt.Sprint(lastAnnounce.left),
"{key}", R.bitTorrentClient.Key(),
"{event}", R.status,
"{numwant}", fmt.Sprint(R.numWant))
query := replacer.Replace(R.bitTorrentClient.Query())
var trackerResp *trackerResponse
if retry {
retryDelay := 30 * time.Second
for {
exit := false
func() {
defer func() {
if err := recover(); err != nil {
R.changeCurrentTimer(int(retryDelay.Seconds()))
R.retryAttempt++
time.Sleep(retryDelay)
retryDelay *= 2
if retryDelay.Seconds() > 900 {
retryDelay = 900
}
}
}()
trackerResp = R.tryMakeRequest(query)
exit = true
}()
if exit {
break
}
}
} else {
trackerResp = R.tryMakeRequest(query)
}
R.retryAttempt = 0
if trackerResp != nil {
R.updateSeedersAndLeechers(*trackerResp)
R.updateInterval(*trackerResp)
}
}
func (R *ratioSpoofState) generateNextAnnounce() {
R.changeCurrentTimer(R.announceInterval)
lastAnnounce := R.announceHistory.Back().(announceEntry)
currentDownloaded := lastAnnounce.downloaded
var downloadCandidate int
if currentDownloaded < R.torrentInfo.TotalSize {
downloadCandidate = calculateNextTotalSizeByte(R.input.downloadSpeed, currentDownloaded, R.torrentInfo.PieceSize, R.currentAnnounceTimer, R.torrentInfo.TotalSize)
} else {
downloadCandidate = R.torrentInfo.TotalSize
}
currentUploaded := lastAnnounce.uploaded
uploadCandidate := calculateNextTotalSizeByte(R.input.uploadSpeed, currentUploaded, R.torrentInfo.PieceSize, R.currentAnnounceTimer, 0)
leftCandidate := calculateBytesLeft(downloadCandidate, R.torrentInfo.TotalSize)
d, u, l := R.bitTorrentClient.NextAmountReport(downloadCandidate, uploadCandidate, leftCandidate, R.torrentInfo.PieceSize)
R.addAnnounce(d, u, l, (float32(d)/float32(R.torrentInfo.TotalSize))*100)
}
func (R *ratioSpoofState) decreaseTimer() {
for {
time.Sleep(1 * time.Second)
R.mutex.Lock()
if R.currentAnnounceTimer > 0 {
R.currentAnnounceTimer--
}
R.mutex.Unlock()
}
}
func (R *ratioSpoofState) changeCurrentTimer(newAnnounceRate int) {
R.mutex.Lock()
R.currentAnnounceTimer = newAnnounceRate
R.mutex.Unlock()
}
func (R *ratioSpoofState) tryMakeRequest(query string) *trackerResponse {
for idx, baseUrl := range R.trackerState.urls {
completeURL := buildFullUrl(baseUrl, query)
R.lastAnounceRequest = completeURL
req, _ := http.NewRequest("GET", completeURL, nil)
for header, value := range R.bitTorrentClient.Headers() {
req.Header.Add(header, value)
}
resp, err := R.httpClient.Do(req)
if err == nil {
if resp.StatusCode == http.StatusOK {
bytesR, _ := ioutil.ReadAll(resp.Body)
if len(bytesR) == 0 {
return nil
}
mimeType := http.DetectContentType(bytesR)
if mimeType == "application/x-gzip" {
gzipReader, _ := gzip.NewReader(bytes.NewReader(bytesR))
bytesR, _ = ioutil.ReadAll(gzipReader)
gzipReader.Close()
}
R.lastTackerResponse = string(bytesR)
decodedResp := bencode.Decode(bytesR)
if idx != 0 {
R.trackerState.SwapFirst(idx)
}
ret := extractTrackerResponse(decodedResp)
return &ret
}
resp.Body.Close()
}
}
panic("Connection error with the tracker")
}
func buildFullUrl(baseurl, query string) string {
if len(strings.Split(baseurl, "?")) > 1 {
return baseurl + "&" + strings.TrimLeft(query, "&")
}
return baseurl + "?" + strings.TrimLeft(query, "?")
}
func calculateNextTotalSizeByte(speedBytePerSecond, currentByte, pieceSizeByte, seconds, limitTotalBytes int) int {
if speedBytePerSecond == 0 {
return currentByte
}
totalCandidate := currentByte + (speedBytePerSecond * seconds)
randomPieces := rand.Intn(10-1) + 1
totalCandidate = totalCandidate + (pieceSizeByte * randomPieces)
if limitTotalBytes != 0 && totalCandidate > limitTotalBytes {
return limitTotalBytes
}
return totalCandidate
}
func extractTrackerResponse(datatrackerResponse map[string]interface{}) trackerResponse {
var result trackerResponse
if v, ok := datatrackerResponse["failure reason"].(string); ok && len(v) > 0 {
panic(errors.New(v))
}
result.minInterval, _ = datatrackerResponse["min interval"].(int)
result.interval, _ = datatrackerResponse["interval"].(int)
result.seeders, _ = datatrackerResponse["complete"].(int)
result.leechers, _ = datatrackerResponse["incomplete"].(int)
return result
}
func calculateBytesLeft(currentBytes, totalBytes int) int {
return totalBytes - currentBytes
}
func strSize2ByteSize(input string, totalSize int) int {
lowerInput := strings.ToLower(input)
parseStrNumberFn := func(strWithSufix string, sufixLength, n int) int {
v, _ := strconv.ParseFloat(strWithSufix[:len(lowerInput)-sufixLength], 64)
result := v * math.Pow(1024, float64(n))
return int(result)
}
switch {
case strings.HasSuffix(lowerInput, "kb"):
{
return parseStrNumberFn(lowerInput, 2, 1)
}
case strings.HasSuffix(lowerInput, "mb"):
{
return parseStrNumberFn(lowerInput, 2, 2)
}
case strings.HasSuffix(lowerInput, "gb"):
{
return parseStrNumberFn(lowerInput, 2, 3)
}
case strings.HasSuffix(lowerInput, "tb"):
{
return parseStrNumberFn(lowerInput, 2, 4)
}
case strings.HasSuffix(lowerInput, "b"):
{
return parseStrNumberFn(lowerInput, 1, 0)
}
case strings.HasSuffix(lowerInput, "%"):
{
v, _ := strconv.ParseFloat(lowerInput[:len(lowerInput)-1], 64)
if v < 0 || v > 100 {
panic("percent value must be in (0-100)")
}
result := int(float64(v/100) * float64(totalSize))
return result
}
default:
panic("Size not found")
}
}