File Coverage

blib/lib/App/ipinfo.pm
Criterion Covered Total %
statement 250 276 90.5
branch 22 38 57.8
condition 26 32 81.2
subroutine 45 47 95.7
pod 19 19 100.0
total 362 412 87.8


line stmt bran cond sub pod time code
1             #!perl
2 9     9   2944683 use utf8;
  9         1389  
  9         56  
3 9     9   347 use v5.20;
  9         30  
4 9     9   81 use strict;
  9         15  
  9         262  
5 9     9   2641 use open qw(:std :utf8);
  9         6038  
  9         78  
6              
7 9     9   5868 use experimental qw(signatures);
  9         16239  
  9         50  
8              
9             package App::ipinfo;
10              
11 9     9   2035 use Carp qw(croak);
  9         16  
  9         593  
12 9     9   6913 use Geo::IPinfo;
  9         1044867  
  9         1552  
13 9     9   162 use Encode qw(decode);
  9         20  
  9         1170  
14 9     9   5550 use String::Sprintf;
  9         6030  
  9         17384  
15              
16             our $VERSION = '1.01';
17              
18             __PACKAGE__->run(@ARGV) unless caller();
19              
20             =encoding utf8
21              
22             =head1 NAME
23              
24             App::ipinfo - a command-line tool for IPinfo.io
25              
26             =head1 SYNOPSIS
27              
28             Call it as the program:
29              
30             % ipinfo '%c' [ip addresses]
31              
32             Do it all at once:
33              
34             use App::ipinfo;
35             App::ipinfo->run( \%options, @ip_addresses );
36              
37             Control most of it yourself:
38              
39             use App::ipinfo;
40              
41             my $app = App::ipinfo->new(
42             template => '%c',
43             token => ...,
44             );
45              
46             foreach my $ip ( @ip_addresses ) {
47             my $info = $app->get_info($ip);
48             next unless defined $info;
49             $app->output( $app->format($info) );
50             }
51              
52             =head1 DESCRIPTION
53              
54             =head2 Formatting
55              
56             Most of the data provided by IPinfo has an C-style formatting
57             code, and for everything else you can use C<%j> to get JSON that you can
58             format with B for some other tool.
59              
60             =over 4
61              
62             =item * C<%a> - the ASN of the organization
63              
64             =item * C<%c> - the city of the organization
65              
66             =item * C<%C> - the country code of the organization
67              
68             =item * C<%f> - the emoji flag of the country
69              
70             =item * C<%h> - the hostname for the IP address
71              
72             =item * C<%i> - the IP address
73              
74             =item * C<%j> - all the data as JSON, in a UTF-8 decoded string
75              
76             =item * C<%k> - the continent of the organization
77              
78             =item * C<%L> - the latitude of the organization
79              
80             =item * C<%l> - the longitude of the organization
81              
82             =item * C<%n> - the country name of the organization
83              
84             =item * C<%N> - newline
85              
86             =item * C<%o> - the organization name
87              
88             =item * C<%r> - the region of the organization (i.e. state or province)
89              
90             =item * C<%t> - the timezone of the organization (e.g. C )
91              
92             =item * C<%T> - tab
93              
94             =item * C<%%> - literal percent
95              
96             =back
97              
98             =head2 Class methods
99              
100             =over 4
101              
102             =item * new( HASH )
103              
104              
105             Allowed keys:
106              
107             =over 4
108              
109             =item * error_fh
110              
111             The filehandle to send error output to. The default is standard error.
112              
113             =item * template
114              
115             The template.
116              
117             =item * output_fh
118              
119             The filehandle to send error output to. The default is standard output.
120              
121             =item * token
122              
123             The API token from IPinfo.io.
124              
125             =back
126              
127             =cut
128              
129 43     43 1 208146 sub new ($class, %hash) {
  43         88  
  43         109  
  43         70  
130 43         117 state $defaults = {
131             output_fh => $class->default_output_fh,
132             error_fh => $class->default_error_fh,
133             template => $class->default_template,
134             token => $class->get_token,
135             };
136              
137 43         250 my %args = ( $defaults->%*, %hash );
138              
139 43         155 bless \%args, $class;
140             }
141              
142             =item * looks_like_template(STRING)
143              
144             Returns true if STRING looks like a template. That is, it has a C<%>
145             followed by a non-whitespace character. This will get more sophisticated
146             later.
147              
148             =cut
149              
150 0     0 1 0 sub looks_like_template ($either, $string) {
  0         0  
  0         0  
  0         0  
151 0         0 $string =~ m/%\S/;
152             }
153              
154             =item * CLASS->run( [TEMPLATE,] IP_ADDRESS [, IP_ADDRESS ... ] )
155              
156             =item * OBJ->run( [TEMPLATE,] IP_ADDRESS [, IP_ADDRESS ... ] )
157              
158             Format every IP address according to TEMPLATE and send the result to
159             the output filehandle.
160              
161             If the first argument looks like a template (has a C<%>), it is used
162             to format the output. Otherwise, the first argument is taken as the start
163             of the list of IP addresses and the default format is used.
164              
165             If the invocant is not a reference, it's used as the class name to
166             build the object. If the invocant is a reference, it's used as the
167             object. These are the same and use all the default settings:
168              
169             my $obj = App::ipinfo->new;
170             $obj->run( @ip_addresses );
171              
172             App::ipfinfo->run( @ip_addresses );
173              
174             =cut
175              
176 1     1 1 6599 sub run ($either, @args) {
  1         3  
  1         2  
  1         2  
177 1 50       5 my $opts = ref $args[0] eq ref {} ? shift @args : {};
178 1 50       9 my $app = ref $either ? $either : $either->new($opts->%*);
179              
180 1         3 ARG: foreach my $ip (@args) {
181 1         4 my $info = $app->get_info($ip);
182 1 50       26 next ARG unless eval { $info->isa('Geo::Details') };
  1         23  
183 1         5 $app->output( $app->format( $info ) );
184             }
185             }
186              
187             =back
188              
189             =head2 Instance methods
190              
191             =over 4
192              
193             =cut
194              
195             # https://stackoverflow.com/a/45943193/2766176
196             sub _compact_ipv6 {
197             # taken from IPv6::Address on CPAN
198 8     8   119700 my $str = shift;
199 8 100       58 return '::' if($str eq '0:0:0:0:0:0:0:0');
200 7         46 for(my $i=7;$i>1;$i--) {
201 42         174 my $zerostr = join(':',split('','0'x$i));
202             ###print "DEBUG: $str $zerostr \n";
203 42 100       1176 if($str =~ /:$zerostr$/) {
    100          
    100          
204 1         13 $str =~ s/:$zerostr$/::/;
205 1         6 return $str;
206             }
207             elsif ($str =~ /:$zerostr:/) {
208 1         14 $str =~ s/:$zerostr:/::/;
209 1         31 return $str;
210             }
211             elsif ($str =~ /^$zerostr:/) {
212 1         15 $str =~ s/^$zerostr:/::/;
213 1         40 return $str;
214             }
215             }
216 4         16 return $str;
217             }
218              
219             =item * decode_info
220              
221             Fixup some issues in the API response.
222              
223             =cut
224              
225 0     0 1 0 sub decode_info ($app, $info) {
  0         0  
  0         0  
  0         0  
226 0 0       0 return unless defined $info;
227 0         0 my @queue = $info;
228 0 0       0 return $info if $info->meta->{from_cache} == 1;
229              
230 0         0 ITEM: while( my $i = shift @queue ) {
231 0         0 KEY: foreach my $key ( keys $i->%* ) {
232 0 0       0 if( ref $i->{$key} eq ref {} ) {
233 0         0 push @queue, $i->{$key};
234 0         0 next KEY;
235             }
236 0 0       0 next if utf8::is_utf8($i->{$key});
237 0         0 $i->{$key} = decode( 'UTF-8', $i->{$key} );
238             }
239             }
240              
241 0         0 $info->meta->{decoded} = 1;
242             };
243              
244             =item * default_error_fh
245              
246             Returns the default for the error filehandle. In this module, it's
247             standard error.
248              
249             =cut
250              
251 5     5 1 50 sub default_error_fh { \*STDERR }
252              
253             =item * default_template
254              
255             Returns the default template for output. In this modules, it's C<%c>,
256             for the city. See the L section.
257              
258             =cut
259              
260 7     7 1 511 sub default_template ($app) { '%c' }
  7         26  
  7         27  
  7         53  
