File Coverage

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