Ztorrent/internal/tracker/tracker.go
2026-03-06 12:52:01 +03:00

413 lines
9.8 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"`
}
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, opts)
}
func GetPeersFromURL(ctx context.Context, announce string, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
if tf == nil {
return nil, errors.New("torrent metadata is nil")
}
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, tf, opts)
case "udp":
return getPeersUDP(ctx, announceURL, tf, 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, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
opts = normalizeOptions(opts)
if client == nil {
client = &http.Client{Timeout: opts.Timeout}
}
announceURLString, err := buildAnnounceURL(announceURL, tf, 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)
}
return parsePeers([]byte(tr.Peers))
}
func getPeersUDP(ctx context.Context, announceURL *url.URL, tf *torrentfile.TorrentFile, 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, tf, 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, tf *torrentfile.TorrentFile, opts AnnounceOptions) (string, error) {
if tf == nil {
return "", errors.New("torrent metadata is nil")
}
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(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),
)
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, tf *torrentfile.TorrentFile, opts AnnounceOptions, key uint32) ([]byte, error) {
if tf == nil {
return nil, errors.New("torrent metadata is nil")
}
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
}
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], tf.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
}
}
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
}
func randomUint32() (uint32, error) {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, err
}
return binary.BigEndian.Uint32(b[:]), nil
}