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") } opts = normalizeOptions(opts) announceURL, err := url.Parse(tf.Announce) if err != nil { return nil, fmt.Errorf("invalid tracker URL %q: %w", tf.Announce, err) } switch announceURL.Scheme { case "http", "https": client := &http.Client{Timeout: opts.Timeout} return getPeersWithClient(ctx, client, 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, 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 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(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 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 }