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< 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 }