File Coverage

blib/lib/Acme/UPnP.pm
Criterion Covered Total %
statement 23 23 100.0
branch n/a
condition n/a
subroutine 8 8 100.0
pod n/a
total 31 31 100.0


line stmt bran cond sub pod time code
1 1     1   159300 use v5.40;
  1         3  
2 1     1   5 use feature 'class';
  1         1  
  1         88  
3 1     1   3 no warnings 'experimental::class';
  1         1  
  1         72  
4             #
5             class Acme::UPnP v1.0.0 {
6 1     1   8 use Carp qw[carp croak];
  1         1  
  1         75  
7 1     1   462 use IO::Socket::INET;
  1         20235  
  1         7  
8 1     1   1276 use HTTP::Tiny;
  1         29583  
  1         59  
9 1     1   9 use Time::HiRes qw[time];
  1         6  
  1         9  
10 1     1   75 use Socket qw[inet_aton pack_sockaddr_in];
  1         2  
  1         3027  
11             #
12             field $control_url;
13             field $service_type;
14             field %on;
15             field $http;
16             field $upnp_available : reader(is_available) = 1;
17             field $upnp_device : reader; # For compatibility, just holds a dummy object or undef
18              
19             #
20             method on ( $event, $cb ) { push $on{$event}->@*, $cb }
21              
22             method _emit ( $event, @args ) {
23             for my $cb ( $on{$event}->@* ) {
24             try { $cb->(@args) } catch ($e) {
25             carp 'Acme::UPnP callback error: ' . $e;
26             }
27             }
28             }
29             ADJUST {
30             $http = HTTP::Tiny->new( agent => 'Acme-UPnP/1.0', timeout => 3 );
31             $upnp_device = bless {}, 'Acme::UPnP::Device'; # Dummy
32             }
33              
34             method discover_device () {
35              
36             # SSDP Search
37             my $sock = IO::Socket::INET->new( Proto => 'udp', Broadcast => 1, LocalPort => 0, ) or
38             do { carp 'Failed to create UDP socket: ' . $!; return 0 };
39             my $msg = join "\r\n", 'M-SEARCH * HTTP/1.1', 'HOST: 239.255.255.250:1900', 'MAN: "ssdp:discover"', 'MX: 2',
40             'ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1', '';
41             $sock->send( $msg, 0, pack_sockaddr_in( 1900, inet_aton('239.255.255.250') ) );
42             my $rin = '';
43             vec( $rin, $sock->fileno, 1 ) = 1;
44             my $rout;
45             my $found_location;
46             my $end_time = time + 2.5;
47              
48             while ( time < $end_time ) {
49             my $left = $end_time - time;
50             last if $left <= 0;
51             if ( select( $rout = $rin, undef, undef, $left ) ) {
52             my $data;
53             my $addr = $sock->recv( $data, 4096 );
54             if ( defined $data && $data =~ /Location:\s*(https?:\/\/[^\s\r\n]+)/i ) {
55             $found_location = $1;
56             last;
57             }
58             }
59             else {
60             last;
61             }
62             }
63             unless ($found_location) {
64             $self->_emit('device_not_found');
65             return 0;
66             }
67              
68             # Fetch Description
69             my $res = $http->get($found_location);
70             unless ( $res->{success} ) {
71             $self->_emit( device_not_found => 'Failed to fetch description' );
72             return 0;
73             }
74             my $content = $res->{content};
75              
76             # Parse for Service
77             my $svc_type;
78             my $ctrl_url;
79              
80             # Simple regex extraction
81             while ( $content =~ m[(.*?)]sg ) {
82             my $svc_block = $1;
83             if ( $svc_block =~ m[(urn:schemas-upnp-org:service:WAN(?:IP|PPP)Connection:1)]s ) {
84             $svc_type = $1;
85             if ( $svc_block =~ m[(.*?)]s ) {
86             $ctrl_url = $1;
87             last;
88             }
89             }
90             }
91             unless ($ctrl_url) {
92             $self->_emit( device_not_found => "No valid WANIP/PPP service" );
93             return 0;
94             }
95              
96             # Handle URL resolution
97             if ( $ctrl_url !~ /^http/ ) {
98             if ( $ctrl_url =~ m{^/} ) {
99             if ( $found_location =~ m[^(https?:\/\/[^\/]+)] ) {
100             $ctrl_url = $1 . $ctrl_url;
101             }
102             }
103             else {
104             # Base URL?
105             if ( $content =~ m[(.*?)]s ) {
106             my $base = $1;
107             $base =~ s/\/$//; # strip trailing slash
108             $ctrl_url = "$base/$ctrl_url";
109             }
110             else {
111             # Relative to location
112             my $base = $found_location;
113             $base =~ s/[^\/]+$//; # remove filename
114             $ctrl_url = $base . $ctrl_url;
115             }
116             }
117             }
118             $control_url = $ctrl_url;
119             $service_type = $svc_type;
120             $self->_emit( device_found => { name => 'UPnP Gateway' } );
121             return 1;
122             }
123              
124             method map_port ( $int_port, $ext_port, $proto, $desc ) {
125             return 0 unless $control_url;
126             my $local_ip = $self->_get_local_ip();
127             my $args = {
128             NewRemoteHost => '',
129             NewExternalPort => $ext_port,
130             NewProtocol => $proto,
131             NewInternalPort => $int_port,
132             NewInternalClient => $local_ip,
133             NewEnabled => 1,
134             NewPortMappingDescription => $desc,
135             NewLeaseDuration => 0
136             };
137             if ( $self->_send_soap( AddPortMapping => $args ) ) {
138             $self->_emit( map_success => { int_p => $int_port, ext_p => $ext_port, proto => $proto, desc => $desc } );
139             return 1;
140             }
141             else {
142             $self->_emit( map_failed => { err_c => 500, err_d => 'SOAP Failed' } );
143             return 0;
144             }
145             }
146              
147             method unmap_port ( $ext_port, $proto ) {
148             return 0 unless $control_url;
149             my $args = { NewRemoteHost => '', NewExternalPort => $ext_port, NewProtocol => $proto };
150             if ( $self->_send_soap( DeletePortMapping => $args ) ) {
151             $self->_emit( unmap_success => { ext_p => $ext_port, proto => $proto } );
152             return 1;
153             }
154             $self->_emit( unmap_failed => { err_c => 500, err_d => 'SOAP Failed' } );
155             return 0;
156             }
157              
158             method get_external_ip () {
159             return undef unless $control_url;
160             my $action = 'GetExternalIPAddress';
161             my $res = $self->_send_soap_response( $action, {} );
162             return $1 if $res && $res =~ m{(.*?)}s;
163             return undef;
164             }
165              
166             method _send_soap ( $action, $args ) {
167             return defined $self->_send_soap_response( $action, $args );
168             }
169              
170             method _send_soap_response ( $action, $args ) {
171             my $body = <<~END;
172            
173            
174            
175            
176             END
177             for my $k ( keys %$args ) {
178             $body .= "<$k>" . $args->{$k} . "\n";
179             }
180             $body .= <<~END;
181            
182            
183            
184             END
185             my $res = $http->post( $control_url,
186             { headers => { 'Content-Type' => 'text/xml; charset="utf-8"', 'SOAPAction' => "\"$service_type#$action\"" }, content => $body } );
187             return $res->{success} ? $res->{content} : undef;
188             }
189              
190             method _get_local_ip () {
191             my $sock = IO::Socket::INET->new( Proto => 'udp', PeerAddr => '192.168.1.1', PeerPort => '1' );
192             if ($sock) {
193             my $addr = $sock->sockhost;
194             return $addr;
195             }
196             '127.0.0.1';
197             }
198             };
199             #
200             1;