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 }