Ztorrent/internal/tracker/tracker.go

490 lines
12 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)
var connectionID uint64
var connOK bool
for n := 0; n <= 8; n++ {
if _, err := conn.Write(connectReq[:]); err != nil {
return nil, err
}
conn.SetReadDeadline(time.Now().Add(15 * time.Second * time.Duration(1<<n)))
resp := make([]byte, 65535)
nRead, err := conn.Read(resp)
if err == nil {
cid, errParse := parseUDPConnectResponse(resp[:nRead], connectTx)
if errParse == nil {
connectionID = cid
connOK = true
break
}
}
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
}
if !connOK {
return nil, fmt.Errorf("udp tracker connect timeout")
}
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
}
for n := 0; n <= 8; n++ {
if _, err := conn.Write(announceReq); err != nil {
return nil, err
}
conn.SetReadDeadline(time.Now().Add(15 * time.Second * time.Duration(1<<n)))
resp := make([]byte, 65535)
for {
nRead, err := conn.Read(resp)
if err != nil {
break // read timeout or error, retransmit
}
peers, errParse := parseUDPAnnounceResponse(resp[:nRead], announceTx)
if errParse == nil {
return peers, nil
}
// if errParse != nil, it might be a delayed packet from another transaction, so we just loop and Read again
}
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
}
return nil, fmt.Errorf("udp tracker announce timeout")
}
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], 1) // event: 1 (started) instead of 0 (none)
binary.BigEndian.PutUint32(req[84:88], 0) // IP address (default 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
}