File Coverage

blib/lib/Zonemaster/CLI.pm
Criterion Covered Total %
statement 56 269 20.8
branch 0 120 0.0
condition 0 39 0.0
subroutine 19 29 65.5
pod 0 9 0.0
total 75 466 16.0


line stmt bran cond sub pod time code
1             # Brief help module to define the exception we use for early exits.
2             package Zonemaster::Engine::Exception::NormalExit;
3 1     1   45588 use Moose;
  1         360303  
  1         7  
4             extends 'Zonemaster::Engine::Exception';
5              
6             # The actual interesting module.
7             package Zonemaster::CLI;
8              
9 1     1   6509 use version; our $VERSION = version->declare("v1.1.1");
  1         1418  
  1         5  
10              
11 1     1   85 use 5.014002;
  1         5  
12 1     1   4 use warnings;
  1         2  
  1         30  
13              
14 1     1   341 use Locale::TextDomain 'Zonemaster-CLI';
  1         16579  
  1         5  
15 1     1   5012 use Moose;
  1         2  
  1         8  
16             with 'MooseX::Getopt';
17              
18 1     1   6503 use Zonemaster::Engine;
  1         679950  
  1         31  
19 1     1   8 use Zonemaster::Engine::Logger::Entry;
  1         2  
  1         21  
20 1     1   288 use Zonemaster::Engine::Translator;
  1         20154  
  1         39  
21 1     1   19 use Zonemaster::Engine::Util qw[pod_extract_for];
  1         2  
  1         64  
22 1     1   335 use Zonemaster::Engine::Exception;
  1         11907  
  1         29  
23 1     1   7 use Zonemaster::Engine::Zone;
  1         2  
  1         21  
24 1     1   4 use Scalar::Util qw[blessed];
  1         2  
  1         41  
25 1     1   4 use Encode;
  1         11  
  1         66  
26 1     1   5 use Zonemaster::LDNS;
  1         2  
  1         30  
27 1     1   4 use POSIX qw[setlocale LC_MESSAGES];
  1         2  
  1         7  
28 1     1   69 use List::Util qw[max];
  1         2  
  1         47  
29 1     1   351 use Text::Reflow qw[reflow_string];
  1         3925  
  1         53  
30 1     1   414 use JSON::XS;
  1         3376  
  1         2340  