261              
262             =item * default_output_fh
263              
264             Returns the default for the error filehandle. In this module, it's
265             standard error.
266              
267             =cut
268              
269 5     5 1 43 sub default_output_fh { \*STDOUT }
270              
271             =item * error(MESSAGE)
272              
273             Send the MESSAGE string to the error filehandle.
274              
275             =cut
276              
277 8     8 1 20 sub error ($app, $message ) {
  8         20  
  8         15  
  8         28  
278 8         17 say { $app->error_fh } $message
  8         31  
279             }
280              
281             =item * error_fh
282              
283             Returns the filehandle for error output.
284              
285             =cut
286              
287 8     8 1 20 sub error_fh ($app) { $app->{error_fh} }
  8         14  
  8         17  
  8         48  
288              
289             =item * formatter
290              
291             Returns the formatter object. In this module, that's an object of
292             L.
293              
294             =cut
295              
296 3     3 1 5 sub formatter ($app) {
  3         6  
  3         4  
297             # $w - width of field
298             # $v - value that corresponds to position in template
299             # $V - list of all values
300             # $l - letter
301 4         8 my $formatter = String::Sprintf->formatter(
302 4     4   64 a => sub ( $w, $v, $V, $l ) {
  4         6  
  4         4  
  4         8  
  4         6  
303 4         14 my $asn = $V->[0]->asn;
304 4 50 100     26 ($asn) = ($V->[0]->org // '') =~ m/^AS(\d+)\s/ unless defined $asn;
305              
306 4   100     41 sprintf "%${w}s", $asn // '';
307             },
308 4     4   64 c => sub ( $w, $v, $V, $l ) {
  4         7  
  4         5  
  4         5  
  4         7  
  4         6  
309 4   100     15 sprintf "%${w}s", $V->[0]->city // '';
310             },
311 4     4   116 C => sub ( $w, $v, $V, $l ) {
  4         8  
  4         5  
  4         6  
  4         8  
  4         12  
312 4   100     17 sprintf "%${w}s", $V->[0]->country // '';
313             },
314              
315 4     4   63 e => sub ( $w, $v, $V, $l ) {
  4         8  
  4         8  
  4         5  
  4         8  
  4         6  
316 4   50     17 sprintf "%${w}s", $V->[0]->abuse // '';
317             },
318              
319 4     4   62 f => sub ( $w, $v, $V, $l ) {
  4         9  
  4         5  
  4         6  
  4         7  
  4         6  
320 4   100     15 sprintf "%${w}s", $V->[0]->country_flag->{emoji} // '';
321             },
322              
323 4     4   64 h => sub ( $w, $v, $V, $l ) {
  4         8  
  4         6  
  4         6  
  4         6  
  4         5  
324 4   100     15 sprintf "%${w}s", $V->[0]->hostname // '';
325             },
326              
327 4     4   83 i => sub ( $w, $v, $V, $l ) {
  4         9  
  4         4  
  4         6  
  4         5  
  4         6  
328 4   100     16 sprintf "%${w}s", $V->[0]->ip // '';
329             },
330 1     1   24 j => sub ( $w, $v, $V, $l ) {
  1         2  
  1         2  
  1         1  
  1         3  
  1         1  
331 9     9   80 use JSON;
  9         15  
  9         95  
332             # we decode UTF-8 because it will be encoded again on the
333             # way out
334 1         8 decode( 'UTF-8', encode_json($V->[0]->TO_JSON) );
335             },
336 4     4   65 k => sub ( $w, $v, $V, $l ) {
  4         7  
  4         7  
  4         5  
  4         6  
  4         6  
337 4   100     17 sprintf "%${w}s", $V->[0]->continent->{name} // '';
338             },
339              
340              
341 4     4   87 L => sub ( $w, $v, $V, $l ) {
  4         7  
  4         7  
  4         4  
  4         7  
  4         5  
342 4 100       14 defined $V->[0]->latitude ?
343             sprintf "%${w}f", $V->[0]->latitude
344             :
345             '';
346             },
347 4     4   67 l => sub ( $w, $v, $V, $l ) {
  4         8  
  4         8  
  4         36  
  4         7  
  4         6  
348 4 100       14 defined $V->[0]->longitude ?
349             sprintf "%${w}f", $V->[0]->longitude
350             :
351             '';
352             },
353              
354 4     4   61 n => sub ( $w, $v, $V, $l ) {
  4         6  
  4         7  
  4         5  
  4         8  
  4         5  
355 4   100     15 sprintf "%${w}s", $V->[0]->country_name // '';
356             },
357              
358 4     4   67 o => sub ( $w, $v, $V, $l ) {
  4         9  
  4         7  
  4         6  
  4         8  
  4         6  
359 4   100     15 sprintf "%${w}s", $V->[0]->org // '';
360             },
361 1     1   21 r => sub ( $w, $v, $V, $l ) {
  1         2  
  1         1  
  1         2  
  1         1  
  1         1  
362 1   50     7 sprintf "%${w}s", $V->[0]->region // '';
363             },
364              
365 4     4   68 t => sub ( $w, $v, $V, $l ) {
  4         7  
  4         5  
  4         7  
  4         7  
  4         4  
366 4   100     18 sprintf "%${w}s", $V->[0]->timezone // '';
367             },
368              
369 2     2   63 N => sub { "\n" },
370 2     2   45 T => sub { "\t" },
371 3         119 );
372             }
373              
374             =item * format( TEMPLATE, IP_INFO )
375              
376             Formats a L object according to template.
377              
378             =cut
379              
380 60     60 1 31177 sub format ($app, $info) {
  60         93  
  60         74  
  60         93  
381 60         87 state $formatter = $app->formatter;
382 60         344 $formatter->sprintf( $app->template, $info );
383             }
384              
385             =item * get_info(IP_ADDRESS)
386              
387             =cut
388              
389 8     8 1 66612 sub get_info ($app, $ip ) {
  8         24  
  8         18  
  8         15  
390 8         15 state $ipinfo = do {
391 1         4 my $g = Geo::IPinfo->new( $app->token );
392 1         4801 $g->{base_url_ipv6} = $g->{base_url};
393 1         3 $g;
394             };
395              
396 8         18 my $method = do {
397 8 100       39 if( $app->looks_like_ipv4($ip) ) {
    50          
398 5         56519 'info';
399             }
400             elsif( $app->looks_like_ipv6($ip) ) {
401 0         0 $ip = _compact_ipv6($ip);
402 0         0 'info_v6'
403             }
404             else {
405 3         118 $app->error( "<$ip> does not look like an IP address. Skipping." );
406 3         11 return;
407             }
408             };
409              
410 5         48 my $info = $ipinfo->$method($ip);
411              
412             # https://github.com/ipinfo/perl/pull/32
413             # cache hit is doubly wrapped in object
414 5         120661 my @values = grep { eval { $_->isa('Geo::Details') } } values %$info;
  10         28  
  10         87  
415 5 50       26 $info = shift @values if @values;
416              
417 5 50 33     26 unless( defined $info and eval { $info->isa('Geo::Details') } ) {
  5         50  
418 0         0 $app->error( "Could not get info for <$ip>." );
419 0         0 return;
420             }
421              
422 5 50 33     36 if( exists $info->{bogon} and $info->{bogon} eq 'True' ) {
423 5         39 $app->error( "<$ip> is a bogon." );
424 5         38 return;
425             }
426              
427 0         0 $app->decode_info($info);
428              
429 0         0 return $info;
430             }
431              
432             =item * looks_like_ipv4(IP)
433              
434             Returns true if IP looks like an IPv4 address.
435              
436             =cut
437              
438 8     8 1 22 sub looks_like_ipv4 ($app, $ip) {
  8         20  
  8         15  
  8         15  
439 8         58 Net::CIDR::cidrvalidate($ip);
440             }
441              
442             =item * looks_like_ipv6(IP)
443              
444             Returns true if IP looks like an IPv6 address.
445              
446             =cut
447              
448 3     3 1 144 sub looks_like_ipv6 ($app, $ip) {
  3         7  
  3         6  
  3         7  
449 3         11 my $compact = _compact_ipv6($ip);
450 3         12 Net::CIDR::cidrvalidate($compact);
451             }
452              
453             =item * get_token
454              
455             Return the API token. So far, this is just the value in the C
456             environment variable.
457              
458             =cut
459              
460 5     5 1 10 sub get_token ($class) {
  5         9  
  5         16  
461             $ENV{APP_IPINFO_TOKEN}
462 5         34 }
463              
464             =item * output(MESSAGE)
465              
466             Send the MESSAGE string to the output filehandle.
467              
468             =cut
469              
470 1     1 1 14 sub output ($app, $message) {
  1         1  
  1         2  
  1         1  
471 1         2 print { $app->output_fh } $message
  1         3  
472             }
473              
474             =item * output_fh
475              
476             Return the filehandle for output.
477              
478             =cut
479              
480 1     1 1 1 sub output_fh ($app) { $app->{output_fh} }
  1         2  
  1         5  
  1         26  
481              
482             =item * template
483              
484             =cut
485              
486 62     62 1 522 sub template ($app) { $app->{template} }
  62         79  
  62         73  
  62         240  
487              
488             =item * token
489              
490             Return the IPinfo.io token
491              
492             =cut
493              
494 1     1 1 2 sub token ($app) { $app->{token} }
  1         3  
  1         2  
  1         16  
495              
496             =back
497              
498             =head1 SEE ALSO
499              
500             =over 4
501              
502             =item * L
503              
504             =item * IPinfo.io, L
505              
506             =back
507              
508             =head1 SOURCE AVAILABILITY
509              
510             The main source repository is in Github, and there are backup repos
511             in other services:
512              
513             =over 4
514              
515             =item * L
516              
517             =item * L
518              
519             =item * L
520              
521             =item * L
522              
523             =back
524              
525             =head1 COPYRIGHT
526              
527             Copyright © 2025, brian d foy, all rights reserved.
528              
529             =head1 LICENSE
530              
531             You can use this code under the terms of the Artistic License 2.
532              
533             =cut
534              
535             __PACKAGE__;