460 lines
11 KiB
Go
460 lines
11 KiB
Go
package tracker
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/binary"
|
|
"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"`
|
|
Peers6 string `bencode:"peers6"`
|
|
}
|
|
|
|
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) {
|
|
if tf == nil {
|
|
return nil, errors.New("torrent metadata is nil")
|
|
}
|
|
return GetPeersFromURL(ctx, tf.Announce, tf.InfoHash, tf.Length, opts)
|
|
}
|
|
|
|
func GetPeersFromURL(ctx context.Context, announce string, infoHash [20]byte, length int, opts AnnounceOptions) ([]Peer, error) {
|
|
opts = normalizeOptions(opts)
|
|
|
|
announceURL, err := url.Parse(announce)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid tracker URL %q: %w", announce, err)
|
|
}
|
|
|
|
switch announceURL.Scheme {
|
|
case "http", "https":
|
|
client := &http.Client{Timeout: opts.Timeout}
|
|
return getPeersWithClient(ctx, client, announceURL, infoHash, length, opts)
|
|
case "udp":
|
|
return getPeersUDP(ctx, announceURL, infoHash, length, opts)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported tracker scheme %q (only http/https/udp are supported)", announceURL.Scheme)
|
|
}
|
|
}
|
|
|
|
func getPeersWithClient(ctx context.Context, client httpDoer, announceURL *url.URL, infoHash [20]byte, length int, opts AnnounceOptions) ([]Peer, error) {
|
|
opts = normalizeOptions(opts)
|
|
|
|
if client == nil {
|
|
client = &http.Client{Timeout: opts.Timeout}
|
|
}
|
|
|
|
announceURLString, err := buildAnnounceURL(announceURL, infoHash, length, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, announceURLString, 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)
|
|
}
|
|
|
|
// Парсим IPv4 compact peers
|
|
peers, err := ParsePeers([]byte(tr.Peers))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Парсим IPv6 compact peers (peers6, BEP 7)
|
|
if tr.Peers6 != "" {
|
|
peers6, err := ParsePeers6([]byte(tr.Peers6))
|
|
if err == nil {
|
|
peers = mergePeers(peers, peers6)
|
|
}
|
|
}
|
|
|
|
return peers, nil
|
|
}
|
|
|
|
func getPeersUDP(ctx context.Context, announceURL *url.URL, infoHash [20]byte, length int, opts AnnounceOptions) ([]Peer, error) {
|
|
if announceURL == nil {
|
|
return nil, errors.New("tracker URL is nil")
|
|
}
|
|
if announceURL.Scheme != "udp" {
|
|
return nil, fmt.Errorf("tracker URL scheme %q is not udp", announceURL.Scheme)
|
|
}
|
|
if announceURL.Host == "" {
|
|
return nil, errors.New("tracker URL host is empty")
|
|
}
|
|
|
|
dialer := net.Dialer{Timeout: opts.Timeout}
|
|
conn, err := dialer.DialContext(ctx, "udp", announceURL.Host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
deadline := time.Now().Add(opts.Timeout)
|
|
if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
|
|
deadline = ctxDeadline
|
|
}
|
|
if err := conn.SetDeadline(deadline); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
connectTx, err := randomUint32()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
connectReq := buildUDPConnectRequest(connectTx)
|
|
if _, err := conn.Write(connectReq[:]); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp := make([]byte, 65535)
|
|
n, err := conn.Read(resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
connectionID, err := parseUDPConnectResponse(resp[:n], connectTx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
announceTx, err := randomUint32()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
key, err := randomUint32()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
announceReq, err := buildUDPAnnounceRequest(connectionID, announceTx, infoHash, length, opts, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := conn.Write(announceReq); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
n, err = conn.Read(resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return parseUDPAnnounceResponse(resp[:n], announceTx)
|
|
}
|
|
|
|
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(baseURL *url.URL, infoHash [20]byte, length int, opts AnnounceOptions) (string, error) {
|
|
if baseURL == nil {
|
|
return "", errors.New("tracker URL is nil")
|
|
}
|
|
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(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(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),
|
|
)
|
|
|
|
cloned := *baseURL
|
|
cloned.RawQuery = strings.Join(parts, "&")
|
|
return cloned.String(), nil
|
|
}
|
|
|
|
func buildUDPConnectRequest(transactionID uint32) [16]byte {
|
|
var req [16]byte
|
|
binary.BigEndian.PutUint64(req[0:8], 0x41727101980)
|
|
binary.BigEndian.PutUint32(req[8:12], 0)
|
|
binary.BigEndian.PutUint32(req[12:16], transactionID)
|
|
return req
|
|
}
|
|
|
|
func parseUDPConnectResponse(payload []byte, expectedTransactionID uint32) (uint64, error) {
|
|
if len(payload) < 8 {
|
|
return 0, fmt.Errorf("udp tracker connect response too short: %d bytes", len(payload))
|
|
}
|
|
|
|
action := binary.BigEndian.Uint32(payload[0:4])
|
|
transactionID := binary.BigEndian.Uint32(payload[4:8])
|
|
if transactionID != expectedTransactionID {
|
|
return 0, fmt.Errorf("udp tracker connect transaction mismatch: expected %d, got %d", expectedTransactionID, transactionID)
|
|
}
|
|
|
|
if action == 3 {
|
|
return 0, parseUDPTrackerError(payload)
|
|
}
|
|
if action != 0 {
|
|
return 0, fmt.Errorf("unexpected udp tracker connect action %d", action)
|
|
}
|
|
if len(payload) < 16 {
|
|
return 0, fmt.Errorf("udp tracker connect response too short: %d bytes", len(payload))
|
|
}
|
|
|
|
return binary.BigEndian.Uint64(payload[8:16]), nil
|
|
}
|
|
|
|
func buildUDPAnnounceRequest(connectionID uint64, transactionID uint32, infoHash [20]byte, length int, opts AnnounceOptions, key uint32) ([]byte, error) {
|
|
|
|
uploaded := opts.Uploaded
|
|
if uploaded < 0 {
|
|
uploaded = 0
|
|
}
|
|
downloaded := opts.Downloaded
|
|
if downloaded < 0 {
|
|
downloaded = 0
|
|
}
|
|
left := int64(length) - downloaded
|
|
if left < 0 {
|
|
left = 0
|
|
}
|
|
|
|
req := make([]byte, 98)
|
|
binary.BigEndian.PutUint64(req[0:8], connectionID)
|
|
binary.BigEndian.PutUint32(req[8:12], 1)
|
|
binary.BigEndian.PutUint32(req[12:16], transactionID)
|
|
copy(req[16:36], infoHash[:])
|
|
copy(req[36:56], opts.PeerID[:])
|
|
binary.BigEndian.PutUint64(req[56:64], uint64(downloaded))
|
|
binary.BigEndian.PutUint64(req[64:72], uint64(left))
|
|
binary.BigEndian.PutUint64(req[72:80], uint64(uploaded))
|
|
binary.BigEndian.PutUint32(req[80:84], 0)
|
|
binary.BigEndian.PutUint32(req[84:88], 0)
|
|
binary.BigEndian.PutUint32(req[88:92], key)
|
|
binary.BigEndian.PutUint32(req[92:96], uint32(int32(opts.NumWant)))
|
|
binary.BigEndian.PutUint16(req[96:98], opts.Port)
|
|
|
|
return req, nil
|
|
}
|
|
|
|
func parseUDPAnnounceResponse(payload []byte, expectedTransactionID uint32) ([]Peer, error) {
|
|
if len(payload) < 8 {
|
|
return nil, fmt.Errorf("udp tracker announce response too short: %d bytes", len(payload))
|
|
}
|
|
|
|
action := binary.BigEndian.Uint32(payload[0:4])
|
|
transactionID := binary.BigEndian.Uint32(payload[4:8])
|
|
if transactionID != expectedTransactionID {
|
|
return nil, fmt.Errorf("udp tracker announce transaction mismatch: expected %d, got %d", expectedTransactionID, transactionID)
|
|
}
|
|
|
|
if action == 3 {
|
|
return nil, parseUDPTrackerError(payload)
|
|
}
|
|
if action != 1 {
|
|
return nil, fmt.Errorf("unexpected udp tracker announce action %d", action)
|
|
}
|
|
if len(payload) < 20 {
|
|
return nil, fmt.Errorf("udp tracker announce response too short: %d bytes", len(payload))
|
|
}
|
|
|
|
return ParsePeers(payload[20:])
|
|
}
|
|
|
|
func parseUDPTrackerError(payload []byte) error {
|
|
if len(payload) > 8 {
|
|
return fmt.Errorf("udp tracker failure: %s", strings.TrimSpace(string(payload[8:])))
|
|
}
|
|
return errors.New("udp tracker failure")
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// ParsePeers parses compact IPv4 peers (6 bytes per peer: 4 IP + 2 port).
|
|
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 := make(net.IP, 4)
|
|
copy(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
|
|
}
|
|
|
|
// ParsePeers6 парсит compact IPv6 peers (BEP 7): 18 байт на пир (16 IP + 2 порт).
|
|
func ParsePeers6(data []byte) ([]Peer, error) {
|
|
if len(data) == 0 {
|
|
return []Peer{}, nil
|
|
}
|
|
if len(data)%18 != 0 {
|
|
return nil, fmt.Errorf("invalid compact peers6 length %d (must be multiple of 18)", len(data))
|
|
}
|
|
|
|
peers := make([]Peer, 0, len(data)/18)
|
|
for i := 0; i < len(data); i += 18 {
|
|
ip := make(net.IP, 16)
|
|
copy(ip, data[i:i+16])
|
|
port := uint16(data[i+16])<<8 | uint16(data[i+17])
|
|
peers = append(peers, Peer{IP: ip, Port: port})
|
|
}
|
|
return peers, nil
|
|
}
|
|
|
|
// mergePeers объединяет два списка пиров, дедуплицируя по IP:порт.
|
|
func mergePeers(a, b []Peer) []Peer {
|
|
seen := make(map[string]struct{}, len(a)+len(b))
|
|
result := make([]Peer, 0, len(a)+len(b))
|
|
|
|
add := func(p Peer) {
|
|
key := net.JoinHostPort(p.IP.String(), strconv.Itoa(int(p.Port)))
|
|
if _, ok := seen[key]; !ok {
|
|
seen[key] = struct{}{}
|
|
result = append(result, p)
|
|
}
|
|
}
|
|
|
|
for _, p := range a {
|
|
add(p)
|
|
}
|
|
for _, p := range b {
|
|
add(p)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func randomUint32() (uint32, error) {
|
|
var b [4]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return 0, err
|
|
}
|
|
return binary.BigEndian.Uint32(b[:]), nil
|
|
}
|