File Coverage

blib/lib/IPsonar.pm
Criterion Covered Total %
statement 16 18 88.8
branch n/a
condition n/a
subroutine 6 6 100.0
pod n/a
total 22 24 91.6


line stmt bran cond sub pod time code
1             package IPsonar;
2              
3 1     1   20196 use strict;
  1         1  
  1         32  
4 1     1   4 use warnings;
  1         2  
  1         22  
5              
6 1     1   753 use version;
  1         1980  
  1         6  
7             our $VERSION;
8             $VERSION = "0.31";
9              
10 1     1   18734 use Net::SSLeay qw(make_headers get_https);
  1         26056  
  1         856  
11 1     1   3668 use URI;
  1         13847  
  1         36  
12 1     1   465 use XML::Simple qw(:strict);
  0            
  0            
13             use Data::Dumper;
14             use MIME::Base64;
15             use LWP::UserAgent;
16             use Carp;
17             use constant {
18             HTTPS_TCP_PORT => 443,
19             DEFAULT_PAGE_SIZE => 100,
20             };
21              
22             # "IPsonar" and "Lumeta" are both registered marks of the Lumeta Corporation.
23              
24             =head1 NAME
25              
26             IPsonar - Wrapper to interact with the Lumeta IPsonar API
27              
28             =head1 VERSION
29              
30             Version 0.31
31             (Mercurial Revision ID: 8cc8c5b56c62+)
32              
33             =cut
34              
35             =head1 SYNOPSIS
36              
37             This module wraps the IPsonar RESTful API.
38             It handles the paging and https stuff so you can concentrate on extracting
39             information from reports.
40              
41             "Lumeta" and "IPsonar" are both registered trademarks of the Lumeta Coporation
42              
43             =head1 EXAMPLE
44              
45             # Script to get all the IP address for all the devices that have
46             # port 23 open:
47              
48             my $rsn = IPsonar->new('rsn_address_or_name','username','password');
49             my $test_report = 23;
50              
51             my $results = $rsn->query('detail.devices',
52             {
53             'q.f.report.id' => $test_report,
54             'q.f.servicediscovery.ports' => 23
55             }) or die "Problem ".$rsn->error;
56              
57             while (my $x = $rsn->next_result) {
58             print "IP: $x->{ip}\n";
59             }
60              
61              
62             =head1 CONSTRUCTORS
63              
64             =cut
65              
66             #-----------------------------------------------------------
67             # new(rsn, username, password)
68              
69             =over 8
70              
71             =item B
72              
73             =back
74              
75             Setup a connection to a report server using username / password
76             Note: This doesn't actually initiate a connection until you issue
77             a query. The I can either be a hostname or IP address. The I
78             and I are for one of the GUI users.
79              
80             =cut
81              
82             sub new {
83             my $class = shift;
84             my $rsn = shift;
85             my $username = shift;
86             my $password = shift;
87             my $self = {};
88             $self->{request} = sub { #request(query, parameters)
89             my $query = shift;
90             my $params = shift;
91             _request_using_password( $rsn, $query, $params, $username, $password );
92             };
93             bless $self, $class;
94             return $self;
95             }
96              
97             #-----------------------------------------------------------
98              
99             =over 8
100              
101             =item B
102              
103             =back
104              
105             Setup a connection to a report server using SSL certificate
106             Note: This doesn't actually initiate a connection until you issue
107             a query. The I can either be a hostname or IP address. The
108             I is the password required to unlock your certificate (as required).
109              
110             =cut
111              
112             sub new_with_cert {
113             my $class = shift;
114             my $self = {};
115             my $rsn = shift;
116             my $cert_path = shift;
117             my $password = shift;
118             $self->{request} = sub { #request(query, parameters)
119             my $query = shift;
120             my $params = shift;
121             _request_using_certificate( $rsn, $query, $params, $cert_path,
122             $password );
123             };
124             bless $self, $class;
125             return $self;
126             }
127              
128             # This function is to allow us to run unit tests without having
129             # access to a live RSN or the exact same reports I originally tested
130             # against. We're reading from a file with pre-canned requests and data.
131             sub _new_with_file { # _new_with_file(file)
132             my $class = shift;
133             my $file = shift;
134             my $self = {};
135             $self->{pages} = {};
136              
137             # Fill in $self->{pages}
138             open my $testfile, "<", $file or croak "Couldn't open $file";
139             my $page = q{};
140             my $request;
141             while (<$testfile>) {
142             if (/^URL: (.*)$/) {
143             $self->{pages}->{$request} = $page if $page;
144             $request = _normalize_path($1);
145             $page = q{};
146             }
147             else {
148             $page .= $_;
149             }
150             }
151             $self->{pages}->{$request} = $page;
152              
153             $self->{request} = sub { #request(query, parameters)
154             my $query = shift;
155             my $params = shift;
156             my $path = _normalize_path( _get_path( $query, $params ) );
157             return $self->{pages}->{"$path"}
158             || croak "Couldn't find $path in file";
159             };
160             bless $self, $class;
161             return $self;
162             }
163              
164             #-----------------------------------------------------------
165             # Normalize path exists to force the path we're looking for into
166             # a specific order. This fixes the issue I ran into at in perl 5.18
167             # where hash order is now effectively random (which is a generally
168             # a good thing but causes my code to fail)
169             #
170             # /reporting/api/service/detail.devices?q.f.report.id=23&q.f.servicediscovery.ports=2300&fmt=xml&q.pageSize=100&q.page=0 should become
171             # /reporting/api/service/detail.devices?fmt=xml&q.f.report.id=23&q.f.servicediscovery.ports=2300&q.pageSize=100&q.page=0
172             sub _normalize_path {
173             my $path = shift;
174             my ( $start, $rest ) = $path =~ /^(.+)\?(.*)$/;
175             my @params = split /&/, $rest;
176             return $start . '?' . join( '&', sort(@params) );
177             }
178              
179             #-----------------------------------------------------------
180              
181             =over 8
182              
183             =item B
184              
185             =back
186              
187             Setup a connection to the report server you're on
188              
189             =cut
190              
191             sub new_localhost {
192             my $class = shift;
193             my $self = {};
194             $self->{request} = sub { #request(query, parameters)
195             my $query = shift;
196             my $params = shift;
197             _request_localhost( $query, $params );
198             };
199             bless $self, $class;
200             return $self;
201             }
202              
203             #-----------------------------------------------------------
204              
205             =head1 METHODS
206              
207             =over 8
208              
209             =item B<$rsn-Equery ( method, hashref_of_parameters)>
210              
211             =back
212              
213             Issue a query (get results for non-paged queries).
214             If you're getting back paged data we'll return the number of items
215             available in the query. If we're getting back a single result we
216             return a hashref to those results.
217              
218             If the query fails we'll leave the reason in $rsn->error
219              
220             =cut
221              
222             sub query {
223             my $self = shift;
224             $self->{query} = shift;
225             $self->{params} = shift;
226              
227             # Set default parameters (over-riding fmt, it must be XML).
228             $self->{params}->{'q.page'} ||= 0;
229              
230             if ( !defined( $self->{params}->{'q.pageSize'} ) ) {
231             $self->{params}->{'q.pageSize'} = DEFAULT_PAGE_SIZE;
232             }
233              
234             $self->{params}->{fmt} = 'xml';
235              
236             #-----------------------------------------------------------
237             # instance variables
238             #
239             # total The total number of items we could iterate over
240             # request A funcref to the underlying function that gets
241             # our XML back from the server. It's a funcref
242             # because it can either be password or PKI authentication
243             # query The API call we're making (e.g. "config.reports"
244             # params The API parameters we're passing
245             # error The XML error we got back from IPsonar (if any)
246             # page_size The number of items we expect per page
247             # paged Is the result paged (or are we getting a single value)
248             # max_page Maximum page we'll be able to retrieve
249             # max_row Maximum row on this page (0-n).
250             #-----------------------------------------------------------
251              
252             my $res = $self->{request}( $self->{query}, $self->{params} );
253              
254             # KeyAttr => [] because otherwise XML::Simple tries to be clever
255             # and hand back a hashref keyed on "id" or "name" instead of an
256             # arrayref of items.
257             my $xml = XMLin( $res, KeyAttr => [], ForceArray => [] );
258              
259             if ( $xml->{status} ne 'SUCCESS' ) {
260              
261             print Dumper($xml) . "\n";
262             $self->{error} = $xml->{error}->{detail};
263             croak $self->{error};
264             }
265              
266             $self->{xml} = $xml;
267             $self->{page_row} = 0;
268              
269             if ( defined( $xml->{total} ) and $xml->{total} == 0 ) {
270             $self->{total} = 0;
271             $self->{paged} = 1;
272             return $xml;
273             }
274              
275             if ( $xml->{total} && $self->{params}->{'q.pageSize'} ) { # Paged Data
276             $self->{total} = $xml->{total};
277             my $page_size = $self->{params}->{'q.pageSize'};
278              
279             # Figure out what the key to the array data is
280             my $temp = XMLin( $res, NoAttr => 1, KeyAttr => [], ForceArray => 1 );
281             my $key = ( keys %{$temp} )[0];
282             $self->{pagedata} = $self->{xml}->{$key};
283             warn "Key = $key, Self = " . Dumper($self) if !$self->{xml}->{$key};
284              
285             # Setup paging information
286             #TODO this is a honking mess, too many special conditions.
287             $self->{paged} = 1;
288             $self->{max_page} = int( ( $self->{total} - 1 ) / $page_size );
289              
290             $self->{max_row} =
291             $self->{params}->{'q.page'} < $self->{max_page}
292             ? $page_size - 1
293             : ( ( $self->{total} % $page_size ) || $page_size ) - 1;
294              
295             # There's only one page with $self->{total} items
296             if ( $self->{params}->{'q.pageSize'} == $self->{total} ) {
297             $self->{max_row} = $self->{total} - 1;
298             }
299              
300             # We're looking at things with pagesize 1
301             if ( $self->{params}->{'q.pageSize'} == 1 ) {
302             $self->{max_row} = 0;
303             }
304              
305             return $self->{total};
306             }
307             else { # Not paged data
308             $self->{total} = 0;
309             $self->{paged} = 0;
310             delete( $self->{key} );
311             return $xml;
312             }
313             }
314              
315             #-----------------------------------------------------------
316              
317             =over 8
318              
319             =item B<$rsn-Enext_result ()>
320              
321             =back
322              
323             Get next paged results as a hashref. Returns 0 when we've got no more
324             results.
325              
326             Note: Currently, we always return a hashref to the same (only) non-paged
327             results.
328              
329             =cut
330              
331             sub next_result {
332             my $self = shift;
333              
334             #print "page_row: $self->{page_row}, max_row: $self->{max_row}, ".
335             # "page: $self->{params}->{'q.page'}, max_page: $self->{max_page}\n";
336              
337             #No results
338             return 0 if $self->{total} == 0 && $self->{paged};
339              
340             #Not paged data
341             return $self->{xml} if !$self->{paged};
342              
343             #End of Data
344             if (
345             $self->{params}->{'q.page'} == $self->{max_page}
346             && ( $self->{page_row} > $self->{max_row}
347             || !$self->{pagedata}[ $self->{page_row} ] )
348             )
349             {
350             return;
351             }
352              
353             #End of Page
354             # The pagedata of this test handles cases where IPsonar doesn't
355             # return all the rows it's supposed to (rare, but it happens)
356             if (
357             $self->{page_row} > $self->{max_row}
358             || ( ref( $self->{pagedata} ) eq 'ARRAY'
359             && !$self->{pagedata}[ $self->{page_row} ] )
360             )
361             {
362             $self->{params}->{'q.page'}++;
363             $self->query( $self->{query}, $self->{params} );
364             }
365              
366             #Single item on last page
367             if ( $self->{page_row} == 0 and $self->{max_row} == 0 ) {
368             $self->{page_row}++;
369             return $self->{pagedata};
370             }
371              
372             return $self->{pagedata}[ $self->{page_row}++ ];
373              
374             }
375              
376             #-----------------------------------------------------------
377              
378             =over 8
379              
380             =item B<$rsn-Eerror>
381              
382             =back
383              
384             Get error information
385              
386             =cut
387              
388             sub error {
389             my $self = shift;
390             return $self->{error};
391             }
392              
393             #===========================================================
394             # From API cookbook
395             ###
396              
397             ### These can already be defined in your environment, or you can get
398             ### them from the user on the command line or from stdin. It's
399             ### probably best to get the password from stdin.
400              
401             ###
402             ### Routine to run a query using authentication via PKI certificate
403             ### Inputs:
404             ### server - the IPsonar report server
405             ### method - the method or query name, e.g., getReports
406             ### params - reference to a hash of parameter name / value pairs
407             ### Output:
408             ### The page in XML format returned by IPsonar
409             ###
410             sub _request_using_certificate {
411             my ( $server, $method, $params, $cert, $passwd ) = @_;
412              
413             my $path = _get_path( $method, $params ); # See "Constructing URLs";
414             my $url = "https://${server}${path}";
415              
416             local $ENV{HTTPS_PKCS12_FILE} = $cert;
417             local $ENV{HTTPS_PKCS12_PASSWORD} = $passwd;
418             my $ua = LWP::UserAgent->new;
419             my $req = HTTP::Request->new( 'GET', $url );
420             my $res = $ua->request($req);
421              
422             return $res->content;
423             }
424              
425             #===========================================================
426             # From API cookbook
427             ###
428             ### Routine to run a query using authentication via user name and password.
429             ### Inputs:
430             ### server - the IPsonar report server
431             ### method - the method or query name, e.g., initiateScan
432             ### params - reference to a hash of parameter name / value pairs
433             ### uname - the IPsonar user name
434             ### passwd - the IPsonar user's password
435             ### Output:
436             ### The page in XML format returned by IPsonar
437             ###
438             sub _request_using_password {
439             my ( $server, $method, $params, $uname, $passwd ) = @_;
440             my $port = HTTPS_TCP_PORT; # The usual port for https
441             my $path = _get_path( $method, $params ); # See "Constructing URLs"
442             #print "URL: https://$server$path\n";
443              
444             my $authstring = MIME::Base64::encode( "$uname:$passwd", q() );
445             my ( $page, $result, %headers ) = # we're only interested in $page
446             Net::SSLeay::get_https( $server, $port, $path,
447             Net::SSLeay::make_headers( Authorization => 'Basic ' . $authstring ) );
448             if ( !( $result =~ /OK$/ ) ) {
449             croak $result;
450             }
451             return ($page);
452             }
453              
454             sub _request_localhost {
455             my ( $method, $params ) = @_;
456             my $path = _get_path( $method, $params ); # See "Constructing URLs"
457             my $url = "http://127.0.0.1:8081${path}";
458              
459             my $ua = LWP::UserAgent->new;
460             my $req = HTTP::Request->new( 'GET', $url );
461             my $res = $ua->request($req);
462              
463             return $res->content;
464             }
465              
466             #===========================================================
467             # From API cookbook
468             ###
469             ### Routine to encode the path part of an API call's URL. The
470             ### path is everything after "https://server".
471             ### Inputs:
472             ### method - the method or query name, e.g., initiateScan
473             ### params - reference to a hash of parameter name /value pairs
474             ### Output:
475             ### The query path with the special characters properly encoded
476             ###
477             sub _get_path {
478             my ( $method, $params ) = @_;
479             my $path_start = '/reporting/api/service/';
480             my $path = $path_start . $method . q(?); # all API calls start this way
481             # Now add parameters
482             if ( defined $params ) {
483             while ( my ( $p, $v ) = each %{$params} ) {
484             if ( $path !~ /[?]$/xms ) { # ... if this isn't the first param
485             $path .= q(&); # params are separated by &
486             }
487             $path .= "$p=$v";
488             }
489             }
490             my $encoded = URI->new($path); # encode the illegal characters
491             # (eg, space => %20)
492             return ( $encoded->as_string );
493             }
494              
495             #-----------------------------------------------------------
496              
497             =head1 METHODS
498              
499             =over 8
500              
501             =item B<$rsn-Ereports ()>
502              
503             =back
504              
505             Returns an array representing the reports on this RSN.
506             This array is sorted by ascending report id.
507             Do not run this while you're iterating through another query as
508             it will reset its internal state.
509             Timestamps are converted to epoch time.
510              
511             An example of how you might use this:
512              
513             #!/usr/bin/perl
514              
515             use strict;
516             use warnings;
517              
518             use IPsonar;
519              
520             my $rsn = IPsonar->new('s2','username','password');
521             my @reports = $rsn->reports;
522              
523              
524             =cut
525              
526             sub reports {
527             my $self = shift;
528             my @reports;
529             my $results = $self->query( 'config.reports', {} )
530             or croak "Problem " . $self->error;
531              
532             while ( my $r = $self->next_result ) {
533             $r->{timestamp} = int( $r->{timestamp} / 1000 );
534             push @reports, $r;
535             }
536              
537             @reports = sort { $a->{id} <=> $b->{id} } @reports;
538             return @reports;
539             }
540             1;
541              
542             =head1 USAGE
543              
544             The way I've settled on using this is to build the query I want using
545             the built-in IPsonar query builder. Once I've got that fine tuned I
546             translate the url into a query.
547              
548             For example, if I build a query to get all the routers from report 49
549             (showing port information), I'd wind up with the following URL:
550              
551             https://s2/reporting/api/service/detail.devices?fmt=xml&q.page=0&q.pageSize=100&q.details=Ports&q.f.report&q.f.report.id=49&q.f.router&q.f.router.router=true
552              
553             This module takes care of the I, I, and I parameters
554             for you (you can specify I if you really want).
555             I might translate that into the following code:
556              
557             #!/usr/bin/perl
558              
559             use strict;
560             use warnings;
561              
562             use IPsonar;
563             use Data::Dumper;
564              
565             my $rsn = IPsonar->new('s2','username','password');
566              
567             my $results = $rsn->query('detail.devices',
568             {
569             'q.details' => 'Ports',
570             'q.f.report.id' => 49,
571             'q.f.router.router' => 'true',
572             }) or die "Problem ".$rsn->error;
573              
574             while ( my $x = $rsn->next_result ) {
575             print Dumper($x);
576             my $ports = $x->{ports}->{closedPorts}->{integer};
577             print ref($ports) eq 'ARRAY' ? join ',' , @{$ports} : $ports ;
578             }
579              
580             And get this as a result:
581              
582             $VAR1 = {
583             'ports' => {
584             'openPorts' => {
585             'integer' => '23'
586             },
587             'closedPorts' => {
588             'integer' => [
589             '21',
590             '22',
591             '25',
592             ]
593             }
594             },
595             'ip' => '10.2.0.2'
596             };
597             21,23,25
598              
599             Note that things like ports might come back as an Arrayref or might
600             come back as a single item. I find there's some tweaking involved as you
601             figure out how the data is laid out.
602              
603             =cut