File Coverage

lib/Net/Multicast/PeerDiscovery.pm
Criterion Covered Total %
statement 21 28 75.0
branch 1 2 50.0
condition 1 5 20.0
subroutine 8 8 100.0
pod n/a
total 31 43 72.0


line stmt bran cond sub pod time code
1 1     1   238401 use v5.40;
  1         4  
2 1     1   10 use feature 'class';
  1         1  
  1         207  
3 1     1   10 no warnings 'experimental::class';
  1         2  
  1         160  
4             #
5             class Net::Multicast::PeerDiscovery v1.0.0 {
6 1         373 use Socket qw[
7             pack_sockaddr_in unpack_sockaddr_in inet_aton inet_ntoa
8             pack_sockaddr_in6 unpack_sockaddr_in6 inet_pton inet_ntop
9             AF_INET AF_INET6 sockaddr_family
10 1     1   8 ];
  1         2  
11 1     1   629 use IO::Select;
  1         2087  
  1         69  
12 1     1   9 use Carp qw[carp croak];
  1         1  
  1         205  
13             #
14             BEGIN {
15             # Polyfill for systems where Socket::pack_sockaddr_in6 is "not implemented" (e.g. some Windows runners)
16             # or missing entirely.
17 1 50 33 1   11 if ( !defined &pack_sockaddr_in6 || !eval { pack_sockaddr_in6( 0, "\0" x 16 ); 1 } ) {
18 1     1   8 no warnings 'redefine';
  1         2  
  1         327  
19             *pack_sockaddr_in6 = sub ( $port, $ip, $scope_id = 0, $flowinfo = 0 ) {
20 0   0       my $family = eval { AF_INET6() } // 23; # Default to 23 (Win32) if missing, though risky.
  0            
21 0           return pack( 'S n N a16 I', $family, $port, $flowinfo, $ip, $scope_id );
22 0           };
23             *unpack_sockaddr_in6 = sub ($packed) {
24 0           my ( $family, $port, $flowinfo, $ip, $scope_id ) = unpack( 'S n N a16 I', $packed );
25 0           return ( $port, $ip, $scope_id, $flowinfo );
26 0           };
27             }
28             }
29             #
30             field $port : param //= 6771;
31             field $domain : param //= undef;
32             field $socket;
33             field %on;
34             field $available : reader(is_available) = 0;
35             #
36             # BEP 14 Multicast group and port
37             my $MCAST_ADDR4 = '239.192.152.143';
38             my $MCAST_ADDR6 = 'ff15::efc0:988f';
39             my $MCAST_PORT = 6771;
40             #
41             ADJUST {
42             try {
43             require IO::Socket::Multicast;
44              
45             # Use provided domain or try to auto-select
46             if ( defined $domain ) {
47             $socket = IO::Socket::Multicast->new( LocalPort => $MCAST_PORT, Proto => 'udp', ReuseAddr => 1, Domain => $domain ) or die $!;
48             }
49             else {
50             $socket = IO::Socket::Multicast->new( LocalPort => $MCAST_PORT, Proto => 'udp', ReuseAddr => 1, Domain => AF_INET6 )
51             // IO::Socket::Multicast->new( LocalPort => $MCAST_PORT, Proto => 'udp', ReuseAddr => 1, Domain => AF_INET );
52             }
53             die "Could not create discovery socket: $!" unless $socket;
54             $socket->mcast_add($MCAST_ADDR4) if $socket->sockdomain == AF_INET // $socket->sockdomain == AF_INET6;
55             $socket->mcast_add($MCAST_ADDR6) if $socket->sockdomain == AF_INET6;
56             $available = 1;
57             }
58             catch ($e) {
59             carp "IO::Socket::Multicast not available. Disabled: $e";
60             require IO::Socket::IP;
61             $socket = IO::Socket::IP->new( Proto => 'udp', LocalPort => 0 );
62             }
63             }
64             method on ( $event, $cb ) { push $on{$event}->@*, $cb }
65              
66             method _emit ( $event, @args ) {
67             for my $cb ( $on{$event}->@* ) {
68             try { $cb->(@args) } catch ($e) {
69             carp "Discovery callback for $event failed: $e"
70             }
71             }
72             }
73              
74             method announce ( $info_hash, $bt_port ) {
75             return unless $available;
76             my $ih_hex = unpack( 'H*', $info_hash );
77             my $msg = "BT-SEARCH * HTTP/1.1\r\n" . "Host: %s:%d\r\n" . "Port: $bt_port\r\n" . "Infohash: $ih_hex\r\n" . "\r\n\r\n";
78              
79             # Announce to IPv4
80             $socket->mcast_send( sprintf( $msg, $MCAST_ADDR4, $MCAST_PORT ), "$MCAST_ADDR4:$MCAST_PORT" );
81              
82             # Announce to IPv6
83             $socket->mcast_send( sprintf( $msg, "[$MCAST_ADDR6]", $MCAST_PORT ), "[$MCAST_ADDR6]:$MCAST_PORT" ) if $socket->sockdomain == AF_INET6;
84             }
85              
86             method tick ( $timeout //= 0 ) {
87             return unless $available;
88             my $sel = IO::Select->new($socket);
89             while ( $sel->can_read($timeout) ) {
90             my $sender = $socket->recv( my $data, 1024 );
91             $self->_handle_packet( $data, $sender ) if defined $data;
92             last if $timeout == 0;
93             }
94             }
95              
96             method _handle_packet ( $data, $sender ) {
97             if ( $data =~ /^BT-SEARCH/i ) {
98             my ($port) = $data =~ /^Port:\s*(\d+)/mi;
99             my ($ih_hex) = $data =~ /^Infohash:\s*([a-fA-F0-9]+)/mi;
100             if ( $port && $ih_hex ) {
101             my $family = sockaddr_family($sender);
102             my ( $ip, $scope_id );
103             if ( $family == AF_INET ) {
104             ( undef, my $ip_bin ) = unpack_sockaddr_in($sender);
105             $ip = inet_ntoa($ip_bin);
106             }
107             elsif ( $family == AF_INET6 ) {
108             ( undef, my $ip_bin, $scope_id ) = unpack_sockaddr_in6($sender);
109             $ip = inet_ntop( AF_INET6, $ip_bin );
110              
111             # RFC 6724 / IPv6 Link-Local scope handling
112             $ip .= '%' . $scope_id if $ip =~ /^fe80:/i && $scope_id;
113             }
114             $self->_emit( 'peer_found', { ip => $ip, port => $port, info_hash => pack( 'H*', $ih_hex ) } ) if $ip;
115             }
116             }
117             }
118             };
119             1;