212 lines
4.2 KiB
Go
212 lines
4.2 KiB
Go
package tracker
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackpal/bencode-go"
|
|
"github.com/veggiedefender/torrent-client/internal/torrentfile"
|
|
)
|
|
|
|
type TrackerResponse struct {
|
|
FailureReason string `bencode:"failure reason"`
|
|
Interval int `bencode:"interval"`
|
|
Peers string `bencode:"peers"`
|
|
}
|
|
|
|
type Peer struct {
|
|
IP net.IP
|
|
Port uint16
|
|
}
|
|
|
|
type AnnounceOptions struct {
|
|
PeerID [20]byte
|
|
Port uint16
|
|
Uploaded int64
|
|
Downloaded int64
|
|
NumWant int
|
|
Timeout time.Duration
|
|
}
|
|
|
|
type httpDoer interface {
|
|
Do(req *http.Request) (*http.Response, error)
|
|
}
|
|
|
|
func GetPeers(ctx context.Context, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
|
|
opts = normalizeOptions(opts)
|
|
client := &http.Client{Timeout: opts.Timeout}
|
|
return getPeersWithClient(ctx, client, tf, opts)
|
|
}
|
|
|
|
func getPeersWithClient(ctx context.Context, client httpDoer, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
|
|
if tf == nil {
|
|
return nil, errors.New("torrent metadata is nil")
|
|
}
|
|
|
|
opts = normalizeOptions(opts)
|
|
|
|
if client == nil {
|
|
client = &http.Client{Timeout: opts.Timeout}
|
|
}
|
|
|
|
announceURL, err := buildAnnounceURL(tf, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, announceURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("tracker returned HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
var tr TrackerResponse
|
|
|
|
err = bencode.Unmarshal(resp.Body, &tr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if tr.FailureReason != "" {
|
|
return nil, fmt.Errorf("tracker failure: %s", tr.FailureReason)
|
|
}
|
|
|
|
return parsePeers([]byte(tr.Peers))
|
|
}
|
|
|
|
func normalizeOptions(opts AnnounceOptions) AnnounceOptions {
|
|
if opts.Port == 0 {
|
|
opts.Port = 6881
|
|
}
|
|
if opts.NumWant <= 0 {
|
|
opts.NumWant = 50
|
|
}
|
|
if opts.Timeout <= 0 {
|
|
opts.Timeout = 10 * time.Second
|
|
}
|
|
return opts
|
|
}
|
|
|
|
func buildAnnounceURL(tf *torrentfile.TorrentFile, opts AnnounceOptions) (string, error) {
|
|
if tf == nil {
|
|
return "", errors.New("torrent metadata is nil")
|
|
}
|
|
|
|
baseURL, err := url.Parse(tf.Announce)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid tracker URL %q: %w", tf.Announce, err)
|
|
}
|
|
if baseURL.Scheme != "http" && baseURL.Scheme != "https" {
|
|
return "", fmt.Errorf("unsupported tracker scheme %q (only http/https are supported)", baseURL.Scheme)
|
|
}
|
|
if baseURL.Host == "" {
|
|
return "", errors.New("tracker URL host is empty")
|
|
}
|
|
|
|
uploaded := opts.Uploaded
|
|
if uploaded < 0 {
|
|
uploaded = 0
|
|
}
|
|
|
|
downloaded := opts.Downloaded
|
|
if downloaded < 0 {
|
|
downloaded = 0
|
|
}
|
|
|
|
left := int64(tf.Length) - downloaded
|
|
if left < 0 {
|
|
left = 0
|
|
}
|
|
|
|
parts := make([]string, 0, 10)
|
|
if baseURL.RawQuery != "" {
|
|
parts = append(parts, baseURL.RawQuery)
|
|
}
|
|
|
|
parts = append(parts,
|
|
"info_hash="+escapeBinary(tf.InfoHash[:]),
|
|
"peer_id="+escapeBinary(opts.PeerID[:]),
|
|
"port="+strconv.Itoa(int(opts.Port)),
|
|
"uploaded="+strconv.FormatInt(uploaded, 10),
|
|
"downloaded="+strconv.FormatInt(downloaded, 10),
|
|
"left="+strconv.FormatInt(left, 10),
|
|
"compact=1",
|
|
"numwant="+strconv.Itoa(opts.NumWant),
|
|
)
|
|
|
|
baseURL.RawQuery = strings.Join(parts, "&")
|
|
return baseURL.String(), nil
|
|
}
|
|
|
|
func escapeBinary(data []byte) string {
|
|
const hex = "0123456789ABCDEF"
|
|
|
|
var b strings.Builder
|
|
for _, c := range data {
|
|
if isURLUnreserved(c) {
|
|
b.WriteByte(c)
|
|
continue
|
|
}
|
|
b.WriteByte('%')
|
|
b.WriteByte(hex[c>>4])
|
|
b.WriteByte(hex[c&0x0F])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func isURLUnreserved(c byte) bool {
|
|
switch {
|
|
case c >= 'a' && c <= 'z':
|
|
return true
|
|
case c >= 'A' && c <= 'Z':
|
|
return true
|
|
case c >= '0' && c <= '9':
|
|
return true
|
|
case c == '-', c == '.', c == '_', c == '~':
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func parsePeers(data []byte) ([]Peer, error) {
|
|
if len(data) == 0 {
|
|
return []Peer{}, nil
|
|
}
|
|
if len(data)%6 != 0 {
|
|
return nil, fmt.Errorf("invalid compact peers length %d", len(data))
|
|
}
|
|
|
|
var peers []Peer
|
|
|
|
for i := 0; i < len(data); i += 6 {
|
|
|
|
ip := net.IP(data[i : i+4])
|
|
|
|
port := uint16(data[i+4])<<8 |
|
|
uint16(data[i+5])
|
|
|
|
peers = append(peers, Peer{
|
|
IP: ip,
|
|
Port: port,
|
|
})
|
|
}
|
|
|
|
return peers, nil
|
|
}
|