File Coverage

lib/Net/BitTorrent/Tracker/UDP.pm
Criterion Covered Total %
statement 32 37 86.4
branch n/a
condition 1 2 50.0
subroutine 10 12 83.3
pod n/a
total 43 51 84.3


line stmt bran cond sub pod time code
1 21     21   173334 use v5.40;
  21         95  
2 21     21   130 use feature 'class', 'try';
  21         41  
  21         3234  
3 21     21   121 no warnings 'experimental::class', 'experimental::try';
  21         42  
  21         2583  
4 1     1   400 class Net::BitTorrent::Tracker::UDP v2.0.0 : isa(Net::BitTorrent::Tracker::Base) {
  1         2  
  1         32  
5 21     21   477 use Net::BitTorrent::Protocol::BEP23;
  21         40  
  21         779  
6 21     21   621 use IO::Socket::IP;
  21         28496  
  21         261  
7 21     21   14775 use Config;
  21         51  
  21         1587  
8 21     21   133 use constant HAS_64BIT => $Config{ivsize} >= 8;
  21         34  
  21         42072  
9             field $connection_id = HAS_64BIT ? 0 : pack( 'NN', 0, 0 );
10             field $connection_id_time = 0;
11             field $transaction_id;
12             field $host;
13             field $port;
14             field $socket;
15             field %pending_transactions; # tid => { type => ..., cb => ..., payload => ..., retries => ..., timestamp => ... }
16              
17 3     3   3 sub _split64 ($val) {
  3         4  
  3         3  
18 3   50     4 $val //= 0;
19 3         3 if (HAS_64BIT) { return $val }
  3         30  
20 0         0 my $hi = int( $val / 4294967296 );
21 0         0 my $lo = $val - ( $hi * 4294967296 );
22 0         0 return ( $hi, $lo );
23             }
24             ADJUST {
25             if ( $self->url =~ m{^udp://([^:/]+):(\d+)} ) {
26             $host = $1;
27             $port = $2;
28             $socket = IO::Socket::IP->new( Proto => 'udp', Blocking => 0, ) or
29             $self->_emit( log => "Could not create UDP socket: $!", level => 'fatal' );
30             }
31             else {
32             $self->_emit( log => 'Invalid UDP tracker URL: ' . $self->url, level => 'fatal' );
33             }
34             }
35              
36             method _new_transaction_id () {
37             return $transaction_id = int( rand( 2**31 ) );
38             }
39              
40             method _is_connected () {
41             return defined $connection_id && ( time() - $connection_id_time < 60 );
42             }
43              
44             method tick ( $delta = 0.1 ) {
45             return unless $socket;
46              
47             # Check for incoming data
48             while ( $socket->recv( my $buf, 4096 ) ) {
49             $self->receive_data($buf);
50             }
51              
52             # Handle retransmissions
53             my $now = time();
54             for my $tid ( keys %pending_transactions ) {
55             my $entry = $pending_transactions{$tid};
56             my $timeout = 15 * ( 2**$entry->{retries} );
57             if ( $now - $entry->{timestamp} > $timeout ) {
58             if ( $entry->{retries} >= 8 ) {
59             $self->_emit( log => "UDP transaction $tid timed out after 8 retries", level => 'error' );
60             delete $pending_transactions{$tid};
61             next;
62             }
63             $entry->{retries}++;
64             $entry->{timestamp} = $now;
65             $self->_send_packet( $entry->{payload} );
66             }
67             }
68             }
69              
70             method receive_data ($data) {
71             return if length($data) < 8;
72             my ( $action, $tid ) = unpack( 'N N', $data );
73             my $entry = delete $pending_transactions{$tid};
74             if ( !$entry ) {
75             $self->_emit( log => "Received UDP packet with unknown transaction ID: $tid", level => 'debug' );
76             return;
77             }
78             try {
79             if ( $action == 3 ) { # Error
80             my $msg = substr( $data, 8 );
81             $self->_emit( log => "UDP Tracker error: $msg", level => 'error' );
82             return;
83             }
84             if ( $entry->{type} eq 'connect' ) {
85             if (HAS_64BIT) {
86             ( undef, undef, $connection_id ) = unpack( 'N N Q>', $data );
87             }
88             else {
89             $connection_id = substr( $data, 8, 8 );
90             }
91             $connection_id_time = time();
92              
93             # Now that we are connected, trigger the original request
94             if ( $entry->{on_connect} ) {
95             $entry->{on_connect}->();
96             }
97             }
98             elsif ( $entry->{type} eq 'announce' ) {
99             my $res = $self->parse_announce_response($data);
100             $entry->{cb}->($res) if $entry->{cb};
101             }
102             elsif ( $entry->{type} eq 'scrape' ) {
103             my $res = $self->parse_scrape_response( $data, $entry->{num_hashes} );
104             $entry->{cb}->($res) if $entry->{cb};
105             }
106             }
107             catch ($e) {
108             $self->_emit( log => "Error parsing UDP tracker response: $e", level => 'error' );
109             }
110             }
111              
112             method _send_packet ($payload) {
113             return unless $socket;
114             my $dest = sockaddr_in( $port, inet_aton($host) );
115             $socket->send( $payload, 0, $dest );
116             }
117              
118             method build_connect_packet () {
119             my $tid = $self->_new_transaction_id();
120             if (HAS_64BIT) {
121 21     21   185 no warnings 'portable';
  21         41  
  21         36087  
122             return ( $tid, pack( 'Q> N N', 0x41727101980, 0, $tid ) );
123             }
124             return ( $tid, pack( 'NN N N', 0x417, 0x27101980, 0, $tid ) );
125             }
126              
127             method perform_announce ( $params, $cb = undef ) {
128             if ( !$self->_is_connected() ) {
129             my ( $tid, $pkt ) = $self->build_connect_packet();
130             $pending_transactions{$tid} = {
131             type => 'connect',
132             payload => $pkt,
133             retries => 0,
134             timestamp => time(),
135 0     0   0 on_connect => sub { $self->perform_announce( $params, $cb ) },
136             };
137             $self->_send_packet($pkt);
138             return;
139             }
140             my $pkt = $self->build_announce_packet($params);
141             return unless $pkt;
142             my ($tid) = unpack( 'x8 N', $pkt ); # transaction_id is at offset 12 but after action(4)
143              
144             # Wait, action(4) tid(4). So offset 12 is correct for cid(8) + action(4).
145             $tid = unpack( 'N', substr( $pkt, 12, 4 ) );
146             $pending_transactions{$tid} = { type => 'announce', payload => $pkt, retries => 0, timestamp => time(), cb => $cb, };
147             $self->_send_packet($pkt);
148             }
149              
150             method perform_scrape ( $infohashes, $cb = undef ) {
151             if ( !$self->_is_connected() ) {
152             my ( $tid, $pkt ) = $self->build_connect_packet();
153             $pending_transactions{$tid} = {
154             type => 'connect',
155             payload => $pkt,
156             retries => 0,
157             timestamp => time(),
158 0     0   0 on_connect => sub { $self->perform_scrape( $infohashes, $cb ) },
159             };
160             $self->_send_packet($pkt);
161             return;
162             }
163             my $pkt = $self->build_scrape_packet($infohashes);
164             my $tid = unpack( 'N', substr( $pkt, 12, 4 ) );
165             $pending_transactions{$tid}
166             = { type => 'scrape', payload => $pkt, retries => 0, timestamp => time(), cb => $cb, num_hashes => scalar @$infohashes, };
167             $self->_send_packet($pkt);
168             }
169              
170             method build_announce_packet ($params) {
171             $self->_new_transaction_id();
172             my %event_map = ( none => 0, completed => 1, started => 2, stopped => 3, );
173             my $event = $event_map{ $params->{event} // 'none' } // 0;
174             my $ih = $params->{info_hash};
175             my $ih_len = length($ih);
176              
177             # Mandatory key for tracker identification
178             my $key = $params->{key} // int( rand( 2**31 ) );
179              
180             # BEP 52: Support 32-byte infohashes
181             # For UDP trackers, we use the v1 infohash if available,
182             # or truncate/hash the v2 one as per common practice if 32 bytes provided.
183             # REAL BEP 52 UDP trackers expect a modified layout, but standard ones
184             # usually get the 20-byte 'info_hash' (v1 or truncated).
185             my $ih_20 = length($ih) == 32 ? sha1($ih) : $ih;
186             my $tmpl = HAS_64BIT ? 'Q> N N a20 a20 Q> Q> Q> N N N l> n' : 'a8 N N a20 a20 NN NN NN N N N l> n';
187             return pack(
188             $tmpl, $connection_id, 1, $transaction_id, $ih_20, $params->{peer_id}, _split64( $params->{downloaded} // 0 ),
189             _split64( $params->{left} // 0 ), _split64( $params->{uploaded} // 0 ), $event, 0, # ip
190             $key, $params->{num_want} // -1, $params->{port}
191             );
192             }
193              
194             method parse_announce_response ($data) {
195             my ( $action, $tid, $interval, $leechers, $seeders ) = unpack( 'N N N N N', $data );
196             my $peers_raw = substr( $data, 20 );
197             my $peers;
198             if ( length($peers_raw) % 18 == 0 && length($peers_raw) % 6 != 0 ) {
199             $peers = Net::BitTorrent::Protocol::BEP23::unpack_peers_ipv6($peers_raw);
200             }
201             else {
202             $peers = Net::BitTorrent::Protocol::BEP23::unpack_peers_ipv4($peers_raw);
203             }
204             return { interval => $interval, leechers => $leechers, seeders => $seeders, peers => $peers, };
205             }
206              
207             method build_scrape_packet ($infohashes) {
208             $self->_new_transaction_id();
209              
210             # Truncate v2 hashes to 20 bytes for scrape as well
211             my $ih_data = join( '', map { length($_) == 32 ? sha1($_) : $_ } @$infohashes );
212             my $tmpl = HAS_64BIT ? 'Q> N N a*' : 'a8 N N a*';
213             return pack( $tmpl, $connection_id, 2, $transaction_id, $ih_data );
214             }
215              
216             method parse_scrape_response ( $data, $num_hashes ) {
217             my ( $action, $tid ) = unpack( 'N N', $data );
218             my $results = { files => [] };
219             for ( my $i = 0; $i < $num_hashes; $i++ ) {
220             my ( $seeders, $completed, $leechers ) = unpack( 'N N N', substr( $data, 8 + ( $i * 12 ), 12 ) );
221             push @{ $results->{files} }, { seeders => $seeders, completed => $completed, leechers => $leechers };
222             }
223             return $results;
224             }
225             } 1;