package Zonemaster::Backend::RPCAPI; use strict; use warnings; use 5.14.2; # Public Modules use DBI qw(:utils); use Digest::MD5 qw(md5_hex); use File::Slurp qw(append_file); use HTML::Entities; use JSON::PP; use JSON::Validator::Joi; use Log::Any qw($log); use Mojo::JSON::Pointer; use Scalar::Util qw(blessed); use JSON::Validator::Schema::Draft7; use Locale::TextDomain qw[Zonemaster-Backend]; use Locale::Messages qw[LC_MESSAGES LC_ALL]; use POSIX qw (setlocale); use Encode; # Zonemaster Modules use Zonemaster::Engine; use Zonemaster::Engine::Normalization qw( normalize_name trim_space ); use Zonemaster::Engine::Profile; use Zonemaster::Engine::Recursor; use Zonemaster::Backend; use Zonemaster::Backend::Config; use Zonemaster::Backend::Translator; use Zonemaster::Backend::Validator; use Zonemaster::Backend::Errors; my $zm_validator = Zonemaster::Backend::Validator->new; our %json_schemas; my $recursor = Zonemaster::Engine::Recursor->new; sub joi { return JSON::Validator::Joi->new; } sub new { my ( $type, $params ) = @_; my $self = {}; bless( $self, $type ); if ( ! $params || ! $params->{config} ) { handle_exception("Missing 'config' parameter"); } $self->{config} = $params->{config}; my $dbtype; if ( $params->{dbtype} ) { $dbtype = $self->{config}->check_db($params->{dbtype}); } else { $dbtype = $self->{config}->DB_engine; } $self->_init_db($dbtype); $self->{_profiles} = Zonemaster::Backend::Config->load_profiles( # $self->{config}->PUBLIC_PROFILES, $self->{config}->PRIVATE_PROFILES, ); return ( $self ); } sub _init_db { my ( $self, $dbtype ) = @_; eval { my $dbclass = Zonemaster::Backend::DB->get_db_class( $dbtype ); $self->{db} = $dbclass->from_config( $self->{config} ); }; if ($@) { handle_exception("Failed to initialize the [$dbtype] database backend module: [$@]"); } } sub handle_exception { my ( $exception ) = @_; if ( !$exception->isa('Zonemaster::Backend::Error') ) { my $reason = $exception; $exception = Zonemaster::Backend::Error::Internal->new( reason => $reason ); } my $log_extra = $exception->as_hash; delete $log_extra->{message}; if ( $exception->isa('Zonemaster::Backend::Error::Internal') ) { $log->error($exception->as_string, $log_extra); } else { $log->info($exception->as_string, $log_extra); } die $exception->as_hash; } $json_schemas{version_info} = joi->object->strict; sub version_info { my ( $self ) = @_; my %ver; eval { $ver{zonemaster_ldns} = Zonemaster::LDNS->VERSION; $ver{zonemaster_engine} = Zonemaster::Engine->VERSION; $ver{zonemaster_backend} = Zonemaster::Backend->VERSION; }; if ($@) { handle_exception( $@ ); } return \%ver; } # Experimental $json_schemas{system_versions} = $json_schemas{version_info}; sub system_versions { return version_info( @_ ); } $json_schemas{profile_names} = joi->object->strict; sub profile_names { my ( $self ) = @_; my %profiles; eval { %profiles = $self->{config}->PUBLIC_PROFILES }; if ( $@ ) { handle_exception( $@ ); } return [ keys %profiles ]; } # Experimental $json_schemas{conf_profiles} = $json_schemas{profile_names}; sub conf_profiles { my $result = { profiles => profile_names( @_ ) }; return $result; } # Return the list of language tags supported by get_test_results(). The tags are # derived from the locale tags set in the configuration file. $json_schemas{get_language_tags} = joi->object->strict; sub get_language_tags { my ( $self ) = @_; my @lang_tags; eval { my %locales = $self->{config}->LANGUAGE_locale; @lang_tags = sort keys %locales; }; if ( $@ ) { handle_exception( $@ ); } return \@lang_tags; } # Experimental $json_schemas{conf_languages} = $json_schemas{get_language_tags}; sub conf_languages { my $result = { languages => get_language_tags( @_ ) }; return $result; } $json_schemas{get_host_by_name} = { type => 'object', additionalProperties => 0, required => [ 'hostname' ], properties => { hostname => $zm_validator->domain_name } }; sub get_host_by_name { my ( $self, $params ) = @_; my @adresses; eval { my $ns_name = $params->{hostname}; @adresses = map { {$ns_name => $_->short} } $recursor->get_addresses_for($ns_name); @adresses = { $ns_name => '0.0.0.0' } if not @adresses; }; if ($@) { handle_exception( $@ ); } return \@adresses; } # Experimental $json_schemas{lookup_address_records} = $json_schemas{get_host_by_name}; sub lookup_address_records { my $result = { address_records => get_host_by_name( @_ ) }; return $result; } $json_schemas{get_data_from_parent_zone} = { type => 'object', additionalProperties => 0, required => [ 'domain' ], properties => { domain => $zm_validator->domain_name, language => $zm_validator->language_tag, } }; sub get_data_from_parent_zone { my ( $self, $params ) = @_; my $result = eval { my %result; my $domain = $params->{domain}; my ( $_errors, $normalized_domain ) = normalize_name( trim_space ( $domain ) ); my @ns_list; my @ns_names; my $zone = Zonemaster::Engine->zone( $normalized_domain ); push @ns_list, { ns => $_->name->string, ip => $_->address->short} for @{$zone->glue}; my @ds_list; $zone = Zonemaster::Engine->zone($normalized_domain); my $ds_p = $zone->parent->query_one( $zone->name, 'DS', { dnssec => 1, cd => 1, recurse => 1 } ); if ($ds_p) { my @ds = $ds_p->get_records( 'DS', 'answer' ); foreach my $ds ( @ds ) { next unless $ds->type eq 'DS'; push(@ds_list, { keytag => $ds->keytag, algorithm => $ds->algorithm, digtype => $ds->digtype, digest => $ds->hexdigest }); } } $result{ns_list} = \@ns_list; $result{ds_list} = \@ds_list; return \%result; }; if ($@) { handle_exception( $@ ); } elsif ($result) { return $result; } } # Experimental $json_schemas{lookup_delegation_data} = $json_schemas{get_data_from_parent_zone}; sub lookup_delegation_data { return get_data_from_parent_zone( @_ ); } $json_schemas{start_domain_test} = { type => 'object', additionalProperties => 0, required => [ 'domain' ], properties => { domain => $zm_validator->domain_name, ipv4 => joi->boolean->compile, ipv6 => joi->boolean->compile, nameservers => { type => 'array', items => $zm_validator->nameserver }, ds_info => { type => 'array', items => $zm_validator->ds_info }, profile => $zm_validator->profile_name, client_id => $zm_validator->client_id->compile, client_version => $zm_validator->client_version->compile, config => joi->string->compile, priority => $zm_validator->priority->compile, queue => $zm_validator->queue->compile, language => $zm_validator->language_tag, } }; sub start_domain_test { my ( $self, $params ) = @_; my $result = 0; eval { $params->{profile} //= "default"; $params->{priority} //= 10; $params->{queue} //= 0; my $profile = $self->{_profiles}{ $params->{profile} }; $params->{ipv4} //= $profile->get( "net.ipv4" ); $params->{ipv6} //= $profile->get( "net.ipv6" ); $result = $self->{db}->create_new_test( $params->{domain}, $params, $self->{config}->ZONEMASTER_age_reuse_previous_test ); }; if ($@) { handle_exception( $@ ); } return $result; } # Experimental $json_schemas{job_create} = $json_schemas{start_domain_test}; sub job_create { my $result = { job_id => start_domain_test( @_ ) }; return $result; } $json_schemas{test_progress} = joi->object->strict->props( test_id => $zm_validator->test_id->required ); sub test_progress { my ( $self, $params ) = @_; my $result = 0; eval { my $test_id = $params->{test_id}; $result = $self->{db}->test_progress( $test_id ); }; if ($@) { handle_exception( $@ ); } return $result; } # Experimental $json_schemas{job_status} = joi->object->strict->props( job_id => $zm_validator->test_id->required ); sub job_status { my ( $self, $params ) = @_; $params->{test_id} = delete $params->{job_id}; my $result = { progress => $self->test_progress( $params ) }; return $result; } $json_schemas{get_test_params} = joi->object->strict->props( test_id => $zm_validator->test_id->required ); sub get_test_params { my ( $self, $params ) = @_; my $result; eval { my $test_id = $params->{test_id}; $result = $self->{db}->get_test_params( $test_id ); }; if ($@) { handle_exception( $@ ); } return $result; } # Experimental $json_schemas{job_params} = joi->object->strict->props( job_id => $zm_validator->test_id->required ); sub job_params { my ( $self, $params ) = @_; $params->{test_id} = delete $params->{job_id}; return $self->get_test_params( $params ); } $json_schemas{get_test_results} = { type => 'object', additionalProperties => 0, required => [ 'id', 'language' ], properties => { id => $zm_validator->test_id->required->compile, language => $zm_validator->language_tag, } }; sub get_test_results { my ( $self, $params ) = @_; my $result; eval{ my $locale = $self->_get_locale( $params ); my $translator; $translator = Zonemaster::Backend::Translator->instance(); my $previous_locale = $translator->locale; if ( !$translator->locale( $locale ) ) { die "Failed to set locale: $locale"; } eval { $translator->data } if $translator; # Provoke lazy loading of translation data my @zm_results; my %testcases; my $test_info = $self->{db}->test_results( $params->{id} ); foreach my $test_res ( @{ $test_info->{results} } ) { my $res; if ( $test_res->{module} eq 'Nameserver' ) { $res->{ns} = ( $test_res->{args}->{ns} ) ? ( $test_res->{args}->{ns} ) : ( 'All' ); } elsif ($test_res->{module} eq 'SYSTEM' && $test_res->{tag} eq 'POLICY_DISABLED' && $test_res->{args}->{name} eq 'Example' ) { next; } $res->{module} = $test_res->{module}; $res->{message} = $translator->translate_tag( $test_res ) . "\n"; $res->{message} =~ s/,/, /isg; $res->{message} =~ s/;/; /isg; $res->{level} = $test_res->{level}; $res->{testcase} = $test_res->{testcase} // 'UNSPECIFIED'; $testcases{$res->{testcase}} = $translator->test_case_description($res->{testcase}); if ( $test_res->{module} eq 'SYSTEM' ) { if ( $res->{message} =~ /policy\.json/ ) { my ( $policy ) = ( $res->{message} =~ /\s(\/.*)$/ ); if ( $policy ) { my $policy_description = 'DEFAULT POLICY'; $policy_description = 'SOME OTHER POLICY' if ( $policy =~ /some\/other\/policy\/path/ ); $res->{message} =~ s/$policy/$policy_description/; } else { $res->{message} = 'UNKNOWN POLICY FORMAT'; } } elsif ( $res->{message} =~ /config\.json/ ) { my ( $config ) = ( $res->{message} =~ /\s(\/.*)$/ ); if ( $config ) { my $config_description = 'DEFAULT CONFIGURATION'; $config_description = 'SOME OTHER CONFIGURATION' if ( $config =~ /some\/other\/configuration\/path/ ); $res->{message} =~ s/$config/$config_description/; } else { $res->{message} = 'UNKNOWN CONFIG FORMAT'; } } } push( @zm_results, $res ); } $result = $test_info; $result->{testcase_descriptions} = \%testcases; $result->{results} = \@zm_results; $translator->locale( $previous_locale ); $result = $test_info; $result->{results} = \@zm_results; }; if ($@) { handle_exception( $@ ); } return $result; } # Experimental $json_schemas{job_results} = { type => 'object', additionalProperties => 0, required => [ 'job_id', 'language' ], properties => { job_id => $zm_validator->test_id->required->compile, language => $zm_validator->language_tag, } }; sub job_results { my ( $self, $params ) = @_; $params->{id} = delete $params->{job_id}; my $result = $self->get_test_results( $params ); return { created_at => $result->{created_at}, job_id => $result->{hash_id}, results => $result->{results}, params => $result->{params}, testcase_descriptions => $result->{testcase_descriptionsd}, }; } $json_schemas{get_test_history} = { type => 'object', additionalProperties => 0, required => [ 'frontend_params' ], properties => { offset => joi->integer->min(0)->compile, limit => joi->integer->min(0)->compile, filter => joi->string->regex('^(?:all|delegated|undelegated)$')->compile, frontend_params => { type => 'object', additionalProperties => 0, required => [ 'domain' ], properties => { domain => $zm_validator->domain_name } } } }; sub get_test_history { my ( $self, $params ) = @_; my $results; eval { $params->{offset} //= 0; $params->{limit} //= 200; $params->{filter} //= "all"; $results = $self->{db}->get_test_history( $params ); my @results = map { { %$_, undelegated => $_->{undelegated} ? JSON::PP::true : JSON::PP::false } } @$results; $results = \@results; }; if ($@) { handle_exception( $@ ); } return $results; } # Experimental $json_schemas{domain_history} = { type => 'object', additionalProperties => 0, required => [ 'params' ], properties => { offset => joi->integer->min(0)->compile, limit => joi->integer->min(0)->compile, filter => joi->string->regex('^(?:all|delegated|undelegated)$')->compile, params => { type => 'object', additionalProperties => 0, required => [ 'domain' ], properties => { domain => $zm_validator->domain_name } } } }; sub domain_history { my ( $self, $params ) = @_; $params->{frontend_params} = delete $params->{params}; my $results = $self->get_test_history( $params ); return { history => [ map { { job_id => $_->{id}, created_at => $_->{created_at}, overall_result => $_->{overall_result}, undelegated => $_->{undelegated}, } } @$results ], }; } $json_schemas{add_api_user} = joi->object->strict->props( username => $zm_validator->username->required, api_key => $zm_validator->api_key->required, ); sub add_api_user { my ( $self, $params, undef, $remote_ip ) = @_; my $result = 0; eval { my $allow = 0; if ( defined $remote_ip ) { $allow = 1 if ( $remote_ip eq '::1' || $remote_ip eq '127.0.0.1' || $remote_ip eq '::ffff:127.0.0.1' ); } else { $allow = 1; } if ( $allow ) { $result = 1 if ( $self->{db}->add_api_user( $params->{username}, $params->{api_key} ) eq '1' ); } else { die Zonemaster::Backend::Error::PermissionDenied->new( message => 'Call to "add_api_user" method not permitted from a remote IP', data => { remote_ip => $remote_ip } ); } }; if ($@) { handle_exception( $@ ); } return $result; } # Experimental $json_schemas{user_create} = $json_schemas{add_api_user}; sub user_create { my $result = { success => add_api_user( @_ ) }; return $result; } $json_schemas{add_batch_job} = { type => 'object', additionalProperties => 0, required => [ 'username', 'api_key', 'domains' ], properties => { username => $zm_validator->username->required->compile, api_key => $zm_validator->api_key->required->compile, domains => { type => "array", additionalItems => 0, items => $zm_validator->domain_name, minItems => 1 }, test_params => { type => 'object', additionalProperties => 0, properties => { ipv4 => joi->boolean->compile, ipv6 => joi->boolean->compile, nameservers => { type => 'array', items => $zm_validator->nameserver }, ds_info => { type => 'array', items => $zm_validator->ds_info }, profile => $zm_validator->profile_name, client_id => $zm_validator->client_id->compile, client_version => $zm_validator->client_version->compile, config => joi->string->compile, priority => $zm_validator->priority->compile, queue => $zm_validator->queue->compile, } } } }; sub add_batch_job { my ( $self, $params ) = @_; my $results; eval { $params->{test_params}{profile} //= "default"; $params->{test_params}{priority} //= 5; $params->{test_params}{queue} //= 0; my $profile = $self->{_profiles}{ $params->{test_params}{profile} }; $params->{test_params}{ipv4} //= $profile->get( "net.ipv4" ); $params->{test_params}{ipv6} //= $profile->get( "net.ipv6" ); $results = $self->{db}->add_batch_job( $params ); }; if ($@) { handle_exception( $@ ); } return $results; } # Experimental $json_schemas{batch_create} = { type => 'object', additionalProperties => 0, required => [ 'username', 'api_key', 'domains' ], properties => { username => $zm_validator->username->required->compile, api_key => $zm_validator->api_key->required->compile, domains => { type => "array", additionalItems => 0, items => $zm_validator->domain_name, minItems => 1 }, job_params => { type => 'object', additionalProperties => 0, properties => { ipv4 => joi->boolean->compile, ipv6 => joi->boolean->compile, nameservers => { type => 'array', items => $zm_validator->nameserver }, ds_info => { type => 'array', items => $zm_validator->ds_info }, profile => $zm_validator->profile_name, client_id => $zm_validator->client_id->compile, client_version => $zm_validator->client_version->compile, config => joi->string->compile, priority => $zm_validator->priority->compile, queue => $zm_validator->queue->compile, } } } }; sub batch_create { my ( $self, $params ) = @_; $params->{test_params} = delete $params->{job_params}; my $result = { batch_id => $self->add_batch_job( $params ) }; return $result; } $json_schemas{batch_status} = { type => 'object', additionalProperties => 0, required => [ 'batch_id' ], properties => { batch_id => $zm_validator->batch_id->required, list_waiting_tests => joi->boolean->compile, list_running_tests => joi->boolean->compile, list_finished_tests => joi->boolean->compile, } }; sub batch_status { my ( $self, $params ) = @_; my $result; eval { $result = $self->{db}->batch_status($params); }; if ($@) { handle_exception( $@ ); } return $result; } sub _get_locale { my ( $self, $params ) = @_; my @error; if ( ref $params ne 'HASH' ) { return undef; } my $language = $params->{language}; if ( !defined $language ) { return undef; } my %locales = $self->{config}->LANGUAGE_locale; my $locale = $locales{$language}; if ( !defined $locale ) { return undef; } return $locale . '.UTF-8'; } sub _set_error_message_locale { my ( $self, $params ) = @_; my @error_response = (); my $locale = $self->_get_locale( $params ); if (not defined $locale or $locale eq "") { # Don't translate message if locale is not defined $locale = "C"; } # Use POSIX implementation instead of Locale::Messages wrapper setlocale( LC_ALL, $locale ); return @error_response; } my $rpc_request = joi->object->props( jsonrpc => joi->string->required, method => $zm_validator->jsonrpc_method()->required, id => joi->type([qw(null number string)])); sub jsonrpc_validate { my ( $self, $jsonrpc_request ) = @_; my @error_rpc = $rpc_request->validate($jsonrpc_request); if ((ref($jsonrpc_request) eq 'HASH' && !exists $jsonrpc_request->{id}) || @error_rpc) { $self->_set_error_message_locale; return { jsonrpc => '2.0', id => undef, error => { code => '-32600', message => 'The JSON sent is not a valid request object.', data => "@error_rpc" } } } my $method_schema = $json_schemas{$jsonrpc_request->{method}}; if (blessed $method_schema) { $method_schema = $method_schema->compile; } # The "params" key of the JSONRPC object is optional per the JSONRPC 2.0 # specification, but if the method being called requires at least one # parameter, omitting it is an error. if ( exists $method_schema->{required} and not exists $jsonrpc_request->{params} ) { return { jsonrpc => '2.0', id => $jsonrpc_request->{id}, error => { code => '-32602', message => "Missing 'params' object", } }; } elsif ( exists $jsonrpc_request->{params} ) { my @error_response = $self->validate_params($method_schema, $jsonrpc_request->{params}); if ( scalar @error_response ) { return { jsonrpc => '2.0', id => $jsonrpc_request->{id}, error => { code => '-32602', message => decode_utf8(__ 'Invalid method parameter(s).'), data => \@error_response } }; } } return ''; } sub validate_params { my ( $self, $method_schema, $params ) = @_; my @error_response = (); push @error_response, $self->_set_error_message_locale( $params ); if (blessed $method_schema) { $method_schema = $method_schema->compile; } my $jv = JSON::Validator::Schema::Draft7->new->coerce('booleans,numbers,strings')->data($method_schema); $jv->formats(Zonemaster::Backend::Validator::formats( $self->{config} )); my @json_validation_error = $jv->validate( $params ); # Customize error message from json validation foreach my $err ( @json_validation_error ) { my $message = $err->message; my @details = @{$err->details}; # Handle 'required' errors globally so it does not get overwritten if ($details[1] eq 'required') { $message = N__ 'Missing property'; } else { my @path = split '/', $err->path, -1; shift @path; # first item is an empty string my $found = 1; my $data = Mojo::JSON::Pointer->new($method_schema); foreach my $p (@path) { if ( $data->contains("/properties/$p") ) { $data = $data->get("/properties/$p") } elsif ( $p =~ /^\d+$/ and $data->contains("/items") ) { $data = $data->get("/items") } else { $found = 0; last; } $data = Mojo::JSON::Pointer->new($data); } if ($found and exists $data->data->{'x-error-message'}) { $message = $data->data->{'x-error-message'}; } } push @error_response, { path => $err->path, message => $message }; } # Translate messages @error_response = map { { %$_, ( message => decode_utf8 __ $_->{message} ) } } @error_response; return @error_response; } 1;