#!/usr/bin/env perl use strict; use warnings; use feature 'say'; use Encode qw( decode_utf8 FB_CROAK ); use Getopt::Long qw( GetOptionsFromArray :config require_order ); use JSON::PP qw( encode_json ); use LWP::UserAgent; use Pod::Usage; =head1 NAME B - Shell bindings for the Zonemaster::Backend RPC API Zmb is meant to be pronounced I. =head1 SYNOPSIS zmb [GLOBAL OPTIONS] COMMAND [OPTIONS] =head1 GLOBAL OPTIONS --help Show usage --verbose Show RPC query --server URL The server to connect to. Default is http://localhost:5000/. =cut sub main { my @argv = @_; @argv = map { decode_utf8( $_, FB_CROAK ) } @argv; my $opt_help; my $opt_verbose; my $opt_server = 'http://localhost:5000/'; GetOptionsFromArray( \@argv, 'help' => \$opt_help, 'verbose' => \$opt_verbose, 'server=s' => \$opt_server, ) or pod2usage( 2 ); if ( !@argv ) { pod2usage( -verbose => 99, -sections => ['SYNOPSIS', 'GLOBAL OPTIONS'], -exitval => 'NOEXIT' ); show_commands(); exit 1; } my $cmd = shift @argv; pod2usage( 1 ) if !defined $cmd; my $cmd_sub = \&{ "cmd_" . $cmd }; pod2usage( "'$cmd' is not a command" ) if !defined &$cmd_sub; pod2usage( -verbose => 99, -sections => ["COMMANDS/$cmd"] ) if $opt_help; my $json = &$cmd_sub( @argv ); if ( $json ) { say $json if $opt_verbose; my $request = to_request( $opt_server, $json ); my $response = submit( $request ); say $response; } } =head1 COMMANDS =head2 man Show the full manual page. zmb [GLOBAL OPTIONS] man =cut sub cmd_man { pod2usage( -verbose => 2 ); } =head2 non_existing_method Call a non-existing RPC method. zmb [GLOBAL OPTIONS] non_existing_method =cut sub cmd_non_existing_method { return to_jsonrpc( id => 1, method => 'non_existing_method', ); } =head2 version_info zmb [GLOBAL OPTIONS] version_info =cut sub cmd_version_info { return to_jsonrpc( id => 1, method => 'version_info', ); } =head2 profile_names zmb [GLOBAL OPTIONS] profile_names =cut sub cmd_profile_names { return to_jsonrpc( id => 1, method => 'profile_names', ); } =head2 get_language_tags zmb [GLOBAL OPTIONS] get_language_tags =cut sub cmd_get_language_tags { return to_jsonrpc( id => 1, method => 'get_language_tags', ); } =head2 start_domain_test zmb [GLOBAL OPTIONS] start_domain_test [OPTIONS] Options: --domain DOMAIN_NAME --ipv4 true|false|null --ipv6 true|false|null --nameserver DOMAIN_NAME:IP_ADDRESS --nameserver DOMAIN_NAME # Trailing colon is optional when not specifing IP_ADDRESS --ds-info DS_INFO --client-id CLIENT_ID --client-version CLIENT_VERSION --profile PROFILE_NAME --queue QUEUE --language LANGUAGE DS_INFO is a comma separated list of key-value pairs. The expected pairs are: keytag=NON_NEGATIVE_INTEGER algorithm=NON_NEGATIVE_INTEGER digtype=NON_NEGATIVE_INTEGER digest=HEX_STRING =cut sub cmd_start_domain_test { my @opts = @_; my @opt_nameserver; my $opt_domain; my $opt_client_id; my $opt_client_version; my @opt_ds_info; my $opt_ipv4; my $opt_ipv6; my $opt_profile; my $opt_queue; my $opt_language; GetOptionsFromArray( \@opts, 'domain|d=s' => \$opt_domain, 'nameserver|n=s' => \@opt_nameserver, 'client-id=s' => \$opt_client_id, 'client-version=s' => \$opt_client_version, 'ds-info=s' => \@opt_ds_info, 'ipv4=s' => \$opt_ipv4, 'ipv6=s' => \$opt_ipv6, 'profile=s' => \$opt_profile, 'queue=s' => \$opt_queue, 'language=s' => \$opt_language, ) or pod2usage( 2 ); my %params = ( domain => $opt_domain, ); if ( $opt_client_id ) { $params{client_id} = $opt_client_id; } if ( $opt_client_version ) { $params{client_version} = $opt_client_version; } if ( @opt_ds_info ) { my @info_objects; for my $property_value_pairs ( @opt_ds_info ) { my %info_object; for my $pair ( split /,/, $property_value_pairs ) { my ( $property, $value ) = split /=/, $pair; if ( $property =~ /^(?:keytag|algorithm|digtype)$/ ) { $value = 0 + $value; } $info_object{$property} = $value; } push @info_objects, \%info_object; } $params{ds_info} = \@info_objects; } if ( @opt_nameserver ) { my @nameserver_objects; for my $domain_ip_pair ( @opt_nameserver ) { my ( $domain, $ip ) = split /:/, $domain_ip_pair, 2; if ($ip) { push @nameserver_objects, { ns => $domain, ip => $ip }; } else { push @nameserver_objects, { ns => $domain }; } } $params{nameservers} = \@nameserver_objects; } if ( $opt_ipv4 ) { $params{ipv4} = json_tern( $opt_ipv4 ); } if ( $opt_ipv6 ) { $params{ipv6} = json_tern( $opt_ipv6 ); } if ( $opt_profile ) { $params{profile} = $opt_profile; } if ( $opt_queue ) { $params{queue} = $opt_queue; } if ( $opt_language ) { $params{language} = $opt_language; } return to_jsonrpc( id => 1, method => 'start_domain_test', params => \%params, ); } =head2 test_progress zmb [GLOBAL OPTIONS] test_progress [OPTIONS] Options: --test-id TEST_ID =cut sub cmd_test_progress { my @opts = @_; my $opt_test_id; GetOptionsFromArray( \@opts, 'test-id|t=s' => \$opt_test_id, ) or pod2usage( 2 ); return to_jsonrpc( id => 1, method => 'test_progress', params => { test_id => $opt_test_id, }, ); } =head2 get_test_params zmb [GLOBAL OPTIONS] get_test_params [OPTIONS] Options: --test-id TEST_ID =cut sub cmd_get_test_params { my @opts = @_; my $opt_test_id; GetOptionsFromArray( # \@opts, 'test-id|t=s' => \$opt_test_id, ) or pod2usage( 2 ); return to_jsonrpc( id => 1, method => 'get_test_params', params => { test_id => $opt_test_id, }, ); } =head2 get_test_results zmb [GLOBAL OPTIONS] get_test_results [OPTIONS] Options: --test-id TEST_ID --lang LANGUAGE =cut sub cmd_get_test_results { my @opts = @_; my $opt_lang; my $opt_test_id; GetOptionsFromArray( \@opts, 'test-id|t=s' => \$opt_test_id, 'lang|l=s' => \$opt_lang, ) or pod2usage( 2 ); return to_jsonrpc( id => 1, method => 'get_test_results', params => { id => $opt_test_id, language => $opt_lang, }, ); } =head2 get_test_history zmb [GLOBAL OPTIONS] get_test_history [OPTIONS] Options: --domain DOMAIN_NAME --filter all|delegated|undelegated --offset COUNT --limit COUNT =cut sub cmd_get_test_history { my @opts = @_; my $opt_filter; my $opt_domain; my $opt_offset; my $opt_limit; GetOptionsFromArray( \@opts, 'domain|d=s' => \$opt_domain, 'filter|n=s' => \$opt_filter, 'offset|o=i' => \$opt_offset, 'limit|l=i' => \$opt_limit, ) or pod2usage( 2 ); my %params = ( frontend_params => { domain => $opt_domain, }, ); if ( $opt_filter ) { unless ( $opt_filter =~ /^(?:all|delegated|undelegated)$/ ) { die 'Illegal filter value. Expects "all", "delegated" or "undelegated" '; } $params{filter} = $opt_filter; } if ( defined $opt_offset ) { $params{offset} = $opt_offset; } if ( defined $opt_limit ) { $params{limit} = $opt_limit; } return to_jsonrpc( id => 1, method => 'get_test_history', params => \%params, ); } =head2 add_api_user zmb [GLOBAL OPTIONS] add_api_user [OPTIONS] Options: --username USERNAME --api-key API_KEY =cut sub cmd_add_api_user { my @opts = @_; my $opt_username; my $opt_api_key; GetOptionsFromArray( \@opts, 'username|u=s' => \$opt_username, 'api-key|a=s' => \$opt_api_key, ) or pod2usage( 2 ); return to_jsonrpc( id => 1, method => 'add_api_user', params => { username => $opt_username, api_key => $opt_api_key, }, ); } =head2 add_batch_job zmb [GLOBAL OPTIONS] add_batch_job [OPTIONS] Options: --username USERNAME --api-key API_KEY --domain DOMAIN_NAME --ipv4 true|false|null --ipv6 true|false|null --nameserver DOMAIN_NAME:IP_ADDRESS --nameserver DOMAIN_NAME # Trailing colon is optional when not specifing IP_ADDRESS --ds-info DS_INFO --client-id CLIENT_ID --client-version CLIENT_VERSION --profile PROFILE_NAME --queue QUEUE --file FILENAME "--domain" is repeated for each domain to be tested. "--nameserver" can be repeated for each name server. "--ds-info" can be repeated for each DS record. "--file" points at a file with a list of domain names to test, one name per line. Lines starting with "#", empty lines and lines with white space only are ignored. Trailing white space is ignored. "--file" and "--domain" can be combined. Domains specified by any "--domain" are added before those specified in the file, if any. DS_INFO is a comma separated list of key-value pairs. The expected pairs are: keytag=NON_NEGATIVE_INTEGER algorithm=NON_NEGATIVE_INTEGER digtype=NON_NEGATIVE_INTEGER digest=HEX_STRING =cut sub cmd_add_batch_job { my @opts = @_; my $opt_username; my $opt_api_key; my @opt_nameserver; my @opt_domains; my $opt_file; my $opt_client_id; my $opt_client_version; my @opt_ds_info; my $opt_ipv4; my $opt_ipv6; my $opt_profile; my $opt_queue; GetOptionsFromArray( \@opts, 'username|u=s' => \$opt_username, 'api-key|a=s' => \$opt_api_key, 'domain|d=s' => \@opt_domains, 'nameserver|n=s' => \@opt_nameserver, 'client-id=s' => \$opt_client_id, 'client-version=s' => \$opt_client_version, 'ds-info=s' => \@opt_ds_info, 'ipv4=s' => \$opt_ipv4, 'ipv6=s' => \$opt_ipv6, 'profile=s' => \$opt_profile, 'queue=s' => \$opt_queue, 'file=s' => \$opt_file, ) or pod2usage( 2 ); if ($opt_file) { open( my $fh, "<", $opt_file ) or die "Can't open < $opt_file: $!"; while( <$fh> ) { chomp; s/\s+$//; s/^\s+//; next if /^#/ or /^$/; push( @opt_domains, decode_utf8( $_ ) ); }; }; my %params = ( domains => \@opt_domains ); $params{username} = $opt_username; $params{api_key} = $opt_api_key; if ( $opt_client_id ) { $params{test_params}{client_id} = $opt_client_id; } if ( $opt_client_version ) { $params{test_params}{client_version} = $opt_client_version; } if ( @opt_ds_info ) { my @info_objects; for my $property_value_pairs ( @opt_ds_info ) { my %info_object; for my $pair ( split /,/, $property_value_pairs ) { my ( $property, $value ) = split /=/, $pair; if ( $property =~ /^(?:keytag|algorithm|digtype)$/ ) { $value = 0 + $value; } $info_object{$property} = $value; } push @info_objects, \%info_object; } $params{test_params}{ds_info} = \@info_objects; } if ( @opt_nameserver ) { my @nameserver_objects; for my $domain_ip_pair ( @opt_nameserver ) { my ( $domain, $ip ) = split /:/, $domain_ip_pair, 2; $ip //= ""; push @nameserver_objects, { ns => $domain, ip => $ip, }; } $params{test_params}{nameservers} = \@nameserver_objects; } if ( $opt_ipv4 ) { $params{test_params}{ipv4} = json_tern( $opt_ipv4 ); } if ( $opt_ipv6 ) { $params{test_params}{ipv6} = json_tern( $opt_ipv6 ); } if ( $opt_profile ) { $params{test_params}{profile} = $opt_profile; } if ( $opt_queue ) { $params{test_params}{queue} = $opt_queue; } return to_jsonrpc( id => 1, method => 'add_batch_job', params => \%params, ); } =head2 batch_status zmb [GLOBAL OPTIONS] batch_status [OPTIONS] Options: --batch-id BATCH-ID|--bi BATCH-ID --list-waiting-tests true|false|null --list-running-tests true|false|null --list-finished-tests true|false|null --lw # Same as "--list-waiting-tests true" --lr # Same as "--list-running-tests true" --lf # Same as "--list-finished-tests true" "--batch-id" is mandatory. The command provides the number of tests waiting to be run, tests running and test finished, respectively, for the batch. "--list-waiting-tests", "--list-running-tests" and "--list-finished-tests" are optional. If given the test IDs of tests waiting to be run, tests running and test finished, respectively, are listed. "--lw", "--lr" and "--lf" are option. "--lw" must not be combined with "--list-waiting-tests". "--lr" must not be combined with "--list-running-tests". "--lf" must not be combined with "--list-finished-tests". =cut sub cmd_batch_status { my @opts = @_; my $opt_batch_id; my $opt_list_waiting_tests; my $opt_lw; my $opt_list_running_tests; my $opt_lr; my $opt_list_finished_tests; my $opt_lf; GetOptionsFromArray( \@opts, 'batch-id|bi=s' => \$opt_batch_id, 'list-waiting-tests=s' => \$opt_list_waiting_tests, 'lw' => \$opt_lw, 'list-running-tests=s' => \$opt_list_running_tests, 'lr' => \$opt_lr, 'list-finished-tests=s' => \$opt_list_finished_tests, 'lf' => \$opt_lf, ) or pod2usage( 2 ); pod2usage( "'--lw' and '--list-waiting-test' must not be combined" ) if defined $opt_list_waiting_tests and $opt_lw; pod2usage( "'--lr' and '--list-running-test' must not be combined" ) if defined $opt_list_running_tests and $opt_lr; pod2usage( "'--lf' and '--list-finished-test' must not be combined" ) if defined $opt_list_finished_tests and $opt_lf; my %params; $params{batch_id} = $opt_batch_id; $params{list_waiting_tests} = json_tern( $opt_list_waiting_tests ) if $opt_list_waiting_tests and json_tern( $opt_list_waiting_tests ); $params{list_running_tests} = json_tern( $opt_list_running_tests ) if $opt_list_running_tests and json_tern( $opt_list_running_tests ); $params{list_finished_tests} = json_tern( $opt_list_finished_tests ) if $opt_list_finished_tests and json_tern( $opt_list_finished_tests ); $params{list_waiting_tests} = JSON::PP::true if $opt_lw; $params{list_running_tests} = JSON::PP::true if $opt_lr; $params{list_finished_tests} = JSON::PP::true if $opt_lf; return to_jsonrpc( id => 1, method => 'batch_status', params => \%params, ); } sub show_commands { my %specials = ( man => 'Show the full manual page.', non_existing_method => 'Call a non-existing RPC method.', ); my @commands = get_commands(); my $max_width = 0; for my $command ( @commands ) { $max_width = length $command if length $command > $max_width; } say "Commands:"; for my $command ( @commands ) { if ( exists $specials{$command} ) { printf " %-*s %s\n", $max_width, $command, $specials{$command}; } else { say " ", $command; } } } sub get_commands { no strict 'refs'; return sort map { $_ =~ s/^cmd_//r } grep { $_ =~ /^cmd_/ } grep { defined &{"main\::$_"} } keys %{"main\::"}; } sub json_tern { my $value = shift; if ( $value eq 'true' ) { return JSON::PP::true; } elsif ( $value eq 'false' ) { return JSON::PP::false; } elsif ( $value eq 'null' ) { return undef; } else { die 'Illegal value. Expects "true", "false" or "null" '; } } sub to_jsonrpc { my %args = @_; my $id = $args{id}; my $method = $args{method}; my $request = { jsonrpc => "2.0", method => $method, id => $id, }; if ( exists $args{params} ) { $request->{params} = $args{params}; } return encode_json( $request ); } sub to_request { my $server = shift; my $json = shift; my $req = HTTP::Request->new( POST => $server ); $req->content_type( 'application/json' ); $req->content( $json ); return $req; } sub submit { my $req = shift; my $ua = LWP::UserAgent->new; my $res = $ua->request( $req ); if ( $res->is_success ) { return $res->decoded_content; } else { die $res->status_line; } } main( @ARGV );