31              
32             our %numeric = Zonemaster::Engine::Logger::Entry->levels;
33             our $JSON = JSON::XS->new->allow_blessed->convert_blessed->canonical;
34              
35             STDOUT->autoflush( 1 );
36              
37             has 'version' => (
38             is => 'ro',
39             isa => 'Bool',
40             default => 0,
41             required => 0,
42             documentation => __( 'Print version information and exit.' ),
43             );
44              
45             has 'level' => (
46             is => 'ro',
47             isa => 'Str',
48             required => 0,
49             default => 'NOTICE',
50             documentation =>
51             __( 'The minimum severity level to display. Must be one of CRITICAL, ERROR, WARNING, NOTICE, INFO or DEBUG.' ),
52             );
53              
54             has 'locale' => (
55             is => 'ro',
56             isa => 'Str',
57             required => 0,
58             documentation => __( 'The locale to use for messages translation.' ),
59             );
60              
61             has 'json' => (
62             is => 'ro',
63             isa => 'Bool',
64             default => 0,
65             documentation => __( 'Flag indicating if output should be in JSON or not.' ),
66             );
67              
68             has 'json_stream' => (
69             is => 'ro',
70             isa => 'Bool',
71             default => 0,
72             documentation => __( 'Flag indicating if output should be streaming JSON or not.' ),
73             );
74              
75             has 'json_translate' => (
76             is => 'ro',
77             isa => 'Bool',
78             default => 0,
79             documentation => __( 'Flag indicating if streaming JSON output should include the translated message of the tag or not.' ),
80             );
81              
82             has 'raw' => (
83             is => 'ro',
84             isa => 'Bool',
85             default => 0,
86             documentation => __( 'Flag indicating if output should be translated to human language or dumped raw.' ),
87             );
88              
89             has 'time' => (
90             is => 'ro',
91             isa => 'Bool',
92             documentation => __( 'Print timestamp on entries.' ),
93             default => 1,
94             );
95              
96             has 'show_level' => (
97             is => 'ro',
98             isa => 'Bool',
99             documentation => __( 'Print level on entries.' ),
100             default => 1,
101             );
102              
103             has 'show_module' => (
104             is => 'ro',
105             isa => 'Bool',
106             documentation => __( 'Print the name of the module on entries.' ),
107             default => 0,
108             );
109              
110             has 'ns' => (
111             is => 'ro',
112             isa => 'ArrayRef',
113             documentation => __( 'A name/ip string giving a nameserver for undelegated tests, or just a name which will be looked up for IP addresses. Can be given multiple times.' ),
114             );
115              
116             has 'save' => (
117             is => 'ro',
118             isa => 'Str',
119             required => 0,
120             documentation => __( 'Name of a file to save DNS data to after running tests.' ),
121             );
122              
123             has 'restore' => (
124             is => 'ro',
125             isa => 'Str',
126             required => 0,
127             documentation => __( 'Name of a file to restore DNS data from before running test.' ),
128             );
129              
130             has 'ipv4' => (
131             is => 'ro',
132             isa => 'Bool',
133             default => 1,
134             documentation =>
135             __( 'Flag to permit or deny queries being sent via IPv4. --ipv4 permits IPv4 traffic, --no-ipv4 forbids it.' ),
136             );
137              
138             has 'ipv6' => (
139             is => 'ro',
140             isa => 'Bool',
141             default => 1,
142             documentation =>
143             __( 'Flag to permit or deny queries being sent via IPv6. --ipv6 permits IPv6 traffic, --no-ipv6 forbids it.' ),
144             );
145              
146             has 'list_tests' => (
147             is => 'ro',
148             isa => 'Bool',
149             default => 0,
150             documentation => __( 'Instead of running a test, list all available tests.' ),
151             );
152              
153             has 'test' => (
154             is => 'ro',
155             isa => 'ArrayRef',
156             required => 0,
157             documentation => __(
158             'Specify test to run. Should be either the name of a module, or the name of a module and the name of a method in that module separated by a "/" character (Example: "Basic/basic1"). The method specified must be one that takes a zone object as its single argument. This switch can be repeated.'
159             )
160             );
161              
162             has 'stop_level' => (
163             is => 'ro',
164             isa => 'Str',
165             required => 0,
166             documentation => __(
167             'As soon as a message at this level or higher is logged, execution will stop. Must be one of CRITICAL, ERROR, WARNING, NOTICE, INFO or DEBUG.'
168             )
169             );
170              
171             has 'config' => (
172             is => 'ro',
173             isa => 'Str',
174             required => 0,
175             documentation => __( 'Name of configuration file to load.' ),
176             );
177              
178             has 'policy' => (
179             is => 'ro',
180             isa => 'Str',
181             required => 0,
182             documentation => __( 'Name of policy file to load.' ),
183             );
184              
185             has 'ds' => (
186             is => 'ro',
187             isa => 'ArrayRef[Str]',
188             required => 0,
189             documentation => __( 'Strings with DS data on the form "keytag,algorithm,type,digest"' ),
190             );
191              
192             has 'count' => (
193             is => 'ro',
194             isa => 'Bool',
195             required => 0,
196             documentation => __( 'Print a count of the number of messages at each level' ),
197             );
198              
199             has 'progress' => (
200             is => 'ro',
201             isa => 'Bool',
202             default => !!( -t STDOUT ),
203             documentation => __( 'Boolean flag for activity indicator. Defaults to on if STDOUT is a tty, off if it is not.' ),
204             );
205              
206             has 'encoding' => (
207             is => 'ro',
208             isa => 'Str',
209             default => sub {
210             my $locale = $ENV{LC_CTYPE} // 'C';
211             my ( $e ) = $locale =~ m|\.(.*)$|;
212             $e //= 'UTF-8';
213             return $e;
214             },
215             documentation => __( 'Name of the character encoding used for command line arguments' ),
216             );
217              
218             has 'nstimes' => (
219             is => 'ro',
220             isa => 'Bool',
221             required => 0,
222             default => 0,
223             documentation => __('At the end of a run, print a summary of the times the zone\'s name servers took to answer.'),
224             );
225              
226             has 'dump_config' => (
227             is => 'ro',
228             isa => 'Bool',
229             required => 0,
230             default => 0,
231             documentation => __( 'Print the effective configuration used in JSON format, then exit.' ),
232             );
233              
234             has 'dump_policy' => (
235             is => 'ro',
236             isa => 'Bool',
237             required => 0,
238             default => 0,
239             documentation => __( 'Print the effective policy used in JSON format, then exit.' ),
240             );
241              
242             has 'sourceaddr' => (
243             is => 'ro',
244             isa => 'Str',
245             required => 0,
246             documentation => __( 'Local IP address that the test engine should try to send its requests from.' ),
247             );
248              
249             has 'elapsed' => (
250             is => 'ro',
251             isa => 'Bool',
252             required => 0,
253             default => 0,
254             documentation => 'Print elapsed time at end of run.',
255             );
256              
257             sub run {
258 0     0 0   my ( $self ) = @_;
259 0           my @accumulator;
260             my %counter;
261 0           my $printed_something;
262              
263 0 0         if ( $self->locale ) {
264 0           my $loc = setlocale( LC_MESSAGES, $self->locale );
265 0 0         if ( not defined $loc ) {
266 0           printf STDERR __( "Warning: setting locale %s failed (is it installed on this system?).\n\n" ),
267             $self->locale;
268             }
269             }
270              
271 0 0         if ( $self->version ) {
272 0           print_versions();
273 0           exit;
274             }
275              
276 0 0         if ( $self->list_tests ) {
277 0           print_test_list();
278             }
279              
280 0           Zonemaster::Engine->config->ipv4_ok(0+$self->ipv4);
281 0           Zonemaster::Engine->config->ipv6_ok(0+$self->ipv6);
282              
283 0 0         if ($self->sourceaddr) {
284 0           Zonemaster::Engine->config->resolver_source($self->sourceaddr);
285             }
286              
287 0 0         if ( $self->policy ) {
288 0 0 0       say __( "Loading policy from " ) . $self->policy . '.' if not ($self->dump_config or $self->dump_policy);
289 0           Zonemaster::Engine->config->load_policy_file( $self->policy );
290             }
291              
292 0 0         if ( $self->config ) {
293 0 0 0       say __( "Loading configuration from " ) . $self->config . '.' if not ($self->dump_config or $self->dump_policy);
294 0           Zonemaster::Engine->config->load_config_file( $self->config );
295             }
296              
297 0 0         if ( $self->dump_config ) {
298 0           do_dump_config();
299             }
300              
301 0 0         if ( $self->dump_policy ) {
302 0           foreach my $mod (Zonemaster::Engine->modules) {
303 0           Zonemaster::Engine->config->load_module_policy($mod)
304             }
305 0           do_dump_policy();
306             }
307              
308 0 0 0       if ( $self->stop_level and not defined( $numeric{ $self->stop_level } ) ) {
309 0           die __( "Failed to recognize stop level '" ) . $self->stop_level . "'.\n";
310             }
311              
312 0 0         if ( not defined $numeric{ $self->level } ) {
313 0           die __( "--level must be one of CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG, DEBUG2 or DEBUG3.\n" );
314             }
315              
316 0           my $translator;
317 0 0 0       $translator = Zonemaster::Engine::Translator->new unless ( $self->raw or $self->json or $self->json_stream );
      0        
318 0 0 0       $translator->locale( $self->locale ) if $translator and $self->locale;
319 0 0         eval { $translator->data } if $translator; # Provoke lazy loading of translation data
  0            
320              
321 0           my $json_translator;
322 0 0         if ( $self->json_translate ) {
323 0           $json_translator = Zonemaster::Engine::Translator->new;
324 0 0         $json_translator->locale( $self->locale ) if $self->locale;
325 0           eval { $json_translator->data };
  0            
326             }
327              
328 0 0         if ( $self->restore ) {
329 0           Zonemaster::Engine->preload_cache( $self->restore );
330             }
331              
332             # Callback defined here so it closes over the setup above.
333             Zonemaster::Engine->logger->callback(
334             sub {
335 0     0     my ( $entry ) = @_;
336              
337 0 0         $self->print_spinner() unless $self->json_stream;
338              
339 0           $counter{ uc $entry->level } += 1;
340              
341 0 0         if ( $numeric{ uc $entry->level } >= $numeric{ uc $self->level } ) {
342 0           $printed_something = 1;
343              
344 0 0         if ( $translator ) {
    0          
    0          
    0          
345 0 0         if ( $self->time ) {
346 0           printf "%7.2f ", $entry->timestamp;
347             }
348              
349 0 0         if ( $self->show_level ) {
350 0           printf "%-9s ", __( $entry->level );
351             }
352              
353 0 0         if ( $self->show_module ) {
354 0           printf "%-12s ", $entry->module;
355             }
356              
357 0           say $translator->translate_tag( $entry );
358             }
359             elsif ( $self->json_stream ) {
360 0           my %r;
361              
362 0           $r{timestamp} = $entry->timestamp;
363 0           $r{module} = $entry->module;
364 0           $r{tag} = $entry->tag;
365 0           $r{level} = $entry->level;
366 0 0         $r{args} = $entry->args if $entry->args;
367 0 0         $r{message} = $json_translator->translate_tag( $entry ) if $json_translator;
368              
369 0           say $JSON->encode( \%r );
370             }
371             elsif ( $self->json ) {
372             # Don't do anything
373             }
374             elsif ( $self->show_module ) {
375 0           printf "%7.2f %-9s %-12s %s\n", $entry->timestamp, $entry->level, $entry->module, $entry->string;
376             }
377             else {
378 0           printf "%7.2f %-9s %s\n", $entry->timestamp, $entry->level, $entry->string;
379             }
380             } ## end if ( $numeric{ uc $entry...})
381 0 0 0       if ( $self->stop_level and $numeric{ uc $entry->level } >= $numeric{ uc $self->stop_level } ) {
382 0           die( Zonemaster::Engine::Exception::NormalExit->new( { message => "Saw message at level " . $entry->level } ) );
383             }
384             }
385 0           );
386              
387 0 0 0       if ( $self->config or $self->policy ) {
388 0           print "\n"; # Cosmetic
389             }
390              
391 0           my ( $domain ) = @{ $self->extra_argv };
  0            
392 0 0         if ( not $domain ) {
393 0           die __( "Must give the name of a domain to test.\n" );
394             }
395              
396 0 0         if ( $translator ) {
397 0 0         if ( $self->time ) {
398 0           print __( 'Seconds ' );
399             }
400 0 0         if ( $self->show_level ) {
401 0           print __( 'Level ' );
402             }
403 0 0         if ( $self->show_module ) {
404 0           print __( 'Module ' );
405             }
406 0           say __( 'Message' );
407              
408 0 0         if ( $self->time ) {
409 0           print __( '======= ' );
410             }
411 0 0         if ( $self->show_level ) {
412 0           print __( '========= ' );
413             }
414 0 0         if ( $self->show_module ) {
415 0           print __( '============ ' );
416             }
417 0           say __( '=======' );
418             } ## end if ( $translator )
419              
420 0           $domain = $self->to_idn( $domain );
421              
422 0 0 0       if ( $self->ns and @{ $self->ns } > 0 ) {
  0            
423 0           $self->add_fake_delegation( $domain );
424             }
425              
426 0 0 0       if ( $self->ds and @{ $self->ds } ) {
  0            
427 0           $self->add_fake_ds( $domain );
428             }
429              
430             # Actually run tests!
431 0           eval {
432 0 0 0       if ( $self->test and @{ $self->test } > 0 ) {
  0            
433 0           foreach my $t ( @{ $self->test } ) {
  0            
434 0           my ( $module, $method ) = split( '/', $t, 2 );
435 0 0         if ( $method ) {
436 0           Zonemaster::Engine->test_method( $module, $method, Zonemaster::Engine->zone( $domain ) );
437             }
438             else {
439 0           Zonemaster::Engine->test_module( $module, $domain );
440             }
441             }
442             }
443             else {
444 0           Zonemaster::Engine->test_zone( $domain );
445             }
446             };
447 0 0         if ( $translator ) {
448 0 0         if ( not $printed_something ) {
449 0           say __( "Looks OK." );
450             }
451             }
452              
453 0 0         if ( $@ ) {
454 0           my $err = $@;
455 0 0 0       if ( blessed $err and $err->isa( "Zonemaster::Engine::Exception::NormalExit" ) ) {
456 0           say STDERR "Exited early: " . $err->message;
457             }
458             else {
459 0           die $err; # Don't know what it is, rethrow
460             }
461             }
462              
463 0 0         if ( $self->count ) {
464 0           say __( "\n\n Level\tNumber of log entries" );
465 0           say " =====\t=====================";
466 0           foreach my $level ( sort { $numeric{$b} <=> $numeric{$a} } keys %counter ) {
  0            
467 0           printf __( "%8s\t%5d entries.\n" ), __( $level ), $counter{$level};
468             }
469             }
470              
471 0 0         if ( $self->nstimes ) {
472 0           my $zone = Zonemaster::Engine->zone( $domain );
473 0           my $max = max map { length( "$_" ) } @{ $zone->ns };
  0            
  0            
474              
475 0           print "\n";
476 0           printf "%${max}s %s\n", 'Server', ' Max (ms) Min Avg Stddev Median Total';
477 0           printf "%${max}s %s\n", '=' x $max, ' ======== ======== ======== ======== ======== =========';
478              
479 0           foreach my $ns ( @{ $zone->ns } ) {
  0            
480 0           printf "%${max}s ", $ns->string;
481 0           printf "%9.2f ", 1000 * $ns->max_time;
482 0           printf "%8.2f ", 1000 * $ns->min_time;
483 0           printf "%8.2f ", 1000 * $ns->average_time;
484 0           printf "%8.2f ", 1000 * $ns->stddev_time;
485 0           printf "%8.2f ", 1000 * $ns->median_time;
486 0           printf "%9.2f\n", 1000 * $ns->sum_time;
487             }
488             }
489              
490 0 0         if ($self->elapsed) {
491 0           my $last = Zonemaster::Engine->logger->entries->[-1];
492 0           printf "Total test run time: %0.1f seconds.\n", $last->timestamp;
493             }
494              
495 0 0         if ( $self->json ) {
496 0           say Zonemaster::Engine->logger->json( $self->level );
497             }
498              
499 0 0         if ( $self->save ) {
500 0           Zonemaster::Engine->save_cache( $self->save );
501             }
502              
503 0           return;
504             } ## end sub run
505              
506             sub add_fake_delegation {
507 0     0 0   my ( $self, $domain ) = @_;
508 0           my @ns_with_no_ip;
509             my %data;
510              
511 0           foreach my $pair ( @{ $self->ns } ) {
  0            
512 0           my ( $name, $ip ) = split( '/', $pair, 2 );
513              
514 0 0         if ( not $name ) {
515 0           say STDERR "--ns must have be a name or a name/ip pair.";
516 0           exit( 1 );
517             }
518              
519 0 0         if ($ip) {
520 0           push @{ $data{ $self->to_idn( $name ) } }, $ip;
  0            
521             }
522             else {
523 0           push @ns_with_no_ip, $self->to_idn($name);
524             }
525             }
526 0           foreach my $ns ( @ns_with_no_ip ) {
527 0 0         if ( not exists $data{ $ns } ) {
528 0           $data{ $ns } = undef;
529             }
530             }
531              
532 0           Zonemaster::Engine->add_fake_delegation( $domain => \%data );
533              
534 0           return;
535             }
536              
537             sub add_fake_ds {
538 0     0 0   my ( $self, $domain ) = @_;
539 0           my @data;
540              
541 0           foreach my $str ( @{ $self->ds } ) {
  0            
542 0           my ( $tag, $algo, $type, $digest ) = split( /,/, $str );
543 0           push @data, { keytag => $tag, algorithm => $algo, type => $type, digest => $digest };
544             }
545              
546 0           Zonemaster::Engine->add_fake_ds( $domain => \@data );
547              
548 0           return;
549             }
550              
551             sub print_versions {
552 0     0 0   say 'CLI version: ' . __PACKAGE__->VERSION;
553 0           say 'Engine version: ' . $Zonemaster::Engine::VERSION;
554 0           say "\nTest module versions:";
555              
556 0           my %methods = Zonemaster::Engine->all_methods;
557 0           foreach my $module ( sort keys %methods ) {
558 0           my $mod = "Zonemaster::Engine::Test::$module";
559 0           say "\t$module: " . $mod->version;
560             }
561              
562 0           return;
563             }
564              
565             my @spinner_strings = ( ' | ', ' / ', ' - ', ' \\ ' );
566              
567             sub print_spinner {
568 0     0 0   my ( $self ) = @_;
569              
570 0           state $counter = 0;
571              
572 0 0         printf "%s\r", $spinner_strings[ $counter++ % 4 ] if $self->progress;
573              
574 0           return;
575             }
576              
577             sub to_idn {
578 0     0 0   my ( $self, $str ) = @_;
579              
580 0 0         if ( $str =~ m/^[[:ascii:]]+$/ ) {
581 0           return $str;
582             }
583              
584 0 0         if ( Zonemaster::LDNS::has_idn() ) {
585 0           return Zonemaster::LDNS::to_idn( decode( $self->encoding, $str ) );
586             }
587             else {
588 0           say STDERR __( "Warning: Zonemaster::LDNS not compiled with libidn, cannot handle non-ASCII names correctly." );
589 0           return $str;
590             }
591             }
592              
593             sub print_test_list {
594 0     0 0   my %methods = Zonemaster::Engine->all_methods;
595             my $maxlen = max map {
596 0           map { length( $_ ) }
  0            
  0            
597             @$_
598             } values %methods;
599              
600 0           foreach my $module ( sort keys %methods ) {
601 0           say $module;
602 0           my $doc = pod_extract_for( $module );
603 0           foreach my $method ( sort @{ $methods{$module} } ) {
  0            
604 0           printf " %${maxlen}s ", $method;
605 0 0 0       if ( $doc and $doc->{$method} ) {
606             print reflow_string(
607 0           $doc->{$method},
608             optimum => 65,
609             maximum => 75,
610             indent1 => ' ',
611             indent2 => ( ' ' x ( $maxlen + 6 ) )
612             );
613             }
614 0           print "\n";
615             }
616 0           print "\n";
617             }
618 0           exit( 0 );
619             } ## end sub print_test_list
620              
621             sub do_dump_policy {
622 0     0 0   my $json = JSON::XS->new->canonical->pretty;
623 0           print $json->encode(Zonemaster::Engine->config->policy);
624 0           exit;
625             }
626              
627             sub do_dump_config {
628 0     0 0   my $json = JSON::XS->new->canonical->pretty;
629 0           print $json->encode(Zonemaster::Engine->config->get);
630 0           exit;
631             }
632              
633             1;
634              
635             __END__
636             =pod
637              
638             =encoding UTF-8
639              
640             =head1 NAME
641              
642             Zonemaster::CLI - run Zonemaster tests from the command line
643              
644             =head1 AUTHORS
645              
646             Vincent Levigneron <vincent.levigneron at nic.fr>
647             - Current maintainer
648              
649             Calle Dybedahl <calle at init.se>
650             - Original author