From 93c70aad93db930ef32f5f658b0cbc73f707ada5 Mon Sep 17 00:00:00 2001 From: Mats Dufberg Date: Mon, 2 Mar 2026 17:12:47 +0000 Subject: [PATCH 01/34] Updates Swedish PO file --- share/sv.po | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/share/sv.po b/share/sv.po index e409b0247..d08059e28 100644 --- a/share/sv.po +++ b/share/sv.po @@ -2,8 +2,8 @@ msgid "" msgstr "" "Project-Id-Version: 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-21 21:29+0000\n" -"PO-Revision-Date: 2023-05-21 21:29+0000\n" +"POT-Creation-Date: 2026-03-02 17:08+0000\n" +"PO-Revision-Date: 2026-03-02 17:09+0000\n" "Last-Translator: mats.dufberg@iis.se\n" "Language-Team: Zonemaster project\n" "Language: sv\n" @@ -57,25 +57,6 @@ msgstr "Keytag måste vara ett positivt heltal" msgid "Domain name required" msgstr "Domännamn är obligatoriskt" -msgid "The domain name is IDNA invalid" -msgstr "Domännamnet är ogiltigt enligt IDN-standarden" - -msgid "" -"The domain name contains non-ascii characters and IDNA support is not " -"installed" -msgstr "" -"Domännamnet innehåller icke-ASCII-tecken, men stöd för IDN är inte " -"installerat" - -msgid "The domain name character(s) are not supported" -msgstr "Domännamnstecken stöds inte" - -msgid "The domain name contains consecutive dots" -msgstr "Domännamnet innehåller flera punkter i följd" - -msgid "The domain name or label is too long" -msgstr "Domännamnet eller en domännamnsdel är för långt" - msgid "Invalid language tag format" msgstr "Ogiltigt format på språkkoden" From 8a09d98e787bd3ea6230eabf5465b5cc1a17efa6 Mon Sep 17 00:00:00 2001 From: Marc van der Wal Date: Wed, 8 Apr 2026 11:43:10 +0200 Subject: [PATCH 02/34] CI: Target Perl version 5.42 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The latest version of Perl is now 5.42. Let’s bump the topmost version in the list of Perls tested against in CI to that. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48f7b314b..2e0712f2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: strategy: matrix: db: [sqlite, mysql, postgresql] - perl: ['5.40'] + perl: ['5.42'] include: - db: sqlite perl: '5.36' From 51be6e52e43c07b99dd2fb8ebd3c013bb278ad6f Mon Sep 17 00:00:00 2001 From: Michael Timbert Date: Wed, 20 May 2026 10:42:09 +0200 Subject: [PATCH 03/34] move the Docker-related files to the docker directory --- Makefile.PL | 2 +- Dockerfile => docker/Dockerfile | 2 +- zonemaster_launch => docker/zonemaster_launch | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename Dockerfile => docker/Dockerfile (98%) rename zonemaster_launch => docker/zonemaster_launch (100%) diff --git a/Makefile.PL b/Makefile.PL index 7815b8f52..35f66d2c3 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -79,7 +79,7 @@ sub MY::postamble { my $docker = <<'END_DOCKER'; docker-build: - docker build --tag zonemaster/backend:local --build-arg version=$(VERSION) . + docker build -f docker/Dockerfile --tag zonemaster/backend:local --build-arg version=$(VERSION) . docker-tag-version: docker tag zonemaster/backend:local zonemaster/backend:$(VERSION) diff --git a/Dockerfile b/docker/Dockerfile similarity index 98% rename from Dockerfile rename to docker/Dockerfile index ccc050d00..056bc58af 100644 --- a/Dockerfile +++ b/docker/Dockerfile @@ -78,7 +78,7 @@ RUN install -v -m 755 -o zonemaster -g zonemaster -d /var/lib/zonemaster USER zonemaster RUN $(perl -MFile::ShareDir -le 'print File::ShareDir::dist_dir("Zonemaster-Backend")')/create_db.pl USER zonemaster -COPY zonemaster_launch /usr/local/bin +COPY docker/zonemaster_launch /usr/local/bin USER root ARG S6_OVERLAY_VERSION=3.2.1.0 diff --git a/zonemaster_launch b/docker/zonemaster_launch similarity index 100% rename from zonemaster_launch rename to docker/zonemaster_launch From a5358b655183e2bc4f6e42d40b2046d3383d9ac7 Mon Sep 17 00:00:00 2001 From: Michael Timbert Date: Wed, 20 May 2026 11:35:05 +0200 Subject: [PATCH 04/34] add comment to zonemaster_launch script --- docker/zonemaster_launch | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docker/zonemaster_launch b/docker/zonemaster_launch index 0d62eb424..b5de5bd19 100755 --- a/docker/zonemaster_launch +++ b/docker/zonemaster_launch @@ -1,5 +1,27 @@ #!/bin/sh +# zonemaster_launch - Docker entrypoint script for Zonemaster Backend +# +# This script serves as the entrypoint for the Zonemaster Backend Docker container. +# It acts as a command dispatcher that starts different Zonemaster components based +# on the first argument passed to the container. +# +# How it works: +# - The script reads the first argument ($1) and uses a case statement to determine +# which component to launch. +# - For CLI tools (cli, zmb, zmtest), it passes any additional arguments to the +# respective command and exits after completion. +# - For services (rpcapi, testagent), it starts the process in the foreground, +# keeping the container running. +# - The 'full' option executes /init (from s6-overlay) which starts both the +# RPC API and testagent services together. +# - If no valid argument is provided, it displays a help message with available options. +# +# Usage: +# docker run zonemaster/zonemaster-backend cli # Run CLI command +# docker run zonemaster/zonemaster-backend rpcapi # Start RPC API server +# docker run zonemaster/zonemaster-backend testagent # Start test agent +# docker run zonemaster/zonemaster-backend full # Start all services case $1 in From ddf342ea8a5d6d47da40abf27d77862c9071b17d Mon Sep 17 00:00:00 2001 From: Michael Timbert Date: Wed, 20 May 2026 13:54:06 +0200 Subject: [PATCH 05/34] improve help message --- docker/zonemaster_launch | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docker/zonemaster_launch b/docker/zonemaster_launch index b5de5bd19..539c810e6 100755 --- a/docker/zonemaster_launch +++ b/docker/zonemaster_launch @@ -54,13 +54,14 @@ case $1 in ;; *) echo "'$1' is not a valid option. - Available options: - - cli : pass argument to zonemaster-cli then quit - - full : start both rpcapi & testagent - - rpcapi - - testagent - - zmb - - zmtest - " +Available options: + - cli : pass argument to zonemaster-cli + - full : start both rpcapi & testagent + - rpcapi : start RPC API server + - testagent : start test agent + - zmb : run zmb command + - zmtest : run zmtest command" + exit 1 ;; + esac; From 3d8bfa08f5c64e388dc0211fe527bd6de87a92a2 Mon Sep 17 00:00:00 2001 From: Michael Timbert Date: Wed, 20 May 2026 15:32:18 +0200 Subject: [PATCH 06/34] add docker/ to MANIFEST.SKIP --- MANIFEST.SKIP | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MANIFEST.SKIP b/MANIFEST.SKIP index 6e9a2c3c2..9a0a66a62 100644 --- a/MANIFEST.SKIP +++ b/MANIFEST.SKIP @@ -5,8 +5,8 @@ ^.*\.log ^.*\.swp$ ^MANIFEST\.SKIP$ -^Dockerfile$ -^zonemaster_launch$ +^docker/Dockerfile$ +^docker/zonemaster_launch$ ^\.github/ ^docs/internal-documentation/ \.po$ From 21b79580eebdb5248d116542591300f031dd638a Mon Sep 17 00:00:00 2001 From: Mats Dufberg Date: Mon, 25 May 2026 13:48:57 +0200 Subject: [PATCH 07/34] First itiration --- lib/Zonemaster/Backend/Config.pm | 88 +++++++++++++- lib/Zonemaster/Backend/RPCAPI.pm | 161 +++++++++++++++++++++++++- lib/Zonemaster/Backend/Validator.pm | 82 +++++++++++++ script/zmb | 37 ++++++ script/zonemaster_backend_rpcapi.psgi | 5 + share/backend_config.ini | 11 ++ 6 files changed, 381 insertions(+), 3 deletions(-) diff --git a/lib/Zonemaster/Backend/Config.pm b/lib/Zonemaster/Backend/Config.pm index 78eba7b8c..6fba4ef27 100644 --- a/lib/Zonemaster/Backend/Config.pm +++ b/lib/Zonemaster/Backend/Config.pm @@ -182,6 +182,7 @@ sub parse { my $obj = bless( {}, $class ); $obj->{_public_profiles} = {}; $obj->{_private_profiles} = {}; + $obj->{_tld_url_override} = {}; my $ini = Config::IniFiles->new( -file => \$text ) or die "Failed to parse config: " . join( '; ', @Config::IniFiles::errors ) . "\n"; @@ -198,7 +199,18 @@ sub parse { # Validate section names { - my %sections = map { $_ => 1 } ( 'DB', 'MYSQL', 'POSTGRESQL', 'SQLITE', 'LANGUAGE', 'PUBLIC PROFILES', 'PRIVATE PROFILES', 'ZONEMASTER', 'METRICS', 'RPCAPI' ); + my %sections = map { $_ => 1 } ( 'DB', + 'MYSQL', + 'POSTGRESQL', + 'SQLITE', + 'LANGUAGE', + 'PUBLIC PROFILES', + 'PRIVATE PROFILES', + 'ZONEMASTER', + 'METRICS', + 'RPCAPI', + 'TLD URL SETTINGS', + 'TLD URL OVERRIDE'); for my $section ( $ini->Sections ) { if ( !exists $sections{$section} ) { die "config: unrecognized section: $section\n"; @@ -210,6 +222,9 @@ sub parse { $obj->_set_DB_polling_interval( '0.5' ); $obj->_set_MYSQL_port( '3306' ); $obj->_set_POSTGRESQL_port( '5432' ); + $obj->_set_TLD_URL_SETTINGS_enable_tld_url( 'true' ); + $obj->_set_TLD_URL_SETTINGS_lookup_timeout( '3' ); + $obj->_set_TLD_URL_SETTINGS_include_source( 'true' ); $obj->_set_ZONEMASTER_max_zonemaster_execution_time( '600' ); $obj->_set_ZONEMASTER_number_of_processes_for_frontend_testing( '20' ); $obj->_set_ZONEMASTER_number_of_processes_for_batch_testing( '20' ); @@ -277,6 +292,15 @@ sub parse { if ( defined( my $value = $get_and_clear->( 'SQLITE', 'database_file' ) ) ) { $obj->_set_SQLITE_database_file( $value ); } + if ( defined( my $value = $get_and_clear->( 'TLD URL SETTINGS', 'enable_tld_url' ) ) ) { + $obj->_set_TLD_URL_SETTINGS_enable_tld_url( $value ); + } + if ( defined( my $value = $get_and_clear->( 'TLD URL SETTINGS', 'lookup_timeout' ) ) ) { + $obj->_set_TLD_URL_SETTINGS_lookup_timeout( $value ); + } + if ( defined( my $value = $get_and_clear->( 'TLD URL SETTINGS', 'include_source' ) ) ) { + $obj->_set_TLD_URL_SETTINGS_include_source( $value ); + } if ( defined( my $value = $get_and_clear->( 'ZONEMASTER', 'max_zonemaster_execution_time' ) ) ) { $obj->_set_ZONEMASTER_max_zonemaster_execution_time( $value ); } @@ -553,6 +577,35 @@ Returns a hash mapping profile names to profile paths. The profile names are normalized to lowercase. Profile paths are always strings (contrast with L). +=head2 TLD_URL_SETTINGS_enable_tld_url + +Get the value of L. + +Returns a boolean. + + +=head2 TLD_URL_SETTINGS_lookup_timeout + +Get the value of L. + +Returns a positive integer. + + +=head2 TLD_URL_SETTINGS_include_source + +Get the value of L. + +Returns a boolean. + + +=head2 TLD_URL_OVERRIDE + +Get the set of L. + +Returns a hash mapping TLD label (ASCII or A-label) to URL string or blocking policy. +The TLD label is normalized to lowercase. See the meaning of +L. + =head2 ZONEMASTER_max_zonemaster_execution_time @@ -660,6 +713,10 @@ sub SQLITE_database_file { return $_[0]->{_SQLITE sub LANGUAGE_locale { return %{ $_[0]->{_LANGUAGE_locale} }; } sub PUBLIC_PROFILES { return %{ $_[0]->{_public_profiles} }; } sub PRIVATE_PROFILES { return %{ $_[0]->{_private_profiles} }; } +sub TLD_URL_SETTINGS_enable_tld_url { return $_[0]->{_TLD_URL_SETTINGS_enable_tld_url}; } +sub TLD_URL_SETTINGS_lookup_timeout { return $_[0]->{_TLD_URL_SETTINGS_lookup_timeout}; } +sub TLD_URL_SETTINGS_include_source { return $_[0]->{_TLD_URL_SETTINGS_include_source}; } +sub TLD_URL_OVERRIDE { return %{ $_[0]->{_tld_url_override} }; } sub ZONEMASTER_max_zonemaster_execution_time { return $_[0]->{_ZONEMASTER_max_zonemaster_execution_time}; } sub ZONEMASTER_lock_on_queue { return $_[0]->{_ZONEMASTER_lock_on_queue}; } sub ZONEMASTER_number_of_processes_for_frontend_testing { return $_[0]->{_ZONEMASTER_number_of_processes_for_frontend_testing}; } @@ -686,6 +743,9 @@ UNITCHECK { _create_setter( '_set_POSTGRESQL_password', '_POSTGRESQL_password', \&untaint_password ); _create_setter( '_set_POSTGRESQL_database', '_POSTGRESQL_database', \&untaint_postgresql_ident ); _create_setter( '_set_SQLITE_database_file', '_SQLITE_database_file', \&untaint_abs_path ); + _create_setter( '_set_TLD_URL_SETTINGS_enable_tld_url', '_TLD_URL_SETTINGS_enable_tld_url', \&untaint_json_bool ); + _create_setter( '_set_TLD_URL_SETTINGS_lookup_timeout', '_TLD_URL_SETTINGS_lookup_timeout', \&untaint_strictly_positive_int ); + _create_setter( '_set_TLD_URL_SETTINGS_include_source', '_TLD_URL_SETTINGS_include_source', \&untaint_json_bool ); _create_setter( '_set_ZONEMASTER_max_zonemaster_execution_time', '_ZONEMASTER_max_zonemaster_execution_time', \&untaint_strictly_positive_int ); _create_setter( '_set_ZONEMASTER_lock_on_queue', '_ZONEMASTER_lock_on_queue', \&untaint_non_negative_int ); _create_setter( '_set_ZONEMASTER_number_of_processes_for_frontend_testing', '_ZONEMASTER_number_of_processes_for_frontend_testing', \&untaint_strictly_positive_int ); @@ -699,6 +759,7 @@ UNITCHECK { _create_setter( '_set_RPCAPI_enable_add_batch_job', '_RPCAPI_enable_add_batch_job', \&untaint_bool ); } + =head2 new_DB Create a new database adapter object according to configuration. @@ -888,6 +949,31 @@ sub _add_private_profile { return; } +sub _add_tld_url_override { + my ( $self, $tld, $value ) = @_; + + unless ( untaint_tld_label( $tld ) ) { + die "Invalid TLD label in TLD URL OVERRIDE section: $tld\n"; + } + + if ( exists $self->{_tld_url_override}{$tld} ) { + die "TLD label not unique: $tld\n"; + } + + unless ( untaint_tld_value( $value ) ) { + die "Envalid value for a TLD label key: $value\n"; + } + + if (untaint_tld_url_no_path( $value )) { + $value = $value . '/'; + } + + $self->{_tld_url_override}{$tld} = $value; + return; +} + + + # Create a setter method with a given name using the given field and validator sub _create_setter { my ( $setter, $field, $validate ) = @_; diff --git a/lib/Zonemaster/Backend/RPCAPI.pm b/lib/Zonemaster/Backend/RPCAPI.pm index e7e8f392f..da953535c 100644 --- a/lib/Zonemaster/Backend/RPCAPI.pm +++ b/lib/Zonemaster/Backend/RPCAPI.pm @@ -9,7 +9,8 @@ use DBI qw(:utils); use Digest::MD5 qw(md5_hex); use File::Slurp qw(append_file); use HTML::Entities; -use JSON::PP; +use HTTP::Tiny; +use JSON::PP qw(decode_json); use JSON::Validator::Joi; use Log::Any qw($log); use Mojo::JSON::Pointer; @@ -28,7 +29,7 @@ use Zonemaster::Engine::Recursor; use Zonemaster::Backend; use Zonemaster::Backend::Config; use Zonemaster::Backend::Translator; -use Zonemaster::Backend::Validator; +use Zonemaster::Backend::Validator qw[untaint_tld_value]; use Zonemaster::Backend::Errors; my $zm_validator = Zonemaster::Backend::Validator->new; @@ -174,6 +175,162 @@ sub conf_languages { return $result; } + + + + +$json_schemas{get_tld_url} = { + type => 'object', + additionalProperties => 0, + required => [ 'domain' ], + properties => { + domain => $zm_validator->domain_name + } +}; +sub get_tld_url { + my ( $self, $params ) = @_; + my $domain = $params->{domain}; + my %result = {}; + my $timeout = $self->{config}->TLD_URL_SETTINGS_lookup_timeout; + my $iana_rdap_url_base = "https://rdap.iana.org/domain"; + my $dns_name_base_txt_record = "_url._zonemaster"; + my $include_source = $self->{config}->TLD_URL_SETTINGS_include_source; + + # Empty response if the function is not enabled + unless ( $self->{config}->TLD_URL_SETTINGS_enable_tld_url ) { + return \%result; + } + + # Normalize domain + $domain =~ s/\.?$//; # Root zone will be an empty string + + # Empty response if the domain is the root zone + unless ( $domain ) { + return \%result; + } + + # Extract TLD from domain + my $tld; + if ( $domain =~ /\.([a-z][a-z]+|xn--[a-z0-9-][a-z0-9-]+)$/ ) { + $tld = $1; + } + + # If $tld is empty the domain name was not valid and we just do an empty return + unless ( $tld ) { + return \%result; + } + + # Empty response if the domain is just a TLD + if ( $domain =~ /^[^.]+$/) { + return \%result; + } + + # Fetch any override from the configuration + my %overrides = $self->{config}->TLD_URL_OVERRIDE; + my $url; + if ( exists $overrides{$tld} ) { + if ( $overrides{$tld} eq '[BLOCK]' ) { + return \%result; + } else { + $url = $overrides{$tld}; + $url =~ s/\[DOMAIN\]/$domain/; + $result{url} = $url; + $result{source} = "BACKEND CONF" if $self->{config}->TLD_URL_SETTINGS_include_source; + return \%result; + } + } + + # Do a lookup of "_url._zonemaster.$tld" + my $txtlink = get_tld_url_from_txt_record( $tld, $dns_name_base_txt_record, $domain, $timeout ); + if ( $txtlink ) { + if ( $txtlink eq '[BLOCK]' ) { + return \%result; + } else { + $result{url} = $txtlink; + $result{source} = "TXT RECORD" if $self->{config}->TLD_URL_SETTINGS_include_source; + return \%result; + } + + + # Do an IANA RDAP lookup + my $rdaplink = get_tld_url_from_rdap( 'na', $iana_rdap_url_base, $timeout ); + if ($rdaplink) { + $result{url} = $rdaplink; + $result{source} = "IANA RDAP" if $self->{config}->TLD_URL_SETTINGS_include_source; + return \%result; + } + + return \%result; +} + +sub get_tld_url_from_txt_record { + my ($tld, # TLD to be looked up + $namebase, # Name base of TXT record + $dom, # Domain TLD is extracted from + $to, # Time out value + ) = @_; + + my $name = $namebase . '.' . $tld; + my $packet; + + eval { + local $SIG{ALRM} = sub { die "alarm\n" }; + alarm $to; + $packet = Zonemaster::Engine::Recursor->recurse( $name, 'TXT' ); + alarm 0; + }; + # Use $packet if defined + if ( $packet and $packet->rcode eq q{NOERROR} ) { + my @txt_rrs = $packet->get_records_for_name( q{TXT}, $name ); + if ( scalar ( @txt_rrs ) == 1 ) { # Ignore all if more than one + if ( untaint_tld_value( $txt_rrs[0] ) ) { + $txt_rrs[0] =~ s/\[DOMAIN\]/$dom/; + return $txt_rrs[0]; + } + } + } + } + return ''; +} + +sub get_tld_url_from_rdap { + my ($tld, $urlbase, $timeout) = @_; + my $url = $urlbase . '/' . $tld; + my $response; + my @links = (); + my $link = ''; + + eval { + local $SIG{ALRM} = sub { die "alarm\n" }; + alarm $timeout; + $response = HTTP::Tiny->new->get($url); + alarm 0; + }; + if ($@) { + if ( $@ eq "alarm\n" ) { + handle_exception( "Timeout looking $url up" ); + } else { + handle_exception( "Unexpected error looking $url up: $@" ); + } + } + if ($response->{success}) { + my $data = decode_json($response->{content}); + @links = map { $_->{href} } + grep { ($_->{rel} // '') eq 'related' } + @{ $data->{links} // [] }; + }; + if (scalar @links > 0) { + $links[0] = $links[0] . '/' if untaint_tld_url_no_path ( $links[0] ); + $link = $links[0] if untaint_tld_url_with_path ( $links[0] ); + return $link; + + } else { + return $link; + } +} + + + $json_schemas{get_host_by_name} = { type => 'object', additionalProperties => 0, diff --git a/lib/Zonemaster/Backend/Validator.pm b/lib/Zonemaster/Backend/Validator.pm index 1d7f4f36c..3f9698d5d 100644 --- a/lib/Zonemaster/Backend/Validator.pm +++ b/lib/Zonemaster/Backend/Validator.pm @@ -19,6 +19,7 @@ use Zonemaster::LDNS; our @EXPORT_OK = qw( untaint_abs_path untaint_bool + untaint_json_bool untaint_engine_type untaint_ip_address untaint_ipv4_address @@ -34,17 +35,25 @@ our @EXPORT_OK = qw( untaint_profile_name untaint_strictly_positive_int untaint_strictly_positive_millis + untaint_tld_label + untaint_tld_value + untaint_tld_url_no_path + untaint_tld_url_with_path check_domain check_ip check_profile check_language_tag ); + + + our %EXPORT_TAGS = ( untaint => [ qw( untaint_abs_path untaint_bool + untaint_json_bool untaint_engine_type untaint_ip_address untaint_ipv4_address @@ -60,6 +69,10 @@ our %EXPORT_TAGS = ( untaint_profile_name untaint_strictly_positive_int untaint_strictly_positive_millis + untaint_tld_label + untaint_tld_value + untaint_tld_url_no_path + untaint_tld_url_with_path ) ], format => [ @@ -114,11 +127,26 @@ Readonly my $RELAXED_DOMAIN_NAME_RE => qr/^[.]$|^.{2,254}$/; Readonly my $TEST_ID_RE => qr/^[0-9a-f]{16}$/; Readonly my $USERNAME_RE => qr/^[a-z0-9-.@]{1,50}$/i; +# RE for URL for TLD +Readonly my $TLD_LABEL_RE => qr/^([a-z][a-z]+|xn--[a-z0-9-][a-z0-9-]+)$/i; # ASCII or A-label +Readonly my $TLD_BLOCK_RE => qr/^\[BLOCK\]$/; # Blocking policy +Readonly my $TLD_URL_NO_PATH_RE => qr/^(http|https):\/\/[a-z0-9][a-z0-9.-]*[a-z0-9]$/; # URL without path + # URL with path and possibly "[DOMAIN]" variable +Readonly my $TLD_URL_STRING_RE => qr/^(http|https):\/\/[a-z0-9][a-z0-9.-]*[a-z0-9]\/[a-zA-Z0-9\/=%_.&-]*(\[DOMAIN\])?[a-zA-Z0-9\/=%_.&-]*$/; +Readonly my $TLD_VALUE_RE => qr/^($TLD_BLOCK_RE|$TLD_URL_NO_PATH_RE|$TLD_URL_STRING_RE)$/; + # URL with path +Readonly my $TLD_URL_WITH_PATH_RE => qr/^(http|https):\/\/[a-z0-9][a-z0-9.-]*[a-z0-9]\/[a-zA-Z0-9\/=%_.&-]*$/; + # Boolean Readonly my $BOOL_TRUE_RE => qr/^(true|yes)$/i; Readonly my $BOOL_FALSE_RE => qr/^(false|no)$/i; Readonly my $BOOL_RE => qr/^$BOOL_TRUE_RE|$BOOL_FALSE_RE$/i; +# Boolean, only JSON values +Readonly my $BOOL_JSON_TRUE_RE => qr/^true$/i; +Readonly my $BOOL_JSON_FALSE_RE => qr/^false$/i; +Readonly my $BOOL_JSON_RE => qr/^$BOOL_JSON_TRUE_RE|$BOOL_JSON_FALSE_RE$/i; + sub joi { return JSON::Validator::Joi->new; } @@ -387,6 +415,51 @@ sub untaint_abs_path { return _untaint_pred( $value, \&file_name_is_absolute ); } +=head2 untaint_tld_label + +Accepts a TLD label, ASCII or IDN (A-label) + +=cut + +sub untaint_tld_label { + my ( $value ) = @_; + return _untaint_pat( $value, $TLD_LABEL_RE ); +} + +=head2 untaint_tld_value + +Accepts a URL string or blocking policy. See the meaning in the +L. + +=cut + +sub untaint_tld_value { + my ( $value ) = @_; + return _untaint_pat( $value, $TLD_VALUE_RE ); +} + +=head2 untaint_tld_url_no_path + +Returns true if the string contains a URL with no path. See +L. + +=cut + +sub untaint_tld_url_no_path { + my ( $value ) = @_; + return _untaint_pat( $value, $TLD_URL_NO_PATH_RE ); +} + +=head2 untaint_tld_url_with_path + +Accepts a URL for TLD. + +=cut + +sub untaint_tld_url_with_path { + my ( $value ) = @_; + return _untaint_pat( $value, $TLD_URL_WITH_PATH_RE );} + =head2 untaint_engine_type Accepts the strings C<"MySQL">, C<"PostgreSQL"> and C<"SQLite">, @@ -524,6 +597,15 @@ sub untaint_bool { return $ret; } +sub untaint_json_bool { + my ( $value ) = @_; + + my $ret; + $ret = 1 if defined _untaint_pat( $value, $BOOL_JSON_TRUE_RE ); + $ret = 0 if defined _untaint_pat( $value, $BOOL_JSON_FALSE_RE ); + return $ret; +} + sub _untaint_pat { my ( $value, @patterns ) = @_; diff --git a/script/zmb b/script/zmb index 2f5d9f638..0263bc140 100755 --- a/script/zmb +++ b/script/zmb @@ -658,6 +658,43 @@ sub cmd_batch_status { } +=head2 get_tld_url + zmb [GLOBAL OPTIONS] get_tld_url [OPTIONS] + + Options: + --domain DOMAIN_NAME + +"--domain" is mandatory. The domain name must be provided with ASCII only. Any +IDN labels must be as A-labels. + +The command provides a URL to the TLD that the DOMAIN_NAME belongs to, if +available and if policy permits. If DOMAIN_NAME is the root or a TLD then no +URL will be provided. +=cut + +sub cmd_get_tld_url { + my @opts = @_; + + my $opt_domain_name; + + GetOptionsFromArray( + \@opts, + 'domain-name|d=s' => \$opt_domain_name, + ) or pod2usage( 2 ); + + my %params; + $params{domain} = $opt_domain_name; + + return to_jsonrpc( + id => 1, + method => 'get_tld_url', + params => \%params, + ); +} + + + + sub show_commands { my %specials = ( man => 'Show the full manual page.', diff --git a/script/zonemaster_backend_rpcapi.psgi b/script/zonemaster_backend_rpcapi.psgi index 78813417f..7cefc9d9f 100644 --- a/script/zonemaster_backend_rpcapi.psgi +++ b/script/zonemaster_backend_rpcapi.psgi @@ -173,6 +173,11 @@ my $router = router { handler => $handler, action => "batch_status" }; + + connect "get_tld_url" => { + handler => $handler, + action => "get_tld_url" + }; }; if ( $config->RPCAPI_enable_user_create or $config->RPCAPI_enable_add_api_user ) { diff --git a/share/backend_config.ini b/share/backend_config.ini index 535228989..a1e62f108 100755 --- a/share/backend_config.ini +++ b/share/backend_config.ini @@ -48,3 +48,14 @@ locale = da_DK en_US es_ES fi_FI fr_FR nb_NO sl_SI sv_SE # Uncoment the following option to enable the metrics feature #statsd_host = localhost #statsd_port = 8125 + +[TLD URL SETTINGS] +#enable_tld_url = true +#lookup_timeout = 3 +#include_source = true + +[TLD URL OVERRIDE] +#xa = [BLOCK] +#xn--4cab6c = [BLOCK] +#xb = http://nic.xb/domain +#example = https://whoisweb.noc.example/domain/[DOMAIN] From e9236b945b1366a2b390368a9824f88f5c9f8dd2 Mon Sep 17 00:00:00 2001 From: Mats Dufberg Date: Mon, 25 May 2026 21:50:50 +0200 Subject: [PATCH 08/34] Second iteration --- lib/Zonemaster/Backend/Config.pm | 7 ++- lib/Zonemaster/Backend/RPCAPI.pm | 84 +++++++++++++++++++---------- lib/Zonemaster/Backend/Validator.pm | 4 +- 3 files changed, 63 insertions(+), 32 deletions(-) diff --git a/lib/Zonemaster/Backend/Config.pm b/lib/Zonemaster/Backend/Config.pm index 6fba4ef27..90247d0c7 100644 --- a/lib/Zonemaster/Backend/Config.pm +++ b/lib/Zonemaster/Backend/Config.pm @@ -359,6 +359,11 @@ sub parse { my $path = $get_and_clear->( 'PRIVATE PROFILES', $name ); $obj->_add_private_profile( $name, $path ); } + + for my $name ( $ini->Parameters( 'TLD URL OVERRIDE' ) ) { + my $path = $get_and_clear->( 'TLD URL OVERRIDE', $name ); + $obj->_add_tld_url_override( $name, $path ); + } # Check required propertys (part 2/2) if ( $obj->DB_engine eq 'MySQL' ) { @@ -964,7 +969,7 @@ sub _add_tld_url_override { die "Envalid value for a TLD label key: $value\n"; } - if (untaint_tld_url_no_path( $value )) { + if ( untaint_tld_url_no_path( $value )) { $value = $value . '/'; } diff --git a/lib/Zonemaster/Backend/RPCAPI.pm b/lib/Zonemaster/Backend/RPCAPI.pm index da953535c..c6311c13a 100644 --- a/lib/Zonemaster/Backend/RPCAPI.pm +++ b/lib/Zonemaster/Backend/RPCAPI.pm @@ -29,7 +29,7 @@ use Zonemaster::Engine::Recursor; use Zonemaster::Backend; use Zonemaster::Backend::Config; use Zonemaster::Backend::Translator; -use Zonemaster::Backend::Validator qw[untaint_tld_value]; +use Zonemaster::Backend::Validator qw[ untaint_tld_value untaint_tld_url_no_path untaint_tld_url_with_path ]; use Zonemaster::Backend::Errors; my $zm_validator = Zonemaster::Backend::Validator->new; @@ -175,10 +175,6 @@ sub conf_languages { return $result; } - - - - $json_schemas{get_tld_url} = { type => 'object', additionalProperties => 0, @@ -189,23 +185,37 @@ $json_schemas{get_tld_url} = { }; sub get_tld_url { my ( $self, $params ) = @_; - my $domain = $params->{domain}; - my %result = {}; + my $domain; + ( undef, $domain ) = normalize_name( trim_space ( $params->{domain} ) ); + + my %result; my $timeout = $self->{config}->TLD_URL_SETTINGS_lookup_timeout; my $iana_rdap_url_base = "https://rdap.iana.org/domain"; my $dns_name_base_txt_record = "_url._zonemaster"; my $include_source = $self->{config}->TLD_URL_SETTINGS_include_source; + #$result{DEBUG} = "DEBUG 0"; + #$result{DOMAIN} = $domain; + #return \%result; + # Empty response if the function is not enabled unless ( $self->{config}->TLD_URL_SETTINGS_enable_tld_url ) { + $result{DEBUG} = "DEBUG 1"; + $result{DOMAIN} = $domain; return \%result; } - # Normalize domain - $domain =~ s/\.?$//; # Root zone will be an empty string - # Empty response if the domain is the root zone - unless ( $domain ) { + if ( $domain eq '.' ) { + $result{DOMAIN} = $domain; + $result{DEBUG} = "DEBUG 2"; + return \%result; + } + + # Empty response if the domain is just a TLD + if ( $domain =~ /^[^.]+$/) { + $result{DOMAIN} = $domain; + $result{DEBUG} = "DEBUG 3"; return \%result; } @@ -217,11 +227,8 @@ sub get_tld_url { # If $tld is empty the domain name was not valid and we just do an empty return unless ( $tld ) { - return \%result; - } - - # Empty response if the domain is just a TLD - if ( $domain =~ /^[^.]+$/) { + $result{DOMAIN} = $domain; + $result{DEBUG} = "DEBUG 4"; return \%result; } @@ -230,10 +237,14 @@ sub get_tld_url { my $url; if ( exists $overrides{$tld} ) { if ( $overrides{$tld} eq '[BLOCK]' ) { + $result{DOMAIN} = $domain; + $result{DEBUG} = "DEBUG 5"; return \%result; } else { $url = $overrides{$tld}; - $url =~ s/\[DOMAIN\]/$domain/; + $url =~ s/\Q[DOMAIN]\E/$domain/; + $result{DOMAIN} = $domain; + $result{DEBUG} = "DEBUG 6"; $result{url} = $url; $result{source} = "BACKEND CONF" if $self->{config}->TLD_URL_SETTINGS_include_source; return \%result; @@ -244,22 +255,30 @@ sub get_tld_url { my $txtlink = get_tld_url_from_txt_record( $tld, $dns_name_base_txt_record, $domain, $timeout ); if ( $txtlink ) { if ( $txtlink eq '[BLOCK]' ) { + $result{DOMAIN} = $domain; + $result{DEBUG} = "DEBUG 7"; return \%result; } else { + $result{DOMAIN} = $domain; + $result{DEBUG} = "DEBUG 8"; $result{url} = $txtlink; $result{source} = "TXT RECORD" if $self->{config}->TLD_URL_SETTINGS_include_source; return \%result; + } } - # Do an IANA RDAP lookup - my $rdaplink = get_tld_url_from_rdap( 'na', $iana_rdap_url_base, $timeout ); + my $rdaplink = get_tld_url_from_rdap( $tld, $iana_rdap_url_base, $timeout ); if ($rdaplink) { + $result{DOMAIN} = $domain; + $result{DEBUG} = "DEBUG 9"; $result{url} = $rdaplink; $result{source} = "IANA RDAP" if $self->{config}->TLD_URL_SETTINGS_include_source; return \%result; } - + + $result{DEBUG} = "DEBUG 10"; + $result{DOMAIN} = $domain; return \%result; } @@ -272,7 +291,7 @@ sub get_tld_url_from_txt_record { my $name = $namebase . '.' . $tld; my $packet; - + eval { local $SIG{ALRM} = sub { die "alarm\n" }; alarm $to; @@ -282,12 +301,19 @@ sub get_tld_url_from_txt_record { # Use $packet if defined if ( $packet and $packet->rcode eq q{NOERROR} ) { my @txt_rrs = $packet->get_records_for_name( q{TXT}, $name ); - if ( scalar ( @txt_rrs ) == 1 ) { # Ignore all if more than one - if ( untaint_tld_value( $txt_rrs[0] ) ) { - $txt_rrs[0] =~ s/\[DOMAIN\]/$dom/; - return $txt_rrs[0]; - } - } + my @txt_rdata = map { $_->txtdata() } @txt_rrs; + + warn "DEBUG 8-B1", "@txt_rdata"; + + if ( scalar ( @txt_rdata ) == 1 ) { # Ignore all if more than one + my $txtr = $txt_rdata[0]; + warn "DEBUG 8-B2", $txtr; + if ( untaint_tld_value( $txtr ) ) { + warn "DEBUG 8-B3", $txtr; + $txtr =~ s/\Q[DOMAIN]\E/$dom/; + warn "DEBUG 8-B4", $txtr; + return $txtr; + } } } return ''; @@ -320,8 +346,8 @@ sub get_tld_url_from_rdap { @{ $data->{links} // [] }; }; if (scalar @links > 0) { - $links[0] = $links[0] . '/' if untaint_tld_url_no_path ( $links[0] ); - $link = $links[0] if untaint_tld_url_with_path ( $links[0] ); + $links[0] = $links[0] . '/' if untaint_tld_url_no_path( $links[0] ); + $link = $links[0] if untaint_tld_url_with_path( $links[0] ); return $link; } else { diff --git a/lib/Zonemaster/Backend/Validator.pm b/lib/Zonemaster/Backend/Validator.pm index 3f9698d5d..04353b5c3 100644 --- a/lib/Zonemaster/Backend/Validator.pm +++ b/lib/Zonemaster/Backend/Validator.pm @@ -132,10 +132,10 @@ Readonly my $TLD_LABEL_RE => qr/^([a-z][a-z]+|xn--[a-z0-9-][a-z0-9-]+) Readonly my $TLD_BLOCK_RE => qr/^\[BLOCK\]$/; # Blocking policy Readonly my $TLD_URL_NO_PATH_RE => qr/^(http|https):\/\/[a-z0-9][a-z0-9.-]*[a-z0-9]$/; # URL without path # URL with path and possibly "[DOMAIN]" variable -Readonly my $TLD_URL_STRING_RE => qr/^(http|https):\/\/[a-z0-9][a-z0-9.-]*[a-z0-9]\/[a-zA-Z0-9\/=%_.&-]*(\[DOMAIN\])?[a-zA-Z0-9\/=%_.&-]*$/; +Readonly my $TLD_URL_STRING_RE => qr/^(http|https):\/\/[a-z0-9][a-z0-9.-]*[a-z0-9]\/[a-zA-Z0-9\/=?%_.&-]*(\[DOMAIN\])?[a-zA-Z0-9\/=?%_.&-]*$/; Readonly my $TLD_VALUE_RE => qr/^($TLD_BLOCK_RE|$TLD_URL_NO_PATH_RE|$TLD_URL_STRING_RE)$/; # URL with path -Readonly my $TLD_URL_WITH_PATH_RE => qr/^(http|https):\/\/[a-z0-9][a-z0-9.-]*[a-z0-9]\/[a-zA-Z0-9\/=%_.&-]*$/; +Readonly my $TLD_URL_WITH_PATH_RE => qr/^(http|https):\/\/[a-z0-9][a-z0-9.-]*[a-z0-9]\/[a-zA-Z0-9\/=?%_.&-]*$/; # Boolean Readonly my $BOOL_TRUE_RE => qr/^(true|yes)$/i; From 1a6a7ea5b404d693440e427dbb3ecdf425e26bfe Mon Sep 17 00:00:00 2001 From: Mats Dufberg Date: Tue, 26 May 2026 16:40:45 +0200 Subject: [PATCH 09/34] Third iteration, all functions are implemented and seems to be working correctly --- lib/Zonemaster/Backend/RPCAPI.pm | 181 ++------------------- lib/Zonemaster/Backend/TLD_URL.pm | 255 ++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 168 deletions(-) create mode 100644 lib/Zonemaster/Backend/TLD_URL.pm diff --git a/lib/Zonemaster/Backend/RPCAPI.pm b/lib/Zonemaster/Backend/RPCAPI.pm index c6311c13a..44140d216 100644 --- a/lib/Zonemaster/Backend/RPCAPI.pm +++ b/lib/Zonemaster/Backend/RPCAPI.pm @@ -9,8 +9,7 @@ use DBI qw(:utils); use Digest::MD5 qw(md5_hex); use File::Slurp qw(append_file); use HTML::Entities; -use HTTP::Tiny; -use JSON::PP qw(decode_json); +use JSON::PP; use JSON::Validator::Joi; use Log::Any qw($log); use Mojo::JSON::Pointer; @@ -29,8 +28,9 @@ use Zonemaster::Engine::Recursor; use Zonemaster::Backend; use Zonemaster::Backend::Config; use Zonemaster::Backend::Translator; -use Zonemaster::Backend::Validator qw[ untaint_tld_value untaint_tld_url_no_path untaint_tld_url_with_path ]; +use Zonemaster::Backend::Validator; use Zonemaster::Backend::Errors; +use Zonemaster::Backend::TLD_URL; my $zm_validator = Zonemaster::Backend::Validator->new; our %json_schemas; @@ -175,6 +175,15 @@ sub conf_languages { return $result; } +=head2 get_tld_url + +Handles the RPCAPI with the same name. All the "dirty work" is done in +a separate subroutine Zonemaster::Backend::TLD_URL::process in a +separate Perl module. + +=cut + + $json_schemas{get_tld_url} = { type => 'object', additionalProperties => 0, @@ -187,175 +196,11 @@ sub get_tld_url { my ( $self, $params ) = @_; my $domain; ( undef, $domain ) = normalize_name( trim_space ( $params->{domain} ) ); - - my %result; - my $timeout = $self->{config}->TLD_URL_SETTINGS_lookup_timeout; - my $iana_rdap_url_base = "https://rdap.iana.org/domain"; - my $dns_name_base_txt_record = "_url._zonemaster"; - my $include_source = $self->{config}->TLD_URL_SETTINGS_include_source; - - #$result{DEBUG} = "DEBUG 0"; - #$result{DOMAIN} = $domain; - #return \%result; - - # Empty response if the function is not enabled - unless ( $self->{config}->TLD_URL_SETTINGS_enable_tld_url ) { - $result{DEBUG} = "DEBUG 1"; - $result{DOMAIN} = $domain; - return \%result; - } - - # Empty response if the domain is the root zone - if ( $domain eq '.' ) { - $result{DOMAIN} = $domain; - $result{DEBUG} = "DEBUG 2"; - return \%result; - } - - # Empty response if the domain is just a TLD - if ( $domain =~ /^[^.]+$/) { - $result{DOMAIN} = $domain; - $result{DEBUG} = "DEBUG 3"; - return \%result; - } - # Extract TLD from domain - my $tld; - if ( $domain =~ /\.([a-z][a-z]+|xn--[a-z0-9-][a-z0-9-]+)$/ ) { - $tld = $1; - } - - # If $tld is empty the domain name was not valid and we just do an empty return - unless ( $tld ) { - $result{DOMAIN} = $domain; - $result{DEBUG} = "DEBUG 4"; - return \%result; - } - - # Fetch any override from the configuration - my %overrides = $self->{config}->TLD_URL_OVERRIDE; - my $url; - if ( exists $overrides{$tld} ) { - if ( $overrides{$tld} eq '[BLOCK]' ) { - $result{DOMAIN} = $domain; - $result{DEBUG} = "DEBUG 5"; - return \%result; - } else { - $url = $overrides{$tld}; - $url =~ s/\Q[DOMAIN]\E/$domain/; - $result{DOMAIN} = $domain; - $result{DEBUG} = "DEBUG 6"; - $result{url} = $url; - $result{source} = "BACKEND CONF" if $self->{config}->TLD_URL_SETTINGS_include_source; - return \%result; - } - } - - # Do a lookup of "_url._zonemaster.$tld" - my $txtlink = get_tld_url_from_txt_record( $tld, $dns_name_base_txt_record, $domain, $timeout ); - if ( $txtlink ) { - if ( $txtlink eq '[BLOCK]' ) { - $result{DOMAIN} = $domain; - $result{DEBUG} = "DEBUG 7"; - return \%result; - } else { - $result{DOMAIN} = $domain; - $result{DEBUG} = "DEBUG 8"; - $result{url} = $txtlink; - $result{source} = "TXT RECORD" if $self->{config}->TLD_URL_SETTINGS_include_source; - return \%result; - } - } + return Zonemaster::Backend::TLD_URL::process( $self, $domain ); - # Do an IANA RDAP lookup - my $rdaplink = get_tld_url_from_rdap( $tld, $iana_rdap_url_base, $timeout ); - if ($rdaplink) { - $result{DOMAIN} = $domain; - $result{DEBUG} = "DEBUG 9"; - $result{url} = $rdaplink; - $result{source} = "IANA RDAP" if $self->{config}->TLD_URL_SETTINGS_include_source; - return \%result; - } - - $result{DEBUG} = "DEBUG 10"; - $result{DOMAIN} = $domain; - return \%result; -} - -sub get_tld_url_from_txt_record { - my ($tld, # TLD to be looked up - $namebase, # Name base of TXT record - $dom, # Domain TLD is extracted from - $to, # Time out value - ) = @_; - - my $name = $namebase . '.' . $tld; - my $packet; - - eval { - local $SIG{ALRM} = sub { die "alarm\n" }; - alarm $to; - $packet = Zonemaster::Engine::Recursor->recurse( $name, 'TXT' ); - alarm 0; - }; - # Use $packet if defined - if ( $packet and $packet->rcode eq q{NOERROR} ) { - my @txt_rrs = $packet->get_records_for_name( q{TXT}, $name ); - my @txt_rdata = map { $_->txtdata() } @txt_rrs; - - warn "DEBUG 8-B1", "@txt_rdata"; - - if ( scalar ( @txt_rdata ) == 1 ) { # Ignore all if more than one - my $txtr = $txt_rdata[0]; - warn "DEBUG 8-B2", $txtr; - if ( untaint_tld_value( $txtr ) ) { - warn "DEBUG 8-B3", $txtr; - $txtr =~ s/\Q[DOMAIN]\E/$dom/; - warn "DEBUG 8-B4", $txtr; - return $txtr; - } - } - } - return ''; } -sub get_tld_url_from_rdap { - my ($tld, $urlbase, $timeout) = @_; - my $url = $urlbase . '/' . $tld; - my $response; - my @links = (); - my $link = ''; - - eval { - local $SIG{ALRM} = sub { die "alarm\n" }; - alarm $timeout; - $response = HTTP::Tiny->new->get($url); - alarm 0; - }; - if ($@) { - if ( $@ eq "alarm\n" ) { - handle_exception( "Timeout looking $url up" ); - } else { - handle_exception( "Unexpected error looking $url up: $@" ); - } - } - if ($response->{success}) { - my $data = decode_json($response->{content}); - @links = map { $_->{href} } - grep { ($_->{rel} // '') eq 'related' } - @{ $data->{links} // [] }; - }; - if (scalar @links > 0) { - $links[0] = $links[0] . '/' if untaint_tld_url_no_path( $links[0] ); - $link = $links[0] if untaint_tld_url_with_path( $links[0] ); - return $link; - - } else { - return $link; - } -} - - $json_schemas{get_host_by_name} = { type => 'object', diff --git a/lib/Zonemaster/Backend/TLD_URL.pm b/lib/Zonemaster/Backend/TLD_URL.pm new file mode 100644 index 000000000..0ff4a8b93 --- /dev/null +++ b/lib/Zonemaster/Backend/TLD_URL.pm @@ -0,0 +1,255 @@ +package Zonemaster::Backend::TLD_URL; + +=head1 Zonemaster::Backend::TLD_URL + +This Perl module is the backend for the RPCAPI method "get_tld_url" + +=cut + +use strict; +use warnings; +use 5.14.2; + +use HTTP::Tiny; +use JSON::PP qw(decode_json); +use Readonly; + +# Zonemaster Modules +use Zonemaster::Engine; +use Zonemaster::Engine::Normalization qw( normalize_name trim_space ); +use Zonemaster::Engine::Recursor; +use Zonemaster::Backend; +use Zonemaster::Backend::Config; +use Zonemaster::Backend::Validator qw[ untaint_tld_value untaint_tld_url_no_path untaint_tld_url_with_path ]; +use Zonemaster::Backend::Errors; + +Readonly my $IANA_RDAP_URL_BASE => "https://rdap.iana.org/domain"; +Readonly my $DNS_NAME_BASE_TXT_RECORD => "_url._zonemaster"; + +=head2 process ($self, $domain) + +Processes the domain name ($domain) for Zonemaster::Backend::RPCAPI::get_tld_url +and returns a complete hash reference to be returned by the RPCAPI. + +=cut + +sub process { + my ( $self, $domain ) = @_; + + my %result; + my $timeout = $self->{config}->TLD_URL_SETTINGS_lookup_timeout; + my $include_source = $self->{config}->TLD_URL_SETTINGS_include_source; + my $enable_tld_url = $self->{config}->TLD_URL_SETTINGS_enable_tld_url; + my %overrides = $self->{config}->TLD_URL_OVERRIDE; + my @labels = split( /\./, $domain ); + my $tld = $labels[$#labels]; # Empty if $domain is root '.' + + #$result{DEBUG} = "DEBUG 0"; + #$result{DOMAIN} = $domain; + #return \%result; + + # Empty response if the function is not enabled + unless ( $enable_tld_url ) { + #$result{DEBUG} = "DEBUG 1"; + $result{tld} = $tld if $tld; + return \%result; + } + + # Empty response if the domain is the root zone + if ( $domain eq '.' ) { + #$result{DEBUG} = "DEBUG 2"; + return \%result; + } + + # Empty response if the domain is just a TLD + if ( scalar @labels == 1 ) { + $result{tld} = $tld; + #$result{DEBUG} = "DEBUG 3"; + return \%result; + } + + # Check any override from the configuration + my $href_override_result = url_from_override( $domain, $tld, $include_source, \%overrides ); + return $href_override_result if %$href_override_result; + + # Do a lookup of "_url._zonemaster.$tld" + my $href_txt_record_result = url_from_txt_record( $domain, $tld, $timeout, $include_source ); + return $href_txt_record_result if %$href_txt_record_result; + + # Do an IANA RDAP lookup + my $href_rdap_lookup_result = url_from_rdap ( $tld, $timeout, $include_source ); + return $href_rdap_lookup_result if %$href_rdap_lookup_result; + + $result{tld} = $tld; + #$result{DEBUG} = "DEBUG last item"; + return \%result; +} + +=head2 url_from_override ($dom, $tld, $include_source, $href_or) + +Used by subroutine "process" to do the "dirty work" to process +any overrides in the configuration. + +The following variables are mandatory in the call: + +=over 8 + +=item * The domain name to be processed ($dom) + +=item * The TLD extracted from the domain name ($tld) + +=item * Boolean value whether source of URL should be indicated in the result ($include_source) + +=item * Reference of a HASH of the override data (if any) from the configuration file ($href_or) + +=back + +A HASH reference ready to be sent by the RPCAPI is returned. Or empty then not to be sent. + +=cut + +sub url_from_override { + my ($dom, $tld, $include_source, $href_or) = @_; + my $url; + my %result; + + if ( exists $$href_or{$tld} ) { + if ( $$href_or{$tld} eq '[BLOCK]' ) { + $result{tld} = $tld; + #$result{DEBUG} = "DEBUG override 1"; + } else { + $url = $$href_or{$tld}; + $url =~ s/\Q[DOMAIN]\E/$dom/; + # + $result{tld} = $tld; + #$result{DEBUG} = "DEBUG override 2"; + $result{url} = $url; + $result{source} = "BACKEND CONF" if $include_source; + } + } + return \%result; +} + +=head2 url_from_txt_record ($dom, $tld, $timeout, $include_source) + +Used by subroutine "process" to do the "dirty work" to process +the TXT lookup and the postprocessing of it. + +The following variables are mandatory in the call: + +=over 8 + +=item * The domain name to be processed ($dom) + +=item * The TLD extracted from the domain name ($tld) + +=item * The time limit for the lookup ($timeout) + +=item * Boolean value whether source of URL should be indicated in the result ($include_source) + +=back + +A HASH reference ready to be sent by the RPCAPI is returned. Or empty then not to be sent. + +=cut + +sub url_from_txt_record { + my ( $dom, $tld, $timeout, $include_source ) = @_; + my $url; + my %result; + my $name = $DNS_NAME_BASE_TXT_RECORD . '.' . $tld; + my $packet; + + eval { + local $SIG{ALRM} = sub { die "alarm\n" }; + alarm $timeout; + $packet = Zonemaster::Engine::Recursor->recurse( $name, 'TXT' ); + alarm 0; + }; + # Use $packet if defined + if ( $packet and $packet->rcode eq q{NOERROR} ) { + my @rrs = $packet->get_records_for_name( q{TXT}, $name ); + my @txt_rdata = map { $_->txtdata() } @rrs; + + warn "DEBUG TXT 1", "@txt_rdata"; + + if ( scalar ( @txt_rdata ) == 1 ) { # Ignore all if more than one + my $data = $txt_rdata[0]; + if ( untaint_tld_value( $data ) ) { # "[BLOCK]" or URL string + $data =~ s/\Q[DOMAIN]\E/$dom/; # If URL string and any "[DOMAIN]" + if ( $data eq '[BLOCK]' ) { + $result{tld} = $tld; + #$result{DEBUG} = "DEBUG TXT 5"; + } else { + $result{tld} = $tld; + #$result{DEBUG} = "DEBUG TXT 6"; + $result{url} = $data; + $result{source} = "TXT RECORD" if $include_source; + } + } + } + } + return \%result; +} + +=head2 url_from_rdap ($tld, $timeout, $include_source ) + +Used by subroutine "process" to do the "dirty work" to process +the IANA RDAP lookup and the postprocessing of it. + +The following variables are mandatory in the call: + +=over 8 + + +=item * The TLD extracted from the domain name ($tld) + +=item * The time limit for the lookup ($timeout) + +=item * Boolean value whether source of URL should be indicated in the result ($include_source) + +=back + +A HASH reference ready to be sent by the RPCAPI is returned. Or empty then not to be sent. + +=cut + +sub url_from_rdap { + my ( $tld, $timeout, $include_source ) = @_; + my $url = $IANA_RDAP_URL_BASE . '/' . $tld; + my $response; + my @links = (); + my $link = ''; + my %result; + + eval { + local $SIG{ALRM} = sub { die "alarm\n" }; + alarm $timeout; + $response = HTTP::Tiny->new->get($url); + alarm 0; + }; + if ($@) { + if ( $@ eq "alarm\n" ) { + handle_exception( "Timeout looking $url up" ); + } else { + handle_exception( "Unexpected error looking $url up: $@" ); + } + } + if ($response->{success}) { + my $data = decode_json($response->{content}); + @links = map { $_->{href} } + grep { ($_->{rel} // '') eq 'related' } + @{ $data->{links} // [] }; + }; + if (scalar @links > 0) { + $links[0] = $links[0] . '/' if untaint_tld_url_no_path( $links[0] ); + $link = $links[0] if untaint_tld_url_with_path( $links[0] ); + if ( $link ) { + $result{tld} = $tld; + #$result{DEBUG} = "DEBUG RDAP"; + $result{url} = $link; + $result{source} = "IANA RDAP" if $include_source; + } + } + return \%result; +} From e77a82175b38a3559afd83c5d18f524bce6c4b0b Mon Sep 17 00:00:00 2001 From: Mats Dufberg Date: Wed, 27 May 2026 10:49:43 +0200 Subject: [PATCH 10/34] Apply suggestions from code review Co-authored-by: Marc van der Wal <103426270+marc-vanderwal@users.noreply.github.com> --- lib/Zonemaster/Backend/RPCAPI.pm | 1 - lib/Zonemaster/Backend/TLD_URL.pm | 20 +++----------------- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/lib/Zonemaster/Backend/RPCAPI.pm b/lib/Zonemaster/Backend/RPCAPI.pm index 44140d216..5d2dd64aa 100644 --- a/lib/Zonemaster/Backend/RPCAPI.pm +++ b/lib/Zonemaster/Backend/RPCAPI.pm @@ -198,7 +198,6 @@ sub get_tld_url { ( undef, $domain ) = normalize_name( trim_space ( $params->{domain} ) ); return Zonemaster::Backend::TLD_URL::process( $self, $domain ); - } diff --git a/lib/Zonemaster/Backend/TLD_URL.pm b/lib/Zonemaster/Backend/TLD_URL.pm index 0ff4a8b93..dd918fc89 100644 --- a/lib/Zonemaster/Backend/TLD_URL.pm +++ b/lib/Zonemaster/Backend/TLD_URL.pm @@ -44,27 +44,20 @@ sub process { my @labels = split( /\./, $domain ); my $tld = $labels[$#labels]; # Empty if $domain is root '.' - #$result{DEBUG} = "DEBUG 0"; - #$result{DOMAIN} = $domain; - #return \%result; - # Empty response if the function is not enabled unless ( $enable_tld_url ) { - #$result{DEBUG} = "DEBUG 1"; - $result{tld} = $tld if $tld; + $result{tld} = $tld if defined $tld and $tld ne ''; return \%result; } # Empty response if the domain is the root zone if ( $domain eq '.' ) { - #$result{DEBUG} = "DEBUG 2"; return \%result; } # Empty response if the domain is just a TLD if ( scalar @labels == 1 ) { - $result{tld} = $tld; - #$result{DEBUG} = "DEBUG 3"; + $result{tld} = $tld; return \%result; } @@ -116,13 +109,10 @@ sub url_from_override { if ( exists $$href_or{$tld} ) { if ( $$href_or{$tld} eq '[BLOCK]' ) { $result{tld} = $tld; - #$result{DEBUG} = "DEBUG override 1"; } else { $url = $$href_or{$tld}; $url =~ s/\Q[DOMAIN]\E/$dom/; - # $result{tld} = $tld; - #$result{DEBUG} = "DEBUG override 2"; $result{url} = $url; $result{source} = "BACKEND CONF" if $include_source; } @@ -169,10 +159,7 @@ sub url_from_txt_record { # Use $packet if defined if ( $packet and $packet->rcode eq q{NOERROR} ) { my @rrs = $packet->get_records_for_name( q{TXT}, $name ); - my @txt_rdata = map { $_->txtdata() } @rrs; - - warn "DEBUG TXT 1", "@txt_rdata"; - + my @txt_rdata = map { $_->txtdata() } @rrs; if ( scalar ( @txt_rdata ) == 1 ) { # Ignore all if more than one my $data = $txt_rdata[0]; if ( untaint_tld_value( $data ) ) { # "[BLOCK]" or URL string @@ -246,7 +233,6 @@ sub url_from_rdap { $link = $links[0] if untaint_tld_url_with_path( $links[0] ); if ( $link ) { $result{tld} = $tld; - #$result{DEBUG} = "DEBUG RDAP"; $result{url} = $link; $result{source} = "IANA RDAP" if $include_source; } From 838e737964ca147286d350c16c65c74e7977e74a Mon Sep 17 00:00:00 2001 From: Mats Dufberg Date: Wed, 27 May 2026 10:51:50 +0200 Subject: [PATCH 11/34] Adds Perl module to MANIFEST --- MANIFEST | 1 + lib/Zonemaster/Backend/TLD_URL.pm | 2 ++ 2 files changed, 3 insertions(+) diff --git a/MANIFEST b/MANIFEST index d61b87c60..551aa6246 100644 --- a/MANIFEST +++ b/MANIFEST @@ -26,6 +26,7 @@ lib/Zonemaster/Backend/Log.pm lib/Zonemaster/Backend/Metrics.pm lib/Zonemaster/Backend/RPCAPI.pm lib/Zonemaster/Backend/TestAgent.pm +lib/Zonemaster/Backend/TLD_URL.pm lib/Zonemaster/Backend/Translator.pm lib/Zonemaster/Backend/Validator.pm LICENSE diff --git a/lib/Zonemaster/Backend/TLD_URL.pm b/lib/Zonemaster/Backend/TLD_URL.pm index dd918fc89..4a043ded9 100644 --- a/lib/Zonemaster/Backend/TLD_URL.pm +++ b/lib/Zonemaster/Backend/TLD_URL.pm @@ -239,3 +239,5 @@ sub url_from_rdap { } return \%result; } + +1; From 5f8c547c28b68a8e88d19d744ef9142e68b69580 Mon Sep 17 00:00:00 2001 From: MichaelTimbert <110017095+MichaelTimbert@users.noreply.github.com> Date: Thu, 28 May 2026 11:19:22 +0200 Subject: [PATCH 12/34] Update docker/zonemaster_launch Co-authored-by: Marc van der Wal <103426270+marc-vanderwal@users.noreply.github.com> Output testagent log on stdout/stderr. --- docker/zonemaster_launch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/zonemaster_launch b/docker/zonemaster_launch index 539c810e6..15db37a6b 100755 --- a/docker/zonemaster_launch +++ b/docker/zonemaster_launch @@ -46,7 +46,7 @@ case $1 in ;; testagent) - /usr/local/bin/zonemaster_backend_testagent -user=zonemaster --group=zonemaster foreground + /usr/local/bin/zonemaster_backend_testagent --user=zonemaster --group=zonemaster --logfile=- foreground ;; full) From 1b557ffb36782c024e91ea39b118b5aa73d00e34 Mon Sep 17 00:00:00 2001 From: Mats Dufberg Date: Thu, 28 May 2026 12:08:12 +0200 Subject: [PATCH 13/34] Minor correction, corrected spelling and smaller refactoring --- lib/Zonemaster/Backend/Config.pm | 10 +++--- lib/Zonemaster/Backend/TLD_URL.pm | 37 +++++++++++--------- lib/Zonemaster/Backend/Validator.pm | 52 +++++++++++++++++++++-------- 3 files changed, 64 insertions(+), 35 deletions(-) diff --git a/lib/Zonemaster/Backend/Config.pm b/lib/Zonemaster/Backend/Config.pm index 90247d0c7..c3e0b0d1b 100644 --- a/lib/Zonemaster/Backend/Config.pm +++ b/lib/Zonemaster/Backend/Config.pm @@ -965,12 +965,10 @@ sub _add_tld_url_override { die "TLD label not unique: $tld\n"; } - unless ( untaint_tld_value( $value ) ) { - die "Envalid value for a TLD label key: $value\n"; - } - - if ( untaint_tld_url_no_path( $value )) { - $value = $value . '/'; + unless ( untaint_tld_block( $value ) or + untaint_tld_url_no_path( $value ) or + untaint_tld_url_string( $value ) ) { + die "Invalid value for a TLD label key: $value\n"; } $self->{_tld_url_override}{$tld} = $value; diff --git a/lib/Zonemaster/Backend/TLD_URL.pm b/lib/Zonemaster/Backend/TLD_URL.pm index 4a043ded9..e8596d032 100644 --- a/lib/Zonemaster/Backend/TLD_URL.pm +++ b/lib/Zonemaster/Backend/TLD_URL.pm @@ -20,11 +20,14 @@ use Zonemaster::Engine::Normalization qw( normalize_name trim_space ); use Zonemaster::Engine::Recursor; use Zonemaster::Backend; use Zonemaster::Backend::Config; -use Zonemaster::Backend::Validator qw[ untaint_tld_value untaint_tld_url_no_path untaint_tld_url_with_path ]; +use Zonemaster::Backend::Validator qw[ untaint_tld_block untaint_tld_url_no_path untaint_tld_url_with_path untaint_tld_url_string ]; use Zonemaster::Backend::Errors; Readonly my $IANA_RDAP_URL_BASE => "https://rdap.iana.org/domain"; Readonly my $DNS_NAME_BASE_TXT_RECORD => "_url._zonemaster"; +Readonly my $SOURCE_BACKEND_CONF_STR => "BACKEND CONF"; +Readonly my $SOURCE_TXT_RECORD_STR => "TXT RECORD"; +Readonly my $SOURCE_IANA_RDAP_STR => "IANA RDAP"; =head2 process ($self, $domain) @@ -107,14 +110,16 @@ sub url_from_override { my %result; if ( exists $$href_or{$tld} ) { - if ( $$href_or{$tld} eq '[BLOCK]' ) { + + if ( untaint_tld_block( $$href_or{$tld} ) ) { $result{tld} = $tld; } else { $url = $$href_or{$tld}; - $url =~ s/\Q[DOMAIN]\E/$dom/; + $url = $url . '/' if untaint_tld_url_no_path( $url ); + $url =~ s/\Q[DOMAIN]\E/$dom/; # If any "[DOMAIN]" $result{tld} = $tld; $result{url} = $url; - $result{source} = "BACKEND CONF" if $include_source; + $result{source} = $SOURCE_BACKEND_CONF_STR if $include_source; } } return \%result; @@ -162,17 +167,17 @@ sub url_from_txt_record { my @txt_rdata = map { $_->txtdata() } @rrs; if ( scalar ( @txt_rdata ) == 1 ) { # Ignore all if more than one my $data = $txt_rdata[0]; - if ( untaint_tld_value( $data ) ) { # "[BLOCK]" or URL string - $data =~ s/\Q[DOMAIN]\E/$dom/; # If URL string and any "[DOMAIN]" - if ( $data eq '[BLOCK]' ) { - $result{tld} = $tld; - #$result{DEBUG} = "DEBUG TXT 5"; - } else { - $result{tld} = $tld; - #$result{DEBUG} = "DEBUG TXT 6"; - $result{url} = $data; - $result{source} = "TXT RECORD" if $include_source; - } + if ( untaint_tld_block( $data ) ) { # "[BLOCK]" + $result{tld} = $tld; + } elsif ( untaint_tld_url_no_path( $data ) ) { # URL without path + $result{tld} = $tld; + $result{url} = $data . '/'; + $result{source} = $SOURCE_TXT_RECORD_STR if $include_source; + } elsif ( untaint_tld_url_string( $data ) ) { # URL with path and possible "[DOMAIN]" + $data =~ s/\Q[DOMAIN]\E/$dom/; # If any "[DOMAIN]" + $result{tld} = $tld; + $result{url} = $data; + $result{source} = $SOURCE_TXT_RECORD_STR if $include_source; } } } @@ -234,7 +239,7 @@ sub url_from_rdap { if ( $link ) { $result{tld} = $tld; $result{url} = $link; - $result{source} = "IANA RDAP" if $include_source; + $result{source} = $SOURCE_IANA_RDAP_STR if $include_source; } } return \%result; diff --git a/lib/Zonemaster/Backend/Validator.pm b/lib/Zonemaster/Backend/Validator.pm index 04353b5c3..d726c92ea 100644 --- a/lib/Zonemaster/Backend/Validator.pm +++ b/lib/Zonemaster/Backend/Validator.pm @@ -36,9 +36,10 @@ our @EXPORT_OK = qw( untaint_strictly_positive_int untaint_strictly_positive_millis untaint_tld_label - untaint_tld_value + untaint_tld_block untaint_tld_url_no_path untaint_tld_url_with_path + untaint_tld_url_string check_domain check_ip check_profile @@ -70,9 +71,10 @@ our %EXPORT_TAGS = ( untaint_strictly_positive_int untaint_strictly_positive_millis untaint_tld_label - untaint_tld_value + untaint_tld_block untaint_tld_url_no_path untaint_tld_url_with_path + untaint_tld_url_string ) ], format => [ @@ -128,14 +130,13 @@ Readonly my $TEST_ID_RE => qr/^[0-9a-f]{16}$/; Readonly my $USERNAME_RE => qr/^[a-z0-9-.@]{1,50}$/i; # RE for URL for TLD -Readonly my $TLD_LABEL_RE => qr/^([a-z][a-z]+|xn--[a-z0-9-][a-z0-9-]+)$/i; # ASCII or A-label -Readonly my $TLD_BLOCK_RE => qr/^\[BLOCK\]$/; # Blocking policy +Readonly my $TLD_LABEL_RE => qr/^([a-z][a-z]+|xn--[a-z0-9-][a-z0-9-]+)$/; # ASCII or A-label +Readonly my $TLD_BLOCK_RE => qr/^\Q[BLOCK]\E$/; # Blocking policy Readonly my $TLD_URL_NO_PATH_RE => qr/^(http|https):\/\/[a-z0-9][a-z0-9.-]*[a-z0-9]$/; # URL without path +Readonly my $TLD_URL_WITH_PATH_RE => qr/^(http|https):\/\/[a-z0-9][a-z0-9.-]*[a-z0-9]\/[a-zA-Z0-9\/=?%_.&-]*$/; # URL with path # URL with path and possibly "[DOMAIN]" variable Readonly my $TLD_URL_STRING_RE => qr/^(http|https):\/\/[a-z0-9][a-z0-9.-]*[a-z0-9]\/[a-zA-Z0-9\/=?%_.&-]*(\[DOMAIN\])?[a-zA-Z0-9\/=?%_.&-]*$/; -Readonly my $TLD_VALUE_RE => qr/^($TLD_BLOCK_RE|$TLD_URL_NO_PATH_RE|$TLD_URL_STRING_RE)$/; - # URL with path -Readonly my $TLD_URL_WITH_PATH_RE => qr/^(http|https):\/\/[a-z0-9][a-z0-9.-]*[a-z0-9]\/[a-zA-Z0-9\/=?%_.&-]*$/; +# Readonly my $TLD_VALUE_RE => qr/^($TLD_BLOCK_RE|$TLD_URL_NO_PATH_RE|$TLD_URL_STRING_RE)$/; # Boolean Readonly my $BOOL_TRUE_RE => qr/^(true|yes)$/i; @@ -426,16 +427,15 @@ sub untaint_tld_label { return _untaint_pat( $value, $TLD_LABEL_RE ); } -=head2 untaint_tld_value +=head2 untaint_tld_block -Accepts a URL string or blocking policy. See the meaning in the -L. +Accepts a string for blocking (policy) =cut -sub untaint_tld_value { +sub untaint_tld_block { my ( $value ) = @_; - return _untaint_pat( $value, $TLD_VALUE_RE ); + return _untaint_pat( $value, $TLD_BLOCK_RE ); } =head2 untaint_tld_url_no_path @@ -458,7 +458,33 @@ Accepts a URL for TLD. sub untaint_tld_url_with_path { my ( $value ) = @_; - return _untaint_pat( $value, $TLD_URL_WITH_PATH_RE );} + return _untaint_pat( $value, $TLD_URL_WITH_PATH_RE ); +} + +=head2 untaint_tld_url_string + +Accepts a URL for TLD. + +=cut + +sub untaint_tld_url_string { + my ( $value ) = @_; + return _untaint_pat( $value, $TLD_URL_STRING_RE ); +} + + +# =head2 untaint_tld_value DEPRECATED + +# Accepts a URL string or blocking policy. See the meaning in the +# L. + +# =cut + +# sub untaint_tld_value { +# my ( $value ) = @_; +# return _untaint_pat( $value, $TLD_VALUE_RE ); +# } + =head2 untaint_engine_type From 8804b78d25497c9ef14c4568d9549e41b532a384 Mon Sep 17 00:00:00 2001 From: Mats Dufberg Date: Thu, 28 May 2026 12:08:46 +0200 Subject: [PATCH 14/34] Adds unit tests --- t/config.t | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++ t/validator.t | 95 +++++++++++++++++++++ 2 files changed, 317 insertions(+) diff --git a/t/config.t b/t/config.t index 2892253a2..81fff36df 100644 --- a/t/config.t +++ b/t/config.t @@ -57,6 +57,15 @@ subtest 'Everything but NoWarnings' => sub { number_of_processes_for_batch_testing = 40 lock_on_queue = 1 age_reuse_previous_test = 800 + + [TLD URL SETTINGS] + enable_tld_url = false + lookup_timeout = 6 + include_source = false + + [TLD URL OVERRIDE] + xa = [BLOCK] + xb = http://nic.xb/domain }; my $config = Zonemaster::Backend::Config->parse( $text ); isa_ok $config, 'Zonemaster::Backend::Config', 'parse() return value'; @@ -89,6 +98,14 @@ subtest 'Everything but NoWarnings' => sub { is $config->ZONEMASTER_number_of_processes_for_batch_testing, 40, 'set: ZONEMASTER.number_of_processes_for_batch_testing'; is $config->ZONEMASTER_lock_on_queue, 1, 'set: ZONEMASTER.lock_on_queue'; is $config->ZONEMASTER_age_reuse_previous_test, 800, 'set: ZONEMASTER.age_reuse_previous_test'; + + is $config->TLD_URL_SETTINGS_enable_tld_url, 0, 'TLD_URL_SETTINGS_enable_tld_url'; + is $config->TLD_URL_SETTINGS_lookup_timeout, 6, 'TLD_URL_SETTINGS_lookup_timeout'; + is $config->TLD_URL_SETTINGS_include_source, 0, 'TLD_URL_SETTINGS_include_source'; + eq_or_diff { $config->TLD_URL_OVERRIDE }, { # + xa => '[BLOCK]', + xb => 'http://nic.xb/domain' + }, }; subtest 'Default values' => sub { @@ -114,6 +131,12 @@ subtest 'Everything but NoWarnings' => sub { is $config->RPCAPI_enable_add_api_user, 0, 'default: RPCAPI.enable_add_api_user'; is $config->RPCAPI_enable_add_batch_job, 1, 'default: RPCAPI.enable_add_batch_job'; + + is $config->TLD_URL_SETTINGS_enable_tld_url, 1, 'TLD_URL_SETTINGS_enable_tld_url'; + is $config->TLD_URL_SETTINGS_lookup_timeout, 3, 'TLD_URL_SETTINGS_lookup_timeout'; + is $config->TLD_URL_SETTINGS_include_source, 1, 'TLD_URL_SETTINGS_include_source'; + eq_or_diff { $config->TLD_URL_OVERRIDE }, { # + }, }; SKIP: { @@ -810,6 +833,205 @@ subtest 'Everything but NoWarnings' => sub { } qr/PRIVATE PROFILES.*default/, 'die: Default profile in PRIVATE PROFILES'; + throws_ok { + my $text = q{ + [DB] + engine = SQLite + + [SQLITE] + database_file = /var/db/zonemaster.sqlite + + [TLD URL SETTINGS] + enable_tld_url = no + }; + Zonemaster::Backend::Config->parse( $text ); + } + qr/Invalid value/, 'die: Invalid value of TLD_URL_SETTINGS_enable_tld_url'; + + + throws_ok { + my $text = q{ + [DB] + engine = SQLite + + [SQLITE] + database_file = /var/db/zonemaster.sqlite + + [TLD URL SETTINGS] + enable_tld_url = 5 + }; + Zonemaster::Backend::Config->parse( $text ); + } + qr/Invalid value/, 'die: Invalid value of TLD_URL_SETTINGS_enable_tld_url'; + + throws_ok { + my $text = q{ + [DB] + engine = SQLite + + [SQLITE] + database_file = /var/db/zonemaster.sqlite + + [TLD URL SETTINGS] + lookup_timeout = -1 + }; + Zonemaster::Backend::Config->parse( $text ); + } + qr/Invalid value/, 'die: Invalid value of TLD_URL_SETTINGS_lookup_timeout'; + + throws_ok { + my $text = q{ + [DB] + engine = SQLite + + [SQLITE] + database_file = /var/db/zonemaster.sqlite + + [TLD URL SETTINGS] + lookup_timeout = 0.5 + }; + Zonemaster::Backend::Config->parse( $text ); + } + qr/Invalid value/, 'die: Invalid value of TLD_URL_SETTINGS_lookup_timeout'; + + throws_ok { + my $text = q{ + [DB] + engine = SQLite + + [SQLITE] + database_file = /var/db/zonemaster.sqlite + + [TLD URL SETTINGS] + include_source = yes + }; + Zonemaster::Backend::Config->parse( $text ); + } + qr/Invalid value/, 'die: Invalid value of TLD_URL_SETTINGS_include_source'; + + throws_ok { + my $text = q{ + [DB] + engine = SQLite + + [SQLITE] + database_file = /var/db/zonemaster.sqlite + + [TLD URL SETTINGS] + include_source = 10 + }; + Zonemaster::Backend::Config->parse( $text ); + } + qr/Invalid value/, 'die: Invalid value of TLD_URL_SETTINGS_include_source'; + + throws_ok { + my $text = q{ + [DB] + engine = SQLite + + [SQLITE] + database_file = /var/db/zonemaster.sqlite + + [TLD URL OVERRIDE] + xa = [BLOCK] + xa = http://nic.xb/domain + }; + Zonemaster::Backend::Config->parse( $text ); + } + qr/Property not unique/, 'die: TLD_URL_OVERRIDE.xa not unique'; + + throws_ok { + my $text = q{ + [DB] + engine = SQLite + + [SQLITE] + database_file = /var/db/zonemaster.sqlite + + [TLD URL OVERRIDE] + xa = [BLOK] + }; + Zonemaster::Backend::Config->parse( $text ); + } + qr/Invalid value/, 'die: Invalid value in URL string in [TLD URL OVERRIDE]'; + + throws_ok { + my $text = q{ + [DB] + engine = SQLite + + [SQLITE] + database_file = /var/db/zonemaster.sqlite + + [TLD URL OVERRIDE] + xa = http://nic.xb/domain/$dom + }; + Zonemaster::Backend::Config->parse( $text ); + } + qr/Invalid value/, 'die: Invalid value in URL string in [TLD URL OVERRIDE]'; + + throws_ok { + my $text = q{ + [DB] + engine = SQLite + + [SQLITE] + database_file = /var/db/zonemaster.sqlite + + [TLD URL OVERRIDE] + xa-xb = [BLOCK] + }; + Zonemaster::Backend::Config->parse( $text ); + } + qr/Invalid TLD label/, 'die: Invalid TLD string in [TLD URL OVERRIDE]'; + + throws_ok { + my $text = q{ + [DB] + engine = SQLite + + [SQLITE] + database_file = /var/db/zonemaster.sqlite + + [TLD URL OVERRIDE] + xa = http://nic.xb/domain1 + xa = http://nic.xb/domain2 + }; + Zonemaster::Backend::Config->parse( $text ); + } + qr/Property not unique/, 'die: TLD_URL_OVERRIDE.xa not unique'; + + throws_ok { + my $text = q{ + [DB] + engine = SQLite + + [SQLITE] + database_file = /var/db/zonemaster.sqlite + + [TLD URL OVERRIDE] + dev.xa = http://nic.xb/domain + }; + Zonemaster::Backend::Config->parse( $text ); + } + qr/Invalid TLD label/, 'die: Invalid TLD string in [TLD URL OVERRIDE]'; + + lives_ok { + my $text = q{ + [DB] + engine = SQLite + + [SQLITE] + database_file = /var/db/zonemaster.sqlite + + [TLD URL OVERRIDE] + xa = http://nic.xb + }; + Zonemaster::Backend::Config->parse( $text ); + } + 'URL needs no path [TLD URL OVERRIDE]'; + + subtest 'RPCAPI experimental aliases' => sub { subtest 'default values' => sub { my $text = q{ diff --git a/t/validator.t b/t/validator.t index 917e7e758..6004a3244 100644 --- a/t/validator.t +++ b/t/validator.t @@ -212,4 +212,99 @@ subtest 'Everything but NoWarnings' => sub { is scalar untaint_strictly_positive_millis( '-1' ), undef, 'reject: -1'; ok !tainted( untaint_strictly_positive_millis( taint( '0.5' ) ) ), 'launder taint'; }; + + subtest 'untaint_tld_label' => sub { + is scalar untaint_tld_label( 'ax' ), 'ax', 'accept: ax'; + is scalar untaint_tld_label( 'globaltld' ), 'globaltld', 'accept: globaltld'; + is scalar untaint_tld_label( 'xn--rksmrgs-5wao1o' ), 'xn--rksmrgs-5wao1o', 'accept: xn--rksmrgs-5wao1o'; + is scalar untaint_tld_label( 'AX' ), undef, 'reject: AX'; + is scalar untaint_tld_label( 'Global' ), undef, 'reject: Global'; + is scalar untaint_tld_label( 'Räksmörgås' ), undef, 'reject: Räksmörgås'; + is scalar untaint_tld_label( 'non-tld' ), undef, 'reject: non-tld'; + is scalar untaint_tld_label( 'xx--rksmrgs-5wao1o' ), undef, 'reject: xx--rksmrgs-5wao1o'; + is scalar untaint_tld_label( 'xn--' ), undef, 'reject: xn--'; + is scalar untaint_tld_label( 'test.xa' ), undef, 'reject: test.xa'; + is scalar untaint_tld_label( 'x' ), undef, 'reject: a'; + is scalar untaint_tld_label( 'ax.' ), undef, 'reject: "ax."'; + is scalar untaint_tld_label( '.' ), undef, 'reject: "."'; + is scalar untaint_tld_label( '' ), undef, 'reject: ""'; + ok !tainted( untaint_tld_label( taint( 'ax' ) ) ), 'launder taint'; + }; + + subtest 'untaint_tld_block' => sub { + is scalar untaint_tld_block( '[BLOCK]' ), '[BLOCK]', 'accept: [BLOCK]'; + is scalar untaint_tld_block( '[BLOK]' ), undef, 'reject: [BLOK]'; + is scalar untaint_tld_block( '[ BLOCK]' ), undef, 'reject: [ BLOCK]'; + is scalar untaint_tld_block( '[BLOCK ]' ), undef, 'reject: [BLOCK ]'; + is scalar untaint_tld_block( '[block]' ), undef, 'reject: [block]'; + ok !tainted( untaint_tld_block( taint( '[BLOCK]' ) ) ), 'launder taint'; + }; + + subtest 'untaint_tld_url_no_path' => sub { + is scalar untaint_tld_url_no_path( 'https://domain.nic.xa' ), 'https://domain.nic.xa', 'accept: https://domain.nic.xa'; + is scalar untaint_tld_url_no_path( 'http://domain.nic.xa' ), 'http://domain.nic.xa', 'accept: http://domain.nic.xa'; + is scalar untaint_tld_url_no_path( 'https://xn--rksmrgs-5wao1o.se' ), 'https://xn--rksmrgs-5wao1o.se', 'accept: https://xn--rksmrgs-5wao1o.se'; + is scalar untaint_tld_url_no_path( 'https:/domain.nic.xa' ), undef, 'reject: https:/domain.nic.xa'; + is scalar untaint_tld_url_no_path( 'ftp://domain.nic.xa' ), undef, 'reject: ftp://domain.nic.xa'; + is scalar untaint_tld_url_no_path( 'https://domain.nic.xa/' ), undef, 'reject: https://domain.nic.xa/'; + is scalar untaint_tld_url_no_path( 'https://domain.nic.xa/domain' ), undef, 'reject: https://domain.nic.xa/domain'; + is scalar untaint_tld_url_no_path( 'https://DOMAIN_NIC.XA' ), undef, 'reject: https://DOMAIN_NIC.XA'; + ok !tainted( untaint_tld_url_no_path( taint( 'https://domain.nic.xa' ) ) ), 'launder taint'; + }; + + subtest 'untaint_tld_url_with_path' => sub { + is scalar untaint_tld_url_with_path( 'https://domain.nic.xa/' ), 'https://domain.nic.xa/', 'accept: https://domain.nic.xa/'; + is scalar untaint_tld_url_with_path( 'http://domain.nic.xa/' ), 'http://domain.nic.xa/', 'accept: http://domain.nic.xa/'; + is scalar untaint_tld_url_with_path( 'https://xn--rksmrgs-5wao1o.se/' ), 'https://xn--rksmrgs-5wao1o.se/', 'accept: https://xn--rksmrgs-5wao1o.se/'; + is scalar untaint_tld_url_with_path( 'https://domain.nic.xa/search/domain' ), 'https://domain.nic.xa/search/domain', 'accept: https://domain.nic.xa/search/domain'; + is scalar untaint_tld_url_with_path( 'https://domain.nic.xa/SEARCH/domain' ), 'https://domain.nic.xa/SEARCH/domain', 'accept: https://domain.nic.xa/SEARCH/domain'; + is scalar untaint_tld_url_with_path( 'https://domain.nic.xa/search=now/domain' ), 'https://domain.nic.xa/search=now/domain', 'accept: https://domain.nic.xa/search=now/domain'; + is scalar untaint_tld_url_with_path( 'https://domain.nic.xa/search=0123456789/domain' ), 'https://domain.nic.xa/search=0123456789/domain', 'accept: https://domain.nic.xa/search=0123456789/domain'; + is scalar untaint_tld_url_with_path( 'https://domain.nic.xa/search?now/domain' ), 'https://domain.nic.xa/search?now/domain', 'accept: https://domain.nic.xa/search?now/domain'; + is scalar untaint_tld_url_with_path( 'https://domain.nic.xa/search%now/domain' ), 'https://domain.nic.xa/search%now/domain', 'accept: https://domain.nic.xa/search%now/domain'; + is scalar untaint_tld_url_with_path( 'https://domain.nic.xa/search_now.now/domain' ), 'https://domain.nic.xa/search_now.now/domain', 'accept: https://domain.nic.xa/search_now.now/domain'; + is scalar untaint_tld_url_with_path( 'https://domain.nic.xa/search&now-now/domain' ), 'https://domain.nic.xa/search&now-now/domain', 'accept: https://domain.nic.xa/search&now-now/domain'; + is scalar untaint_tld_url_with_path( 'https://domain.nic.xa/search$now-now/domain' ), undef, 'reject: https://domain.nic.xa/search$now-now/domain'; + is scalar untaint_tld_url_with_path( 'https://DOMAIN.NIC.XA/' ), undef, 'reject: https://DOMAIN.NIC.XA/'; + is scalar untaint_tld_url_with_path( 'https://domain.nic.xa/[DOMAIN]' ), undef, 'reject: https://domain.nic.xa/[DOMAIN]'; + ok !tainted( untaint_tld_url_with_path( taint( 'https://domain.nic.xa/' ) ) ), 'launder taint'; + }; + + subtest 'untaint_tld_url_string' => sub { + is scalar untaint_tld_url_string( 'https://domain.nic.xa/[DOMAIN]' ), 'https://domain.nic.xa/[DOMAIN]', 'accept: https://domain.nic.xa/[DOMAIN]'; + is scalar untaint_tld_url_string( 'http://domain.nic.xa/[DOMAIN]' ), 'http://domain.nic.xa/[DOMAIN]', 'accept: http://domain.nic.xa/[DOMAIN]'; + is scalar untaint_tld_url_string( 'https://xn--rksmrgs-5wao1o.se/' ), 'https://xn--rksmrgs-5wao1o.se/', 'accept: https://xn--rksmrgs-5wao1o.se/'; + is scalar untaint_tld_url_string( 'https://domain.nic.xa/search/[DOMAIN]/domain' ), 'https://domain.nic.xa/search/[DOMAIN]/domain', 'accept: https://domain.nic.xa/search/[DOMAIN]/domain'; + is scalar untaint_tld_url_string( 'https://domain.nic.xa/SEARCH/domain' ), 'https://domain.nic.xa/SEARCH/domain', 'accept: https://domain.nic.xa/SEARCH/domain'; + is scalar untaint_tld_url_string( 'https://domain.nic.xa/search=now/domain' ), 'https://domain.nic.xa/search=now/domain', 'accept: https://domain.nic.xa/search=now/domain'; + is scalar untaint_tld_url_string( 'https://domain.nic.xa/search=0123456789/domain' ), 'https://domain.nic.xa/search=0123456789/domain', 'accept: https://domain.nic.xa/search=0123456789/domain'; + is scalar untaint_tld_url_string( 'https://domain.nic.xa/search?now/domain' ), 'https://domain.nic.xa/search?now/domain', 'accept: https://domain.nic.xa/search?now/domain'; + is scalar untaint_tld_url_string( 'https://domain.nic.xa/search%now/domain' ), 'https://domain.nic.xa/search%now/domain', 'accept: https://domain.nic.xa/search%now/domain'; + is scalar untaint_tld_url_string( 'https://domain.nic.xa/search_now.now/domain' ), 'https://domain.nic.xa/search_now.now/domain', 'accept: https://domain.nic.xa/search_now.now/domain'; + is scalar untaint_tld_url_string( 'https://domain.nic.xa/search&now-now/domain' ), 'https://domain.nic.xa/search&now-now/domain', 'accept: https://domain.nic.xa/search&now-now/domain'; + is scalar untaint_tld_url_string( 'https://domain.nic.xa/search$now-now/domain' ), undef, 'reject: https://domain.nic.xa/search&now-now/domain'; + is scalar untaint_tld_url_string( 'https://domain.nic.xa/[domain]' ), undef, 'reject: https://domain.nic.xa/[domain]'; + ok !tainted( untaint_tld_url_string( taint( 'https://domain.nic.xa/[DOMAIN]' ) ) ), 'launder taint'; + }; + + subtest 'untaint_json_bool' => sub { + is scalar untaint_json_bool( 'true' ), 1, 'accept: true'; + is scalar untaint_json_bool( 'false' ), 0, 'accept: false'; + is scalar untaint_json_bool( 'yes' ), undef, 'reject: yes'; + is scalar untaint_json_bool( 'no' ), undef, 'reject: no'; + is scalar untaint_json_bool( '1' ), undef, 'reject: 1'; + is scalar untaint_json_bool( '0' ), undef, 'reject: 0'; + ok !tainted( untaint_json_bool( taint( 'true' ) ) ), 'launder taint'; + }; + + subtest 'untaint_bool' => sub { + is scalar untaint_bool( 'true' ), 1, 'accept: true'; + is scalar untaint_bool( 'false' ), 0, 'accept: false'; + is scalar untaint_bool( 'yes' ), 1, 'accept: yes'; + is scalar untaint_bool( 'no' ), 0, 'accept: no'; + is scalar untaint_bool( '1' ), undef, 'reject: 1'; + is scalar untaint_bool( '0' ), undef, 'reject: 0'; + ok !tainted( untaint_bool( taint( 'true' ) ) ), 'launder taint'; + }; + }; From b172b4442b0a3d3387ee48d0f54f2acc0430ba5c Mon Sep 17 00:00:00 2001 From: Mats Dufberg Date: Thu, 28 May 2026 18:25:02 +0200 Subject: [PATCH 15/34] Apply suggestions from code review Co-authored-by: tgreenx <96772376+tgreenx@users.noreply.github.com> --- lib/Zonemaster/Backend/TLD_URL.pm | 8 ++------ lib/Zonemaster/Backend/Validator.pm | 20 +++----------------- 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/lib/Zonemaster/Backend/TLD_URL.pm b/lib/Zonemaster/Backend/TLD_URL.pm index e8596d032..3127731ba 100644 --- a/lib/Zonemaster/Backend/TLD_URL.pm +++ b/lib/Zonemaster/Backend/TLD_URL.pm @@ -16,7 +16,6 @@ use Readonly; # Zonemaster Modules use Zonemaster::Engine; -use Zonemaster::Engine::Normalization qw( normalize_name trim_space ); use Zonemaster::Engine::Recursor; use Zonemaster::Backend; use Zonemaster::Backend::Config; @@ -77,7 +76,6 @@ sub process { return $href_rdap_lookup_result if %$href_rdap_lookup_result; $result{tld} = $tld; - #$result{DEBUG} = "DEBUG last item"; return \%result; } @@ -166,7 +164,7 @@ sub url_from_txt_record { my @rrs = $packet->get_records_for_name( q{TXT}, $name ); my @txt_rdata = map { $_->txtdata() } @rrs; if ( scalar ( @txt_rdata ) == 1 ) { # Ignore all if more than one - my $data = $txt_rdata[0]; + my $data = $txt_rdata[0]; if ( untaint_tld_block( $data ) ) { # "[BLOCK]" $result{tld} = $tld; } elsif ( untaint_tld_url_no_path( $data ) ) { # URL without path @@ -229,9 +227,7 @@ sub url_from_rdap { } if ($response->{success}) { my $data = decode_json($response->{content}); - @links = map { $_->{href} } - grep { ($_->{rel} // '') eq 'related' } - @{ $data->{links} // [] }; + @links = map { $_->{href} } grep { ($_->{rel} // '') eq 'related' } @{ $data->{links} // [] }; }; if (scalar @links > 0) { $links[0] = $links[0] . '/' if untaint_tld_url_no_path( $links[0] ); diff --git a/lib/Zonemaster/Backend/Validator.pm b/lib/Zonemaster/Backend/Validator.pm index d726c92ea..c434b3fb2 100644 --- a/lib/Zonemaster/Backend/Validator.pm +++ b/lib/Zonemaster/Backend/Validator.pm @@ -134,9 +134,8 @@ Readonly my $TLD_LABEL_RE => qr/^([a-z][a-z]+|xn--[a-z0-9-][a-z0-9-]+) Readonly my $TLD_BLOCK_RE => qr/^\Q[BLOCK]\E$/; # Blocking policy Readonly my $TLD_URL_NO_PATH_RE => qr/^(http|https):\/\/[a-z0-9][a-z0-9.-]*[a-z0-9]$/; # URL without path Readonly my $TLD_URL_WITH_PATH_RE => qr/^(http|https):\/\/[a-z0-9][a-z0-9.-]*[a-z0-9]\/[a-zA-Z0-9\/=?%_.&-]*$/; # URL with path - # URL with path and possibly "[DOMAIN]" variable +# URL with path and possibly "[DOMAIN]" variable Readonly my $TLD_URL_STRING_RE => qr/^(http|https):\/\/[a-z0-9][a-z0-9.-]*[a-z0-9]\/[a-zA-Z0-9\/=?%_.&-]*(\[DOMAIN\])?[a-zA-Z0-9\/=?%_.&-]*$/; -# Readonly my $TLD_VALUE_RE => qr/^($TLD_BLOCK_RE|$TLD_URL_NO_PATH_RE|$TLD_URL_STRING_RE)$/; # Boolean Readonly my $BOOL_TRUE_RE => qr/^(true|yes)$/i; @@ -418,7 +417,7 @@ sub untaint_abs_path { =head2 untaint_tld_label -Accepts a TLD label, ASCII or IDN (A-label) +Accepts a TLD label in ASCII or IDN (A-label). =cut @@ -429,7 +428,7 @@ sub untaint_tld_label { =head2 untaint_tld_block -Accepts a string for blocking (policy) +Accepts a string for blocking (policy). =cut @@ -473,19 +472,6 @@ sub untaint_tld_url_string { } -# =head2 untaint_tld_value DEPRECATED - -# Accepts a URL string or blocking policy. See the meaning in the -# L. - -# =cut - -# sub untaint_tld_value { -# my ( $value ) = @_; -# return _untaint_pat( $value, $TLD_VALUE_RE ); -# } - - =head2 untaint_engine_type Accepts the strings C<"MySQL">, C<"PostgreSQL"> and C<"SQLite">, From 72569164aeda52a4592bb5658018bd1f9d5bb46a Mon Sep 17 00:00:00 2001 From: Mats Dufberg Date: Thu, 28 May 2026 18:35:34 +0200 Subject: [PATCH 16/34] Adds link to documentation plus editorial update --- lib/Zonemaster/Backend/TLD_URL.pm | 2 ++ lib/Zonemaster/Backend/Validator.pm | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/Zonemaster/Backend/TLD_URL.pm b/lib/Zonemaster/Backend/TLD_URL.pm index 3127731ba..2f0b9b1b9 100644 --- a/lib/Zonemaster/Backend/TLD_URL.pm +++ b/lib/Zonemaster/Backend/TLD_URL.pm @@ -32,6 +32,8 @@ Readonly my $SOURCE_IANA_RDAP_STR => "IANA RDAP"; Processes the domain name ($domain) for Zonemaster::Backend::RPCAPI::get_tld_url and returns a complete hash reference to be returned by the RPCAPI. +See L +for a specification of the features implemented in this module. =cut diff --git a/lib/Zonemaster/Backend/Validator.pm b/lib/Zonemaster/Backend/Validator.pm index c434b3fb2..7bd8e78c8 100644 --- a/lib/Zonemaster/Backend/Validator.pm +++ b/lib/Zonemaster/Backend/Validator.pm @@ -417,7 +417,7 @@ sub untaint_abs_path { =head2 untaint_tld_label -Accepts a TLD label in ASCII or IDN (A-label). +Accepts a TLD label in ASCII or an IDN TLD label as A-label. =cut From 8bb2e43e3424a15fb84bbcfbc3037e267f08b3c5 Mon Sep 17 00:00:00 2001 From: Marc van der Wal Date: Tue, 9 Jun 2026 15:42:20 +0200 Subject: [PATCH 17/34] Update data file format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes to the cache file format in Zonemaster::Engine require regeneration of some data files in use by the test suite in Zonemaster-Backend. There also was a small mistake in the logic that decides whether to save or restore data from the corresponding data files. Restoration should not be attempted if ZONEMASTER_RECORD=1 is not set in the environment, and likewise, the data files should not be written to if that same variable isn’t in the environment. --- t/idn.data | Bin 10546 -> 24861 bytes t/idn.t | 4 ++-- t/test01.data | Bin 238720 -> 82072 bytes t/test01.t | 8 ++++---- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/t/idn.data b/t/idn.data index 3f7bcc13cb960272723a4ee8af221572cef3f880..7094bac44b14fc453387cefb8b09320ab8f34eaa 100644 GIT binary patch literal 24861 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 literal 10546 zcmdU#TT|j#6oudCS9Il7C!T9Ar^*LFM7t5C9R+#Ijf)yk2r72Xf1iDl2npmk(=`<< zP}M>UmBeqav-UnKO!WC|7X7&h<}bl~p-+S88@7o~4IMK}$L6&l9{n)sy+X44>a7Ev%4qB$K+rhyPwj`t8m+wZndI@j6voay2{I9@oh&yX)asw{-&_IFCP5AIZ<3{*0e`%?mHIU;GAsAN0Qy`1f8T z{LzOGxYG`DTipvkyj?l{&eMRfXh7xrofo%TXJgkGz(LO%^4zRjYy9;LFXVBLkF~$y z@yBErGC>FnWDWy?;Gy`zX-W7~YVX}fHL0nJ&M~3Zp_WrmOj3droaV)2$MF4K_}I2} zQ@62>%K}`iS#)|&0kovPB$u0XcMI|DCKcLCr;-8>dH@fGSueo5-iUOo87u3F@U z!DR^_^rEPgMNGV|)UAdi>@@|KdPs3iEs zj=`Bp=^Cl)ud1s3B7IXKISL{u%8MK$RSl%bIZTMe(!ZKCWfq7&bs1~c+Y8b;UU|qQ zX6&kU4)Y;6dV$X4-?=5}yhtd}H{n8roBspwM)>pSM^(#FylxR^Hd7Tq&N?>igmi*% zNt9C=2iMPg#*LT;w2(|E!n>VlLoXQ>A3xPNvWE(dv$7g#N}ooRHIZV~8q~xvpB67s zz$h5mTB3=O7Zf0w=Pj5R%7U!;6EHQ&+Q!@cpsM=MN_=}ek%5;eJX9&%_h4F4Z}lk$ zf9ZMc=x9O~H{PIjbQCnXamvYPJ4^Y!q$P9p;L^K-k=G?C2elNJ$ubb$|};*}C}l|zjOjJ)=*{8?lm_wuE;dUGFs{Lf@cK|v-DYq72tc?u^<+B9~j zKWE+q6EAF1XXHNyt<%VBy*(xMi(iIJEnHfJgt0?}M@Pn!)nL}kX z7jG=e8hJDD6=5!9a~RmC>YjBgnop?I&237tiaoZej^v&K6T5tMV;He=O+S134dSQ9 zz9BYD#$&`ImAsSq87tyX|108WuFMnRvIJbhrUIQ#Yria{%{>5R*3+a)h@UPz;u%lxvbX$3{Nf)%Zsd zd7k35~%Jwy9HRRna=d?oxnX zc2L{Yv5oZ7Ag3PkzSy&PAF>*A%eZs6@(z;jc7k1r%Vj_i)d__?Pg!?lGwr5m8E(!G zSFLay#L-5{4XdKxi;9(8Ksnnd9ZO*(C@8m2UZR7l`n00x*p&CIGU|cRrPvh#st6Jv F{|B!mk){9u diff --git a/t/idn.t b/t/idn.t index bcda6563b..aeb26ac3c 100644 --- a/t/idn.t +++ b/t/idn.t @@ -22,7 +22,7 @@ use Zonemaster::Backend::Config; my $db_backend = TestUtil::db_backend(); my $datafile = "$t_path/idn.data"; -TestUtil::restore_datafile( $datafile ); +TestUtil::restore_datafile( $datafile ) unless ( $ENV{ZONEMASTER_RECORD} ); my $tempdir = tempdir( CLEANUP => 1 ); @@ -121,6 +121,6 @@ subtest 'test IDN nameserver' => sub { }; }; -TestUtil::save_datafile( $datafile ); +TestUtil::save_datafile( $datafile ) if ( $ENV{ZONEMASTER_RECORD} ); done_testing(); diff --git a/t/test01.data b/t/test01.data index 5f0d1f8011bb22aebd50910806fe1cd7eca9b8f8..3789a28169be8f226ff9e29ab28e74d3c2a71881 100644 GIT binary patch literal 82072 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 EKY;7OS^xk5 literal 238720 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 diff --git a/t/test01.t b/t/test01.t index 9fe41c1bf..f3fdf0191 100644 --- a/t/test01.t +++ b/t/test01.t @@ -22,7 +22,7 @@ use Zonemaster::Backend::Config; my $db_backend = TestUtil::db_backend(); my $datafile = "$t_path/test01.data"; -TestUtil::restore_datafile( $datafile ); +TestUtil::restore_datafile( $datafile ) unless ( $ENV{ZONEMASTER_RECORD} ); my $tempdir = tempdir( CLEANUP => 1 ); @@ -208,9 +208,9 @@ subtest 'API calls' => sub { my $ds_value = { 'algorithm' => 13, - 'digest' => '1303e8da8fb60db500d5bea1ee5dc9a2bcc93dfe2fc43d346576658feccf5749', # must match case + 'digest' => '8163abf45792942cf4ee38cca31f6a6832fcdc6d402338fc687827690c4132f6', # must match case 'digtype' => 2, - 'keytag' => 29133 + 'keytag' => 65381 }; is( scalar( @{ $res->{ds_list} } ), 1, 'Has only one DS set' ); is_deeply( $res->{ds_list}[0], $ds_value, 'Has correct DS values' ); @@ -420,6 +420,6 @@ subtest 'normalize "domain" column' => sub { } }; -TestUtil::save_datafile( $datafile ); +TestUtil::save_datafile( $datafile ) if ( $ENV{ZONEMASTER_RECORD} ); done_testing(); From 704482b3f91d43cec9112aeb45d06d1017075b2c Mon Sep 17 00:00:00 2001 From: Michael Timbert Date: Tue, 24 Feb 2026 14:42:58 +0100 Subject: [PATCH 18/34] Add artifact build to the CI --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48f7b314b..8b820f6f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,3 +136,33 @@ jobs: env: ZONEMASTER_BACKEND_CONFIG_FILE: ./share/backend_config.ci_${{ matrix.db }}.ini run: make test TEST_VERBOSE=1 + + build-artifact: + needs: run-tests + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: apt install + run: sudo apt-get install -y build-essential git libmodule-install-perl + + - name: build + run: perl Makefile.PL && make dist + + - name: Get short SHA + id: short_sha + run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Get Zonemaster-Backend version + id: version + run: | + result=`grep "our $VERSION" lib/Zonemaster/Backend.pm` + result+='printf $VERSION;' + VERSION=`perl -e "$result"` + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: upload artifact + uses: actions/upload-artifact@v4 + with: + name: Zonemaster-Backend-${{ steps.version.outputs.version }}-${{ steps.short_sha.outputs.short_sha }} + path: Zonemaster-Backend-${{ steps.version.outputs.version }}.tar.gz From 92d613f8db1093aa5c7b39ceda2aa0f2244e036d Mon Sep 17 00:00:00 2001 From: Michael Timbert Date: Tue, 3 Mar 2026 08:13:38 +0100 Subject: [PATCH 19/34] fixing missing .mo files --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b820f6f4..e3cadf10c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,7 +147,7 @@ jobs: run: sudo apt-get install -y build-essential git libmodule-install-perl - name: build - run: perl Makefile.PL && make dist + run: perl Makefile.PL && make all dist - name: Get short SHA id: short_sha From 5146753166b09076cae42cfaa3b6c3f80571740d Mon Sep 17 00:00:00 2001 From: Michael Timbert Date: Tue, 3 Mar 2026 10:01:44 +0100 Subject: [PATCH 20/34] add gettext dependency --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3cadf10c..5da4d4cac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,7 +144,7 @@ jobs: - uses: actions/checkout@v4 - name: apt install - run: sudo apt-get install -y build-essential git libmodule-install-perl + run: sudo apt-get install -y build-essential git libmodule-install-perl gettext - name: build run: perl Makefile.PL && make all dist From 9f62e018a6f2eef66622dcabce695a6ac9c682a1 Mon Sep 17 00:00:00 2001 From: Michael Timbert Date: Tue, 3 Mar 2026 13:50:21 +0100 Subject: [PATCH 21/34] fix sha commit --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5da4d4cac..1b5d65dad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,7 +151,12 @@ jobs: - name: Get short SHA id: short_sha - run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + run: | + if [ "${{ github.event_name }}" == "pull_request" ]; then + echo "SHORT_SHA=$(echo ${{ github.event.pull_request.head.sha }} | cut -c1-7)" >> $GITHUB_ENV + else + echo "SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_ENV + fi - name: Get Zonemaster-Backend version id: version @@ -164,5 +169,5 @@ jobs: - name: upload artifact uses: actions/upload-artifact@v4 with: - name: Zonemaster-Backend-${{ steps.version.outputs.version }}-${{ steps.short_sha.outputs.short_sha }} + name: Zonemaster-Backend-${{ steps.version.outputs.version }}-${{ env.SHORT_SHA }} path: Zonemaster-Backend-${{ steps.version.outputs.version }}.tar.gz From 4c4bad91083f0edd7017279df9687b115879ebf7 Mon Sep 17 00:00:00 2001 From: Michael Timbert Date: Thu, 11 Jun 2026 10:46:51 +0200 Subject: [PATCH 22/34] Documentation: add artifact section --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index d2a7552df..f9f912e0f 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,15 @@ The Zonemaster Backend documentation is split up into several documents: it through its life cycle, all using JSON-RPC calls to the *RPC API daemon*. * The [API] documentation describes the *RPC API daemon* inteface in detail. +## CI artifact + +A tarball (`Zonemaster-Backend-.tar.gz`) is built and uploaded as a GitHub Actions artifact on every push and pull request. This artifact can be useful for release testing and PR review. +To download it: +1. Go to the [Actions tab](https://github.com/zonemaster/zonemaster-backend/actions) of the repository. +2. Select a workflow run (e.g. for a specific PR or branch). +3. Scroll to the bottom of the run summary to the **Artifacts** section. +4. Download the artifact named `Zonemaster-Backend--`. +The artifact name includes the module version and the first 7 characters of the commit SHA. ## License From 932c9dbc1f875e70576831f7fafec847c7bd13c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20P=C3=A4iv=C3=A4rinta?= Date: Wed, 3 Jun 2026 12:42:30 +0200 Subject: [PATCH 23/34] Tidy --- lib/Zonemaster/Backend/RPCAPI.pm | 478 ++++++++++++++++--------------- 1 file changed, 250 insertions(+), 228 deletions(-) diff --git a/lib/Zonemaster/Backend/RPCAPI.pm b/lib/Zonemaster/Backend/RPCAPI.pm index 5d2dd64aa..cbc67c951 100644 --- a/lib/Zonemaster/Backend/RPCAPI.pm +++ b/lib/Zonemaster/Backend/RPCAPI.pm @@ -5,32 +5,32 @@ 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 DBI qw( :utils ); +use Digest::MD5 qw( md5_hex ); +use Encode; +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; +use Locale::Messages qw( LC_MESSAGES LC_ALL ); +use Locale::TextDomain qw( Zonemaster-Backend ); +use Log::Any qw( $log ); +use Mojo::JSON::Pointer; +use POSIX qw( setlocale ); +use Scalar::Util qw( blessed ); # 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; use Zonemaster::Backend::TLD_URL; +use Zonemaster::Backend::Translator; +use Zonemaster::Backend::Validator; +use Zonemaster::Backend; +use Zonemaster::Engine::Normalization qw( normalize_name trim_space ); +use Zonemaster::Engine::Profile; +use Zonemaster::Engine::Recursor; +use Zonemaster::Engine; my $zm_validator = Zonemaster::Backend::Validator->new; our %json_schemas; @@ -46,20 +46,21 @@ sub new { my $self = {}; bless( $self, $type ); - if ( ! $params || ! $params->{config} ) { - handle_exception("Missing 'config' parameter"); + 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}->check_db( $params->{dbtype} ); + } + else { $dbtype = $self->{config}->DB_engine; } - $self->_init_db($dbtype); + $self->_init_db( $dbtype ); $self->{_profiles} = Zonemaster::Backend::Config->load_profiles( # $self->{config}->PUBLIC_PROFILES, @@ -77,15 +78,15 @@ sub _init_db { $self->{db} = $dbclass->from_config( $self->{config} ); }; - if ($@) { - handle_exception("Failed to initialize the [$dbtype] database backend module: [$@]"); + if ( $@ ) { + handle_exception( "Failed to initialize the [$dbtype] database backend module: [$@]" ); } } sub handle_exception { my ( $exception ) = @_; - if ( !$exception->isa('Zonemaster::Backend::Error') ) { + if ( !$exception->isa( 'Zonemaster::Backend::Error' ) ) { my $reason = $exception; $exception = Zonemaster::Backend::Error::Internal->new( reason => $reason ); } @@ -93,26 +94,28 @@ sub handle_exception { 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); + 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_ldns} = Zonemaster::LDNS->VERSION; + $ver{zonemaster_engine} = Zonemaster::Engine->VERSION; $ver{zonemaster_backend} = Zonemaster::Backend->VERSION; }; - if ($@) { + if ( $@ ) { handle_exception( $@ ); } @@ -121,11 +124,13 @@ sub version_info { # 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 ) = @_; @@ -140,16 +145,16 @@ sub profile_names { # Experimental $json_schemas{conf_profiles} = $json_schemas{profile_names}; + sub conf_profiles { - my $result = { - profiles => profile_names( @_ ) - }; + 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 ) = @_; @@ -168,10 +173,9 @@ sub get_language_tags { # Experimental $json_schemas{conf_languages} = $json_schemas{get_language_tags}; + sub conf_languages { - my $result = { - languages => get_language_tags( @_ ) - }; + my $result = { languages => get_language_tags( @_ ) }; return $result; } @@ -183,44 +187,46 @@ separate Perl module. =cut - $json_schemas{get_tld_url} = { - type => 'object', + type => 'object', additionalProperties => 0, - required => [ 'domain' ], - properties => { + required => ['domain'], + properties => { domain => $zm_validator->domain_name } }; + sub get_tld_url { my ( $self, $params ) = @_; my $domain; - ( undef, $domain ) = normalize_name( trim_space ( $params->{domain} ) ); + ( undef, $domain ) = normalize_name( trim_space( $params->{domain} ) ); return Zonemaster::Backend::TLD_URL::process( $self, $domain ); } - $json_schemas{get_host_by_name} = { - type => 'object', + type => 'object', additionalProperties => 0, - required => [ 'hostname' ], - properties => { + required => ['hostname'], + properties => { hostname => $zm_validator->domain_name } }; + sub get_host_by_name { my ( $self, $params ) = @_; my @adresses; eval { - my $ns_name = $params->{hostname}; + my $ns_name = $params->{hostname}; - @adresses = map { {$ns_name => $_->short} } $recursor->get_addresses_for($ns_name); + @adresses = map { + { $ns_name => $_->short } + } $recursor->get_addresses_for( $ns_name ); @adresses = { $ns_name => '0.0.0.0' } if not @adresses; }; - if ($@) { + if ( $@ ) { handle_exception( $@ ); } @@ -229,46 +235,46 @@ sub get_host_by_name { # Experimental $json_schemas{lookup_address_records} = $json_schemas{get_host_by_name}; + sub lookup_address_records { - my $result = { - address_records => get_host_by_name( @_ ) - }; + my $result = { address_records => get_host_by_name( @_ ) }; return $result; } $json_schemas{get_data_from_parent_zone} = { - type => 'object', + type => 'object', additionalProperties => 0, - required => [ 'domain' ], - properties => { - domain => $zm_validator->domain_name, + 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 ( $_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}; + push @ns_list, { ns => $_->name->string, ip => $_->address->short } for @{ $zone->glue }; my @ds_list; - $zone = Zonemaster::Engine->zone($normalized_domain); + $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) { + 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 }); + push( @ds_list, { keytag => $ds->keytag, algorithm => $ds->algorithm, digtype => $ds->digtype, digest => $ds->hexdigest } ); } } @@ -276,45 +282,47 @@ sub get_data_from_parent_zone { $result{ds_list} = \@ds_list; return \%result; }; - if ($@) { + if ( $@ ) { handle_exception( $@ ); } - elsif ($result) { + 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', + type => 'object', additionalProperties => 0, - required => [ 'domain' ], - properties => { - domain => $zm_validator->domain_name, - ipv4 => joi->boolean->compile, - ipv6 => joi->boolean->compile, + required => ['domain'], + properties => { + domain => $zm_validator->domain_name, + ipv4 => joi->boolean->compile, + ipv6 => joi->boolean->compile, nameservers => { - type => 'array', + type => 'array', items => $zm_validator->nameserver }, ds_info => { - type => 'array', + type => 'array', items => $zm_validator->ds_info }, - profile => $zm_validator->profile_name, - client_id => $zm_validator->client_id->compile, + 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, + 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 ) = @_; @@ -330,7 +338,7 @@ sub start_domain_test { $result = $self->{db}->create_new_test( $params->{domain}, $params, $self->{config}->ZONEMASTER_age_reuse_previous_test ); }; - if ($@) { + if ( $@ ) { handle_exception( $@ ); } @@ -339,16 +347,16 @@ sub start_domain_test { # Experimental $json_schemas{job_create} = $json_schemas{start_domain_test}; + sub job_create { - my $result = { - job_id => start_domain_test( @_ ) - }; + my $result = { job_id => start_domain_test( @_ ) }; return $result; } -$json_schemas{test_progress} = joi->object->strict->props( +$json_schemas{test_progress} = joi->object->strict->props( # test_id => $zm_validator->test_id->required ); + sub test_progress { my ( $self, $params ) = @_; @@ -357,7 +365,7 @@ sub test_progress { my $test_id = $params->{test_id}; $result = $self->{db}->test_progress( $test_id ); }; - if ($@) { + if ( $@ ) { handle_exception( $@ ); } @@ -365,23 +373,23 @@ sub test_progress { } # Experimental -$json_schemas{job_status} = joi->object->strict->props( +$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 ) - }; + my $result = { progress => $self->test_progress( $params ) }; return $result; } -$json_schemas{get_test_params} = joi->object->strict->props( +$json_schemas{get_test_params} = joi->object->strict->props( # test_id => $zm_validator->test_id->required ); + sub get_test_params { my ( $self, $params ) = @_; @@ -391,7 +399,7 @@ sub get_test_params { $result = $self->{db}->get_test_params( $test_id ); }; - if ($@) { + if ( $@ ) { handle_exception( $@ ); } @@ -399,9 +407,10 @@ sub get_test_params { } # Experimental -$json_schemas{job_params} = joi->object->strict->props( +$json_schemas{job_params} = joi->object->strict->props( # job_id => $zm_validator->test_id->required ); + sub job_params { my ( $self, $params ) = @_; @@ -411,19 +420,20 @@ sub job_params { } $json_schemas{get_test_results} = { - type => 'object', + type => 'object', additionalProperties => 0, - required => [ 'id', 'language' ], - properties => { - id => $zm_validator->test_id->required->compile, + 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{ + eval { my $locale = $self->_get_locale( $params ); @@ -435,7 +445,7 @@ sub get_test_results { die "Failed to set locale: $locale"; } - eval { $translator->data } if $translator; # Provoke lazy loading of translation data + eval { $translator->data } if $translator; # Provoke lazy loading of translation data my @zm_results; my %testcases; @@ -453,13 +463,13 @@ sub get_test_results { next; } - $res->{module} = $test_res->{module}; + $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}); + $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/ ) { @@ -489,16 +499,16 @@ sub get_test_results { push( @zm_results, $res ); } - $result = $test_info; + $result = $test_info; $result->{testcase_descriptions} = \%testcases; - $result->{results} = \@zm_results; + $result->{results} = \@zm_results; $translator->locale( $previous_locale ); $result = $test_info; $result->{results} = \@zm_results; }; - if ($@) { + if ( $@ ) { handle_exception( $@ ); } @@ -507,14 +517,15 @@ sub get_test_results { # Experimental $json_schemas{job_results} = { - type => 'object', + type => 'object', additionalProperties => 0, - required => [ 'job_id', 'language' ], - properties => { - job_id => $zm_validator->test_id->required->compile, + required => [ 'job_id', 'language' ], + properties => { + job_id => $zm_validator->test_id->required->compile, language => $zm_validator->language_tag, } }; + sub job_results { my ( $self, $params ) = @_; @@ -532,23 +543,24 @@ sub job_results { } $json_schemas{get_test_history} = { - type => 'object', + 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, + 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', + type => 'object', additionalProperties => 0, - required => [ 'domain' ], - properties => { + required => ['domain'], + properties => { domain => $zm_validator->domain_name } } } }; + sub get_test_history { my ( $self, $params ) = @_; @@ -556,15 +568,17 @@ sub get_test_history { eval { $params->{offset} //= 0; - $params->{limit} //= 200; + $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; + my @results = map { + { %$_, undelegated => $_->{undelegated} ? JSON::PP::true : JSON::PP::false } + } @$results; $results = \@results; }; - if ($@) { + if ( $@ ) { handle_exception( $@ ); } @@ -573,23 +587,24 @@ sub get_test_history { # Experimental $json_schemas{domain_history} = { - type => 'object', + 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, + 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', + type => 'object', additionalProperties => 0, - required => [ 'domain' ], - properties => { + required => ['domain'], + properties => { domain => $zm_validator->domain_name } } } }; + sub domain_history { my ( $self, $params ) = @_; @@ -600,7 +615,7 @@ sub domain_history { return { history => [ map { - { + { # job_id => $_->{id}, created_at => $_->{created_at}, overall_result => $_->{overall_result}, @@ -613,8 +628,9 @@ sub domain_history { $json_schemas{add_api_user} = joi->object->strict->props( username => $zm_validator->username->required, - api_key => $zm_validator->api_key->required, + api_key => $zm_validator->api_key->required, ); + sub add_api_user { my ( $self, $params, undef, $remote_ip ) = @_; @@ -635,11 +651,11 @@ sub add_api_user { 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 } + data => { remote_ip => $remote_ip } ); } }; - if ($@) { + if ( $@ ) { handle_exception( $@ ); } @@ -648,50 +664,50 @@ sub add_api_user { # Experimental $json_schemas{user_create} = $json_schemas{add_api_user}; + sub user_create { - my $result = { - success => add_api_user( @_ ) - }; + my $result = { success => add_api_user( @_ ) }; return $result; } $json_schemas{add_batch_job} = { - type => 'object', + type => 'object', additionalProperties => 0, - required => [ 'username', 'api_key', 'domains' ], - properties => { + required => [ 'username', 'api_key', 'domains' ], + properties => { username => $zm_validator->username->required->compile, - api_key => $zm_validator->api_key->required->compile, - domains => { - type => "array", + api_key => $zm_validator->api_key->required->compile, + domains => { + type => "array", additionalItems => 0, - items => $zm_validator->domain_name, - minItems => 1 + items => $zm_validator->domain_name, + minItems => 1 }, test_params => { - type => 'object', + type => 'object', additionalProperties => 0, - properties => { - ipv4 => joi->boolean->compile, - ipv6 => joi->boolean->compile, + properties => { + ipv4 => joi->boolean->compile, + ipv6 => joi->boolean->compile, nameservers => { - type => 'array', + type => 'array', items => $zm_validator->nameserver }, ds_info => { - type => 'array', + type => 'array', items => $zm_validator->ds_info }, - profile => $zm_validator->profile_name, - client_id => $zm_validator->client_id->compile, + 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, + config => joi->string->compile, + priority => $zm_validator->priority->compile, + queue => $zm_validator->queue->compile, } } } }; + sub add_batch_job { my ( $self, $params ) = @_; @@ -707,7 +723,7 @@ sub add_batch_job { $results = $self->{db}->add_batch_job( $params ); }; - if ($@) { + if ( $@ ) { handle_exception( $@ ); } @@ -716,73 +732,71 @@ sub add_batch_job { # Experimental $json_schemas{batch_create} = { - type => 'object', + type => 'object', additionalProperties => 0, - required => [ 'username', 'api_key', 'domains' ], - properties => { + required => [ 'username', 'api_key', 'domains' ], + properties => { username => $zm_validator->username->required->compile, - api_key => $zm_validator->api_key->required->compile, - domains => { - type => "array", + api_key => $zm_validator->api_key->required->compile, + domains => { + type => "array", additionalItems => 0, - items => $zm_validator->domain_name, - minItems => 1 + items => $zm_validator->domain_name, + minItems => 1 }, job_params => { - type => 'object', + type => 'object', additionalProperties => 0, - properties => { - ipv4 => joi->boolean->compile, - ipv6 => joi->boolean->compile, + properties => { + ipv4 => joi->boolean->compile, + ipv6 => joi->boolean->compile, nameservers => { - type => 'array', + type => 'array', items => $zm_validator->nameserver }, ds_info => { - type => 'array', + type => 'array', items => $zm_validator->ds_info }, - profile => $zm_validator->profile_name, - client_id => $zm_validator->client_id->compile, + 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, + 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 ) - }; + my $result = { batch_id => $self->add_batch_job( $params ) }; return $result; } $json_schemas{batch_status} = { - type => 'object', + 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, + 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 ($@) { + eval { $result = $self->{db}->batch_status( $params ); }; + if ( $@ ) { handle_exception( $@ ); } return $result; @@ -815,9 +829,10 @@ sub _set_error_message_locale { my ( $self, $params ) = @_; my @error_response = (); - my $locale = $self->_get_locale( $params ); + my $locale = $self->_get_locale( $params ); + + if ( not defined $locale or $locale eq "" ) { - if (not defined $locale or $locale eq "") { # Don't translate message if locale is not defined $locale = "C"; } @@ -829,27 +844,29 @@ sub _set_error_message_locale { my $rpc_request = joi->object->props( jsonrpc => joi->string->required, - method => $zm_validator->jsonrpc_method()->required, - id => joi->type([qw(null number string)])); + 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) { + 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', + id => undef, + error => { + code => '-32600', message => 'The JSON sent is not a valid request object.', - data => "@error_rpc" + data => "@error_rpc" } - } + }; } - my $method_schema = $json_schemas{$jsonrpc_request->{method}}; - if (blessed $method_schema) { + my $method_schema = $json_schemas{ $jsonrpc_request->{method} }; + if ( blessed $method_schema) { $method_schema = $method_schema->compile; } @@ -860,24 +877,24 @@ sub jsonrpc_validate { if ( exists $method_schema->{required} and not exists $jsonrpc_request->{params} ) { return { jsonrpc => '2.0', - id => $jsonrpc_request->{id}, - error => { - code => '-32602', + 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}); + 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 + id => $jsonrpc_request->{id}, + error => { + code => '-32602', + message => decode_utf8( __ 'Invalid method parameter(s).' ), + data => \@error_response } }; } @@ -892,40 +909,43 @@ sub validate_params { push @error_response, $self->_set_error_message_locale( $params ); - if (blessed $method_schema) { + 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 $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}; + my @details = @{ $err->details }; # Handle 'required' errors globally so it does not get overwritten - if ($details[1] eq 'required') { + if ( $details[1] eq 'required' ) { $message = N__ 'Missing property'; - } else { + } + else { my @path = split '/', $err->path, -1; - shift @path; # first item is an empty string + 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 { + 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); + $data = Mojo::JSON::Pointer->new( $data ); } - if ($found and exists $data->data->{'x-error-message'}) { + if ( $found and exists $data->data->{'x-error-message'} ) { $message = $data->data->{'x-error-message'}; } } @@ -935,7 +955,9 @@ sub validate_params { } # Translate messages - @error_response = map { { %$_, ( message => decode_utf8 __ $_->{message} ) } } @error_response; + @error_response = map { + { %$_, ( message => decode_utf8 __ $_->{message} ) } + } @error_response; return @error_response; } From ac8e0eaef3e8036c017af780b9fff80d190c0a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20P=C3=A4iv=C3=A4rinta?= Date: Wed, 3 Jun 2026 14:25:13 +0200 Subject: [PATCH 24/34] Fixes * Link to public doc site instead of github master branch * Report startup errors as strings, not JSON-RPC objects * Avoid premature database connection check * Put Perl version first * Clean up redundant strict pragmas --- lib/Zonemaster/Backend/Config.pm | 56 ++++++++++++++--------------- lib/Zonemaster/Backend/RPCAPI.pm | 8 ++--- lib/Zonemaster/Backend/TestAgent.pm | 6 ++-- script/zonemaster_backend_testagent | 5 ++- t/parameters_validation.t | 3 +- t/rpc_validation.t | 3 +- t/test_validate_syntax.t | 3 +- 7 files changed, 42 insertions(+), 42 deletions(-) diff --git a/lib/Zonemaster/Backend/Config.pm b/lib/Zonemaster/Backend/Config.pm index c3e0b0d1b..d2e2650a0 100644 --- a/lib/Zonemaster/Backend/Config.pm +++ b/lib/Zonemaster/Backend/Config.pm @@ -149,7 +149,7 @@ Construct a new Zonemaster::Backend::Config based on a given configuration. ); The configuration is interpreted according to the -L. +L. Returns a new Zonemaster::Backend::Config instance with its properties set to normalized and untainted values according to the given configuration with @@ -442,7 +442,7 @@ sub check_db { =head2 DB_engine -Get the value of L. +Get the value of L. Returns one of C<"SQLite">, C<"PostgreSQL"> or C<"MySQL">. @@ -465,28 +465,28 @@ sub _set_DB_engine { =head2 DB_polling_interval -Get the value of L. +Get the value of L. Returns a number. =head2 MYSQL_database -Get the value of L. +Get the value of L. Returns a string. =head2 MYSQL_host -Get the value of L. +Get the value of L. Returns a string. =head2 MYSQL_port -Returns the L +Returns the L property from the loaded config. Returns a number. @@ -494,35 +494,35 @@ Returns a number. =head2 MYSQL_password -Get the value of L. +Get the value of L. Returns a string. =head2 MYSQL_user -Get the value of L. +Get the value of L. Returns a string. =head2 POSTGRESQL_database -Get the value of L. +Get the value of L. Returns a string. =head2 POSTGRESQL_host -Get the value of L. +Get the value of L. Returns a string. =head2 POSTGRESQL_port -Returns the L +Returns the L property from the loaded config. Returns a number. @@ -530,28 +530,28 @@ Returns a number. =head2 POSTGRESQL_password -Get the value of L. +Get the value of L. Returns a string. =head2 POSTGRESQL_user -Get the value of L. +Get the value of L. Returns a string. =head2 SQLITE_database_file -Get the value of L. +Get the value of L. Returns a string. =head2 LANGUAGE_locale -Get the value of L. +Get the value of L. Returns a mapping from two-letter locale tag prefixes to full locale tags. This is represented by a hash mapping prefix to full locale tag. @@ -566,7 +566,7 @@ E.g.: =head2 PUBLIC_PROFILES -Get the set of L. +Get the set of L. Returns a hash mapping profile names to profile paths. The profile names are normalized to lowercase. @@ -576,7 +576,7 @@ C means that the Zonemaster Engine default profile should be used. =head2 PRIVATE_PROFILES -Get the set of L. +Get the set of L. Returns a hash mapping profile names to profile paths. The profile names are normalized to lowercase. @@ -614,7 +614,7 @@ L. +Get the value of L. Returns a number. @@ -622,7 +622,7 @@ Returns a number. =head2 ZONEMASTER_number_of_processes_for_frontend_testing Get the value of -L. +L. Returns a number. @@ -630,7 +630,7 @@ Returns a number. =head2 ZONEMASTER_number_of_processes_for_batch_testing Get the value of -L. +L. Returns a number. @@ -638,7 +638,7 @@ Returns a number. =head2 ZONEMASTER_lock_on_queue Get the value of -L. +L. Returns a number. @@ -646,7 +646,7 @@ Returns a number. =head2 ZONEMASTER_age_reuse_previous_test Get the value of -L. +L. Returns a number. @@ -654,7 +654,7 @@ Returns a number. =head2 METRICS_statsd_host Get the value of -L. +L. Returns a string. @@ -662,7 +662,7 @@ Returns a string. =head2 METRICS_statsd_port Get the value of -L. +L. Returns a number. @@ -671,7 +671,7 @@ Returns a number. Experimental. Get the value of -L. +L. Return 0 or 1 @@ -680,7 +680,7 @@ Return 0 or 1 Experimental. Get the value of -L. +L. Return 0 or 1 @@ -688,7 +688,7 @@ Return 0 or 1 =head2 RPCAPI_enable_add_api_user Get the value of -L. +L. Return 0 or 1 @@ -696,7 +696,7 @@ Return 0 or 1 =head2 RPCAPI_enable_add_batch_job Get the value of -L. +L. Return 0 or 1 diff --git a/lib/Zonemaster/Backend/RPCAPI.pm b/lib/Zonemaster/Backend/RPCAPI.pm index cbc67c951..727505994 100644 --- a/lib/Zonemaster/Backend/RPCAPI.pm +++ b/lib/Zonemaster/Backend/RPCAPI.pm @@ -1,10 +1,10 @@ package Zonemaster::Backend::RPCAPI; -use strict; -use warnings; use 5.14.2; +use warnings; # Public Modules +use Carp qw( croak ); use DBI qw( :utils ); use Digest::MD5 qw( md5_hex ); use Encode; @@ -47,7 +47,7 @@ sub new { bless( $self, $type ); if ( !$params || !$params->{config} ) { - handle_exception( "Missing 'config' parameter" ); + croak "Missing 'config' parameter"; } $self->{config} = $params->{config}; @@ -79,7 +79,7 @@ sub _init_db { }; if ( $@ ) { - handle_exception( "Failed to initialize the [$dbtype] database backend module: [$@]" ); + croak "Failed to initialize the [$dbtype] database backend module: $@"; } } diff --git a/lib/Zonemaster/Backend/TestAgent.pm b/lib/Zonemaster/Backend/TestAgent.pm index aaf182e47..5e96be49b 100644 --- a/lib/Zonemaster/Backend/TestAgent.pm +++ b/lib/Zonemaster/Backend/TestAgent.pm @@ -1,9 +1,9 @@ package Zonemaster::Backend::TestAgent; -our $VERSION = '1.1.0'; -use strict; -use warnings; use 5.14.2; +use warnings; + +our $VERSION = '1.1.0'; use DBI qw(:utils); use JSON::PP; diff --git a/script/zonemaster_backend_testagent b/script/zonemaster_backend_testagent index e21ac0f82..bff1fd63b 100755 --- a/script/zonemaster_backend_testagent +++ b/script/zonemaster_backend_testagent @@ -164,7 +164,10 @@ sub preflight_checks { Zonemaster::Backend::Metrics->setup($initial_config->METRICS_statsd_host, $initial_config->METRICS_statsd_port); # Validate the Zonemaster-Engine profile - Zonemaster::Backend::TestAgent->new( { config => $initial_config } ); + Zonemaster::Backend::Config->load_profiles( # + $initial_config->PUBLIC_PROFILES, + $initial_config->PRIVATE_PROFILES, + ); # Connect to the database $initial_config->new_DB(); diff --git a/t/parameters_validation.t b/t/parameters_validation.t index f0a5331eb..66f4af236 100644 --- a/t/parameters_validation.t +++ b/t/parameters_validation.t @@ -1,6 +1,5 @@ -use strict; -use warnings; use 5.14.2; +use warnings; use utf8; use Test::More tests => 4; diff --git a/t/rpc_validation.t b/t/rpc_validation.t index de8a4f9e4..f5c0e4056 100644 --- a/t/rpc_validation.t +++ b/t/rpc_validation.t @@ -1,6 +1,5 @@ -use strict; -use warnings; use 5.14.2; +use warnings; use utf8; use Test::More tests => 30; diff --git a/t/test_validate_syntax.t b/t/test_validate_syntax.t index 84faea46a..17295c4d5 100644 --- a/t/test_validate_syntax.t +++ b/t/test_validate_syntax.t @@ -1,6 +1,5 @@ -use strict; -use warnings; use 5.14.2; +use warnings; use utf8; use Test::More tests => 2; From a55cf6bb6e3cddb11744c5ed1d3eb14e25ccc8b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20P=C3=A4iv=C3=A4rinta?= Date: Wed, 3 Jun 2026 15:43:07 +0200 Subject: [PATCH 25/34] Refactor Make the RPCAPI constructor not build its own dependencies, allowing the caller to access the database handle before providing it to the RPCAPI constructor. --- lib/Zonemaster/Backend/Config.pm | 86 +++++++++++++-------------- lib/Zonemaster/Backend/RPCAPI.pm | 50 +++++----------- script/zonemaster_backend_rpcapi.psgi | 12 +++- t/TestUtil.pm | 45 ++++++++------ t/parameters_validation.t | 17 ++++-- t/rpc_validation.t | 17 ++++-- t/test_validate_syntax.t | 17 ++++-- 7 files changed, 128 insertions(+), 116 deletions(-) diff --git a/lib/Zonemaster/Backend/Config.pm b/lib/Zonemaster/Backend/Config.pm index d2e2650a0..8ad3e01e2 100644 --- a/lib/Zonemaster/Backend/Config.pm +++ b/lib/Zonemaster/Backend/Config.pm @@ -9,8 +9,8 @@ use Carp qw( confess croak ); use Config::IniFiles; use Config; use File::ShareDir qw[dist_file]; -use File::Slurp qw( read_file ); -use Log::Any qw( $log ); +use File::Slurp qw( read_file ); +use Log::Any qw( $log ); use Readonly; use Zonemaster::Backend::Validator qw( :untaint ); use Zonemaster::Backend::DB; @@ -189,7 +189,7 @@ sub parse { my $get_and_clear = sub { # Read and clear a property from a Config::IniFiles object. my ( $section, $param ) = @_; - my ( $value, @extra ) = $ini->val( $section, $param ); + my ( $value, @extra ) = $ini->val( $section, $param ); if ( @extra ) { die "Property not unique: $section.$param\n"; } @@ -199,18 +199,7 @@ sub parse { # Validate section names { - my %sections = map { $_ => 1 } ( 'DB', - 'MYSQL', - 'POSTGRESQL', - 'SQLITE', - 'LANGUAGE', - 'PUBLIC PROFILES', - 'PRIVATE PROFILES', - 'ZONEMASTER', - 'METRICS', - 'RPCAPI', - 'TLD URL SETTINGS', - 'TLD URL OVERRIDE'); + my %sections = map { $_ => 1 } ( 'DB', 'MYSQL', 'POSTGRESQL', 'SQLITE', 'LANGUAGE', 'PUBLIC PROFILES', 'PRIVATE PROFILES', 'ZONEMASTER', 'METRICS', 'RPCAPI', 'TLD URL SETTINGS', 'TLD URL OVERRIDE' ); for my $section ( $ini->Sections ) { if ( !exists $sections{$section} ) { die "config: unrecognized section: $section\n"; @@ -230,8 +219,8 @@ sub parse { $obj->_set_ZONEMASTER_number_of_processes_for_batch_testing( '20' ); $obj->_set_ZONEMASTER_lock_on_queue( '0' ); $obj->_set_ZONEMASTER_age_reuse_previous_test( '600' ); - $obj->_set_RPCAPI_enable_user_create( 'no' ); # experimental - $obj->_set_RPCAPI_enable_batch_create( 'yes' ); # experimental + $obj->_set_RPCAPI_enable_user_create( 'no' ); # experimental + $obj->_set_RPCAPI_enable_batch_create( 'yes' ); # experimental $obj->_set_RPCAPI_enable_add_api_user( 'no' ); $obj->_set_RPCAPI_enable_add_batch_job( 'yes' ); $obj->_set_locales( 'en_US' ); @@ -250,6 +239,7 @@ sub parse { # Check deprecated properties and assign fallback values my @warnings; + #currently no deprecation warnings # Assign property values (part 2/2) @@ -328,7 +318,8 @@ sub parse { } $obj->_set_RPCAPI_enable_add_api_user( $value ); $obj->_set_RPCAPI_enable_user_create( $value ); - } else { + } + else { if ( defined( my $value = $get_and_clear->( 'RPCAPI', 'enable_add_api_user' ) ) ) { $obj->_set_RPCAPI_enable_add_api_user( $value ); $obj->_set_RPCAPI_enable_user_create( $value ); @@ -340,7 +331,8 @@ sub parse { } $obj->_set_RPCAPI_enable_add_batch_job( $value ); $obj->_set_RPCAPI_enable_batch_create( $value ); - } else { + } + else { if ( defined( my $value = $get_and_clear->( 'RPCAPI', 'enable_add_batch_job' ) ) ) { $obj->_set_RPCAPI_enable_add_batch_job( $value ); $obj->_set_RPCAPI_enable_batch_create( $value ); @@ -359,7 +351,7 @@ sub parse { my $path = $get_and_clear->( 'PRIVATE PROFILES', $name ); $obj->_add_private_profile( $name, $path ); } - + for my $name ( $ini->Parameters( 'TLD URL OVERRIDE' ) ) { my $path = $get_and_clear->( 'TLD URL OVERRIDE', $name ); $obj->_add_tld_url_override( $name, $path ); @@ -439,7 +431,6 @@ sub check_db { return _normalize_engine_type( $db ); } - =head2 DB_engine Get the value of L. @@ -729,8 +720,8 @@ sub ZONEMASTER_number_of_processes_for_batch_testing { return $_[0]->{_ZONEMA sub ZONEMASTER_age_reuse_previous_test { return $_[0]->{_ZONEMASTER_age_reuse_previous_test}; } sub METRICS_statsd_host { return $_[0]->{_METRICS_statsd_host}; } sub METRICS_statsd_port { return $_[0]->{_METRICS_statsd_port}; } -sub RPCAPI_enable_user_create { return $_[0]->{_RPCAPI_enable_user_create}; } # experimental -sub RPCAPI_enable_batch_create { return $_[0]->{_RPCAPI_enable_batch_create}; } # experimental +sub RPCAPI_enable_user_create { return $_[0]->{_RPCAPI_enable_user_create}; } # experimental +sub RPCAPI_enable_batch_create { return $_[0]->{_RPCAPI_enable_batch_create}; } # experimental sub RPCAPI_enable_add_api_user { return $_[0]->{_RPCAPI_enable_add_api_user}; } sub RPCAPI_enable_add_batch_job { return $_[0]->{_RPCAPI_enable_add_batch_job}; } @@ -758,35 +749,39 @@ UNITCHECK { _create_setter( '_set_ZONEMASTER_age_reuse_previous_test', '_ZONEMASTER_age_reuse_previous_test', \&untaint_strictly_positive_int ); _create_setter( '_set_METRICS_statsd_host', '_METRICS_statsd_host', \&untaint_host ); _create_setter( '_set_METRICS_statsd_port', '_METRICS_statsd_port', \&untaint_strictly_positive_int ); - _create_setter( '_set_RPCAPI_enable_user_create', '_RPCAPI_enable_user_create', \&untaint_bool ); # experimental - _create_setter( '_set_RPCAPI_enable_batch_create', '_RPCAPI_enable_batch_create', \&untaint_bool ); # experimental + _create_setter( '_set_RPCAPI_enable_user_create', '_RPCAPI_enable_user_create', \&untaint_bool ); # experimental + _create_setter( '_set_RPCAPI_enable_batch_create', '_RPCAPI_enable_batch_create', \&untaint_bool ); # experimental _create_setter( '_set_RPCAPI_enable_add_api_user', '_RPCAPI_enable_add_api_user', \&untaint_bool ); _create_setter( '_set_RPCAPI_enable_add_batch_job', '_RPCAPI_enable_add_batch_job', \&untaint_bool ); } - -=head2 new_DB +=head2 new_DB( %opts ) Create a new database adapter object according to configuration. -The adapter connects to the database before it is returned. +=head3 INPUTS -=head3 INPUT +Options: + +=over 4 + +=item override_dbtype -The database adapter class is selected based on the return value of -L. -The database adapter class constructor is called without arguments and is -expected to configure itself according to available global configuration. +A L value. +Determines the database adapter class to invoke. +If not provided, the value of DB.engine is used. + +=back =head3 RETURNS -A configured L object. +A configured and connected L object. =head3 EXCEPTIONS =over 4 -=item Dies if no adapter for the configured database engine can be loaded. +=item Dies if an invalid option is given. =item Dies if the adapter is unable to connect to the database. @@ -795,9 +790,15 @@ A configured L object. =cut sub new_DB { - my ( $self ) = @_; + my ( $self, %opts ) = @_; - my $dbtype = $self->DB_engine; + my $dbtype_opt = delete $opts{override_dbtype}; + + if ( %opts ) { + croak 'Unrecognized options: ' . join( ', ', sort keys %opts ); + } + + my $dbtype = $self->check_db( $dbtype_opt // $self->DB_engine ); my $dbclass = Zonemaster::Backend::DB->get_db_class( $dbtype ); my $db = $dbclass->from_config( $self ); @@ -958,16 +959,17 @@ sub _add_tld_url_override { my ( $self, $tld, $value ) = @_; unless ( untaint_tld_label( $tld ) ) { - die "Invalid TLD label in TLD URL OVERRIDE section: $tld\n"; + die "Invalid TLD label in TLD URL OVERRIDE section: $tld\n"; } if ( exists $self->{_tld_url_override}{$tld} ) { die "TLD label not unique: $tld\n"; } - unless ( untaint_tld_block( $value ) or - untaint_tld_url_no_path( $value ) or - untaint_tld_url_string( $value ) ) { + unless ( untaint_tld_block( $value ) + or untaint_tld_url_no_path( $value ) + or untaint_tld_url_string( $value ) ) + { die "Invalid value for a TLD label key: $value\n"; } @@ -975,8 +977,6 @@ sub _add_tld_url_override { return; } - - # Create a setter method with a given name using the given field and validator sub _create_setter { my ( $setter, $field, $validate ) = @_; diff --git a/lib/Zonemaster/Backend/RPCAPI.pm b/lib/Zonemaster/Backend/RPCAPI.pm index 727505994..d6a361fb3 100644 --- a/lib/Zonemaster/Backend/RPCAPI.pm +++ b/lib/Zonemaster/Backend/RPCAPI.pm @@ -3,7 +3,6 @@ package Zonemaster::Backend::RPCAPI; use 5.14.2; use warnings; -# Public Modules use Carp qw( croak ); use DBI qw( :utils ); use Digest::MD5 qw( md5_hex ); @@ -19,9 +18,6 @@ use Log::Any qw( $log ); use Mojo::JSON::Pointer; use POSIX qw( setlocale ); use Scalar::Util qw( blessed ); - -# Zonemaster Modules -use Zonemaster::Backend::Config; use Zonemaster::Backend::Errors; use Zonemaster::Backend::TLD_URL; use Zonemaster::Backend::Translator; @@ -41,46 +37,28 @@ sub joi { } sub new { - my ( $type, $params ) = @_; + my ( $class, %args ) = @_; - my $self = {}; - bless( $self, $type ); + my $config = delete $args{config} + or croak "Missing 'config' argument"; - if ( !$params || !$params->{config} ) { - croak "Missing 'config' parameter"; - } + my $db = delete $args{db} + or croak "Missing 'db' argument"; - $self->{config} = $params->{config}; + my $profiles = delete $args{profiles} + or croak "Missing 'profiles' argument"; - my $dbtype; - if ( $params->{dbtype} ) { - $dbtype = $self->{config}->check_db( $params->{dbtype} ); - } - else { - $dbtype = $self->{config}->DB_engine; + if ( %args ) { + croak 'Unrecognized arguments: ' . join( ', ', sort keys %args ); } - $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} ); + my $obj = { + config => $config, + db => $db, + _profiles => $profiles, }; - if ( $@ ) { - croak "Failed to initialize the [$dbtype] database backend module: $@"; - } + return bless $obj, $class; } sub handle_exception { diff --git a/script/zonemaster_backend_rpcapi.psgi b/script/zonemaster_backend_rpcapi.psgi index 7cefc9d9f..98ecac2b9 100644 --- a/script/zonemaster_backend_rpcapi.psgi +++ b/script/zonemaster_backend_rpcapi.psgi @@ -39,7 +39,11 @@ $SIG{__WARN__} = sub { $log->warning(map s/^\s+|\s+$//gr, map s/\n/ /gr, @_); }; -my $config = Zonemaster::Backend::Config->load_config(); +my $config = Zonemaster::Backend::Config->load_config(); +my $profiles = Zonemaster::Backend::Config->load_profiles( # + $config->PUBLIC_PROFILES, + $config->PRIVATE_PROFILES, +); Zonemaster::Backend::Metrics->setup($config->METRICS_statsd_host, $config->METRICS_statsd_port); Zonemaster::Engine::init_engine(); @@ -55,7 +59,11 @@ builder { }; }; -my $handler = Zonemaster::Backend::RPCAPI->new( { config => $config } ); +my $handler = Zonemaster::Backend::RPCAPI->new( + config => $config, + db => $config->new_DB, + profiles => $profiles, +); my $router = router { ############## FRONTEND #################### diff --git a/t/TestUtil.pm b/t/TestUtil.pm index fd62de463..debff82f6 100644 --- a/t/TestUtil.pm +++ b/t/TestUtil.pm @@ -5,6 +5,7 @@ use warnings; use Test::More; +use Carp qw( croak ); use Zonemaster::Engine; use Zonemaster::Backend::Config; @@ -106,28 +107,33 @@ sub init_db { } sub create_rpcapi { - my ( $config ) = @_; + my ( $config, %opts ) = @_; - my $rpcapi; - eval { - $rpcapi = Zonemaster::Backend::RPCAPI->new( - { - dbtype => $db_backend, - config => $config, - } - ); - }; - if ( $@ ) { - diag explain( $@ ); - BAIL_OUT( 'Could not connect to database' ); + my $dbtype_opt = delete $opts{override_dbtype}; + if ( %opts ) { + croak 'Unrecognized options: ' . join( ', ', sort keys %opts ); } - if ( not $rpcapi->isa('Zonemaster::Backend::RPCAPI' ) ) { + my $profiles = Zonemaster::Backend::Config->load_profiles( # + $config->PUBLIC_PROFILES, + $config->PRIVATE_PROFILES, + ); + + my $dbtype = $dbtype_opt // $db_backend; + my $db = $config->new_DB( override_dbtype => $dbtype ); + + prepare_db( $db ); + + my $rpcapi = Zonemaster::Backend::RPCAPI->new( + config => $config, + db => $db, + profiles => $profiles, + ); + + if ( not $rpcapi->isa( 'Zonemaster::Backend::RPCAPI' ) ) { BAIL_OUT( 'Not a Zonemaster::Backend::RPCAPI object' ); } - prepare_db( $rpcapi->{db} ); - return $rpcapi; } @@ -178,11 +184,16 @@ file. Database tables are dropped and created anew. -=item create_rpcapi($config) +=item create_rpcapi($config, %opts) Returns a new Zonemaster::Backend::RPCAPI object using the provided C<$config> file. +The C option overrides the effective value of C. + +The value of C in C<$config> is ignored. The effective value is taken from the +C option, C environment variable, or C by default. + Database tables are dropped and created anew. =item create_testagent($config) diff --git a/t/parameters_validation.t b/t/parameters_validation.t index 66f4af236..097acf55e 100644 --- a/t/parameters_validation.t +++ b/t/parameters_validation.t @@ -5,6 +5,16 @@ use utf8; use Test::More tests => 4; use Test::NoWarnings; +my $t_path; + +BEGIN { + use File::Spec::Functions qw( rel2abs ); + use File::Basename qw( dirname ); + $t_path = dirname( rel2abs( $0 ) ); +} +use lib $t_path; +use TestUtil; + use Cwd; use File::Temp qw[tempdir]; use Zonemaster::Backend::Config; @@ -26,12 +36,7 @@ database_file = $tempdir/zonemaster.sqlite test = $cwd/t/test_profile.json EOF -my $rpcapi = Zonemaster::Backend::RPCAPI->new( - { - dbtype => $config->DB_engine, - config => $config, - } -); +my $rpcapi = TestUtil::create_rpcapi( $config, override_dbtype => $config->DB_engine ); sub test_validation { my ( $method_name, $method_schema, $test_cases ) = @_; diff --git a/t/rpc_validation.t b/t/rpc_validation.t index f5c0e4056..bb6c6b352 100644 --- a/t/rpc_validation.t +++ b/t/rpc_validation.t @@ -5,6 +5,16 @@ use utf8; use Test::More tests => 30; use Test::NoWarnings; +my $t_path; + +BEGIN { + use File::Spec::Functions qw( rel2abs ); + use File::Basename qw( dirname ); + $t_path = dirname( rel2abs( $0 ) ); +} +use lib $t_path; +use TestUtil; + use Cwd; use File::Temp qw[tempdir]; use Zonemaster::Backend::Config; @@ -30,12 +40,7 @@ database_file = $tempdir/zonemaster.sqlite test = $cwd/t/test_profile.json EOF -my $rpcapi = Zonemaster::Backend::RPCAPI->new( - { - dbtype => $config->DB_engine, - config => $config, - } -); +my $rpcapi = TestUtil::create_rpcapi( $config, override_dbtype => $config->DB_engine ); ### ### JSONRPC request object construction helper diff --git a/t/test_validate_syntax.t b/t/test_validate_syntax.t index 17295c4d5..411a310c0 100644 --- a/t/test_validate_syntax.t +++ b/t/test_validate_syntax.t @@ -5,6 +5,16 @@ use utf8; use Test::More tests => 2; use Test::NoWarnings; +my $t_path; + +BEGIN { + use File::Spec::Functions qw( rel2abs ); + use File::Basename qw( dirname ); + $t_path = dirname( rel2abs( $0 ) ); +} +use lib $t_path; +use TestUtil; + use Encode; use File::ShareDir qw[dist_file]; use JSON::PP; @@ -25,12 +35,7 @@ database_file = $tempdir/zonemaster.sqlite locale = en_US fr_FR da_DK fi_FI nb_NO sl_SI sv_SE EOF -my $engine = Zonemaster::Backend::RPCAPI->new( - { - dbtype => $config->DB_engine, - config => $config, - } -); +my $engine = TestUtil::create_rpcapi( $config, override_dbtype => $config->DB_engine ); sub start_domain_validate_params { return $engine->validate_params( $Zonemaster::Backend::RPCAPI::json_schemas{start_domain_test}, @_ ); From d6038049512e57f98408188722475b3e2f9359a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20P=C3=A4iv=C3=A4rinta?= Date: Mon, 4 May 2026 11:02:11 +0200 Subject: [PATCH 26/34] Add schema version --- MANIFEST | 2 + lib/Zonemaster/Backend/DB.pm | 74 +++++++++++++- lib/Zonemaster/Backend/DB/MySQL.pm | 30 ++++++ lib/Zonemaster/Backend/DB/PostgreSQL.pm | 37 ++++++- lib/Zonemaster/Backend/DB/SQLite.pm | 31 +++++- lib/Zonemaster/Backend/RPCAPI.pm | 2 + lib/Zonemaster/Backend/TestAgent.pm | 1 + script/zonemaster_backend_rpcapi.psgi | 5 +- share/patch/patch_db_schema_version_1.pl | 35 +++++++ t/TestUtil.pm | 3 +- t/db_schema_version.t | 121 +++++++++++++++++++++++ 11 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 share/patch/patch_db_schema_version_1.pl create mode 100644 t/db_schema_version.t diff --git a/MANIFEST b/MANIFEST index 551aa6246..250d6dbb9 100644 --- a/MANIFEST +++ b/MANIFEST @@ -53,6 +53,7 @@ share/locale/nb/LC_MESSAGES/Zonemaster-Backend.mo share/locale/sl/LC_MESSAGES/Zonemaster-Backend.mo share/locale/sv/LC_MESSAGES/Zonemaster-Backend.mo share/Makefile +share/patch/patch_db_schema_version_1.pl share/patch/patch_db_zonemaster_backend_ver_11.1.0.pl share/patch/patch_db_zonemaster_backend_ver_11.2.0.pl share/patch/patch_db_zonemaster_backend_ver_9.0.0.pl @@ -77,6 +78,7 @@ t/batches.t t/config.t t/db.t t/db_ddl.t +t/db_schema_version.t t/idn.data t/idn.t t/lifecycle.t diff --git a/lib/Zonemaster/Backend/DB.pm b/lib/Zonemaster/Backend/DB.pm index b83b6469f..c2376f35e 100644 --- a/lib/Zonemaster/Backend/DB.pm +++ b/lib/Zonemaster/Backend/DB.pm @@ -6,8 +6,9 @@ use Moose::Role; use 5.14.2; -use DBI qw(:sql_types); -use Digest::MD5 qw(md5_hex); +use Carp qw( croak ); +use DBI qw( :sql_types ); +use Digest::MD5 qw( md5_hex ); use Encode; use Exporter qw( import ); use JSON::PP; @@ -28,6 +29,7 @@ requires qw( get_dbh_specific_attributes get_relative_start_time is_duplicate + is_unknown_table ); has 'data_source_name' => ( @@ -54,6 +56,14 @@ has 'dbhandle' => ( required => 1, ); +=head2 $REQUIRED_SCHEMA_VERSION + +A positive integer. The database schema version that this module is compatible with. + +=cut + +Readonly our $REQUIRED_SCHEMA_VERSION => 1; + =head2 $TEST_WAITING The test is waiting to be processed. @@ -156,6 +166,66 @@ sub dbh { return $self->dbhandle; } +=head2 get_schema_version + +Detect the schema version of the database. + +Returns an unsigned integer. + +The C<0> value means the database is in a schema state prior to schema versioning. + +=cut + +sub get_schema_version { + my ( $self ) = @_; + + my $dbh = $self->dbh; + + local $dbh->{RaiseError} = 0; + local $dbh->{PrintError} = 0; + my $result = $dbh->selectcol_arrayref( "SELECT version FROM schema_version LIMIT 2" ); + + if ( $dbh->err ) { + if ( !$self->is_unknown_table ) { + croak "Failed to read schema version: " . $dbh->errstr; + } + + return 0; + } + + if ( @$result != 1 + || !defined $result->[0] + || $result->[0] !~ qr{^[1-9][0-9]*$} + || $result->[0] < 1 ) + { + croak 'Invalid schema version declaration'; + } + + return $result->[0]; +} + +=head2 assert_compatible_schema + +Assert that the database has a schema version that is compatible with this version of +Zonemaster Backend. + +Croaks if the database has an incompatible schema version, or if it is in an illegal state +with regard to schema version encoding. + +=cut + +sub assert_compatible_schema { + my ( $self ) = @_; + + my $db_schema_version = $self->get_schema_version; + + if ( $db_schema_version ne $REQUIRED_SCHEMA_VERSION ) { + croak "Expected schema version $REQUIRED_SCHEMA_VERSION, found $db_schema_version"; + } + + return; +} + sub add_api_user { my ( $self, $username, $api_key ) = @_; diff --git a/lib/Zonemaster/Backend/DB/MySQL.pm b/lib/Zonemaster/Backend/DB/MySQL.pm index 86ec1d7df..cbd12072b 100644 --- a/lib/Zonemaster/Backend/DB/MySQL.pm +++ b/lib/Zonemaster/Backend/DB/MySQL.pm @@ -205,6 +205,26 @@ sub create_schema { ' ) or die Zonemaster::Backend::Error::Internal->new( reason => "MySQL error, could not create 'users' table", data => $dbh->errstr() ); + #################################################################### + # SCHEMA VERSION + #################################################################### + $dbh->do( + "CREATE TABLE IF NOT EXISTS schema_version ( + id INTEGER PRIMARY KEY, + version INTEGER NOT NULL, + CHECK (id = 1), + CHECK (version >= 1) + )" + ) + or die Zonemaster::Backend::Error::Internal->new( + reason => "Failed to create 'schema_version' table", + data => $dbh->errstr() + ); + + if ( !defined $dbh->selectrow_array( "SELECT 1 FROM schema_version LIMIT 1" ) ) { + $dbh->do( "INSERT INTO schema_version (id, version) VALUES (1, ?)", {}, $Zonemaster::Backend::DB::REQUIRED_SCHEMA_VERSION ); + } + return; } @@ -234,6 +254,7 @@ sub drop_tables { } } + $self->dbh->do( "DROP TABLE IF EXISTS schema_version" ); $self->dbh->do( "DROP TABLE IF EXISTS test_results" ); $self->dbh->do( "DROP TABLE IF EXISTS result_entries" ); $self->dbh->do( "DROP TABLE IF EXISTS log_level" ); @@ -338,6 +359,15 @@ sub is_duplicate { return ( $self->dbh->err == 1062 ); } +sub is_unknown_table { + my ( $self ) = @_; + + # for the list of codes see: + # https://mariadb.com/kb/en/mariadb-error-codes/ + # https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html + return ( $self->dbh->err == 1146 ); +} + no Moose; __PACKAGE__->meta()->make_immutable(); diff --git a/lib/Zonemaster/Backend/DB/PostgreSQL.pm b/lib/Zonemaster/Backend/DB/PostgreSQL.pm index 617d12abc..581a310fa 100644 --- a/lib/Zonemaster/Backend/DB/PostgreSQL.pm +++ b/lib/Zonemaster/Backend/DB/PostgreSQL.pm @@ -184,6 +184,26 @@ sub create_schema { ' ) or die Zonemaster::Backend::Error::Internal->new( reason => "PostgreSQL error, could not create 'users' table", data => $dbh->errstr() ); + #################################################################### + # SCHEMA VERSION + #################################################################### + $dbh->do( + "CREATE TABLE IF NOT EXISTS schema_version ( + id INTEGER PRIMARY KEY, + version INTEGER NOT NULL, + CHECK (id = 1), + CHECK (version >= 1) + )" + ) + or die Zonemaster::Backend::Error::Internal->new( + reason => "Failed to create 'schema_version' table", + data => $dbh->errstr() + ); + + if ( !defined $dbh->selectrow_array( "SELECT 1 FROM schema_version LIMIT 1" ) ) { + $dbh->do( "INSERT INTO schema_version (id, version) VALUES (1, ?)", {}, $Zonemaster::Backend::DB::REQUIRED_SCHEMA_VERSION ); + } + return; } @@ -203,6 +223,7 @@ sub drop_tables { $self->dbh->do( "SET client_min_messages = warning" ); try { + $self->dbh->do( "DROP TABLE IF EXISTS schema_version CASCADE" ); $self->dbh->do( "DROP TABLE IF EXISTS test_results CASCADE" ); $self->dbh->do( "DROP TABLE IF EXISTS result_entries CASCADE" ); $self->dbh->do( "DROP TABLE IF EXISTS log_level" ); @@ -304,9 +325,23 @@ sub get_relative_start_time { sub is_duplicate { my ( $self ) = @_; + my $state = $self->dbh->state; + + # for the list of codes see: + # https://www.postgresql.org/docs/current/errcodes-appendix.html + return defined $state + && $state eq '23505'; +} + +sub is_unknown_table { + my ( $self ) = @_; + + my $state = $self->dbh->state; + # for the list of codes see: # https://www.postgresql.org/docs/current/errcodes-appendix.html - return ( $self->dbh->state == 23505 ); + return defined $state + && $state eq '42P01'; } no Moose; diff --git a/lib/Zonemaster/Backend/DB/SQLite.pm b/lib/Zonemaster/Backend/DB/SQLite.pm index e04108f89..95cd7cac9 100644 --- a/lib/Zonemaster/Backend/DB/SQLite.pm +++ b/lib/Zonemaster/Backend/DB/SQLite.pm @@ -183,6 +183,26 @@ sub create_schema { ' ) or die Zonemaster::Backend::Error::Internal->new( reason => "SQLite error, could not create 'users' table", data => $dbh->errstr() ); + #################################################################### + # SCHEMA VERSION + #################################################################### + $dbh->do( + "CREATE TABLE IF NOT EXISTS schema_version ( + id INTEGER PRIMARY KEY, + version INTEGER NOT NULL, + CHECK (id = 1), + CHECK (version >= 1) + )" + ) + or die Zonemaster::Backend::Error::Internal->new( + reason => "Failed to create 'schema_version' table", + data => $dbh->errstr() + ); + + if ( !defined $dbh->selectrow_array( "SELECT 1 FROM schema_version LIMIT 1" ) ) { + $dbh->do( "INSERT INTO schema_version (id, version) VALUES (1, ?)", {}, $Zonemaster::Backend::DB::REQUIRED_SCHEMA_VERSION ); + } + return; } @@ -195,8 +215,9 @@ Drop all the tables if they exist. sub drop_tables { my ( $self ) = @_; - $self->dbh->do( "DROP TABLE IF EXISTS test_results" ); + $self->dbh->do( "DROP TABLE IF EXISTS schema_version" ); $self->dbh->do( "DROP TABLE IF EXISTS result_entries" ); + $self->dbh->do( "DROP TABLE IF EXISTS test_results" ); $self->dbh->do( "DROP TABLE IF EXISTS log_level" ); $self->dbh->do( "DROP TABLE IF EXISTS users" ); $self->dbh->do( "DROP TABLE IF EXISTS batch_jobs" ); @@ -295,6 +316,14 @@ sub is_duplicate { return ( $self->dbh->err == 2067 ); } +sub is_unknown_table { + my ( $self ) = @_; + + # for the list of codes see: https://sqlite.org/rescode.html + return ( $self->dbh->err == 1 ) + && $self->dbh->errstr =~ /^no such table: /; +} + no Moose; __PACKAGE__->meta()->make_immutable(); diff --git a/lib/Zonemaster/Backend/RPCAPI.pm b/lib/Zonemaster/Backend/RPCAPI.pm index d6a361fb3..00bb0cebc 100644 --- a/lib/Zonemaster/Backend/RPCAPI.pm +++ b/lib/Zonemaster/Backend/RPCAPI.pm @@ -52,6 +52,8 @@ sub new { croak 'Unrecognized arguments: ' . join( ', ', sort keys %args ); } + $db->assert_compatible_schema; + my $obj = { config => $config, db => $db, diff --git a/lib/Zonemaster/Backend/TestAgent.pm b/lib/Zonemaster/Backend/TestAgent.pm index 5e96be49b..b32580fc9 100644 --- a/lib/Zonemaster/Backend/TestAgent.pm +++ b/lib/Zonemaster/Backend/TestAgent.pm @@ -42,6 +42,7 @@ sub new { my $dbclass = Zonemaster::Backend::DB->get_db_class( $dbtype ); $self->{_db} = $dbclass->from_config( $config ); + $self->{_db}->assert_compatible_schema; $self->{_profiles} = Zonemaster::Backend::Config->load_profiles( # $config->PUBLIC_PROFILES, diff --git a/script/zonemaster_backend_rpcapi.psgi b/script/zonemaster_backend_rpcapi.psgi index 98ecac2b9..70217aad9 100644 --- a/script/zonemaster_backend_rpcapi.psgi +++ b/script/zonemaster_backend_rpcapi.psgi @@ -53,7 +53,10 @@ builder { my $app = shift; # Make sure we can connect to the database - $config->new_DB(); + my $dbh = $config->new_DB; + + # Make sure the database has the expected schema version + $dbh->assert_compatible_schema; return $app; }; diff --git a/share/patch/patch_db_schema_version_1.pl b/share/patch/patch_db_schema_version_1.pl new file mode 100644 index 000000000..f795dbdc3 --- /dev/null +++ b/share/patch/patch_db_schema_version_1.pl @@ -0,0 +1,35 @@ +use 5.14.2; +use strict; +use warnings; + +use Readonly; +use Zonemaster::Backend::Config; + +Readonly my $EXPECTED_SCHEMA_VERSION => 0; + +my $config = Zonemaster::Backend::Config->load_config(); +say "Configured database engine: ", $config->DB_engine; + +my $db = $config->new_DB(); +my $detected_version = $db->get_schema_version(); +say "Expected pre-migration schema version: ", $EXPECTED_SCHEMA_VERSION; +say "Detected database schema version: ", $detected_version; + +if ( $detected_version ne $EXPECTED_SCHEMA_VERSION ) { + say "Schema version requirement not met!"; + exit 2; +} + +say "Starting database migration"; + +$db->dbh->do( + "CREATE TABLE IF NOT EXISTS schema_version ( + id INTEGER PRIMARY KEY, + version INTEGER NOT NULL, + CHECK (id = 1), + CHECK (version >= 1) + )" +); +$db->dbh->do( "INSERT INTO schema_version (id, version) VALUES (1, 1)" ); + +say "Migration done"; diff --git a/t/TestUtil.pm b/t/TestUtil.pm index debff82f6..7366dd8aa 100644 --- a/t/TestUtil.pm +++ b/t/TestUtil.pm @@ -75,7 +75,8 @@ sub restore_datafile { die q{Stored data file missing} if not -r $datafile; Zonemaster::Engine->preload_cache( $datafile ); Zonemaster::Engine->profile->set( q{no_network}, 1 ); - } else { + } + else { diag "recording"; } } diff --git a/t/db_schema_version.t b/t/db_schema_version.t new file mode 100644 index 000000000..69a81455c --- /dev/null +++ b/t/db_schema_version.t @@ -0,0 +1,121 @@ +use strict; +use warnings; + +use Test::More; +use Test::Exception; +use Test::NoWarnings qw( had_no_warnings ); + +use File::ShareDir qw( dist_file ); +use File::Temp qw( tempdir ); + +my $t_path; + +BEGIN { + use File::Spec::Functions qw( rel2abs ); + use File::Basename qw( dirname ); + $t_path = dirname( rel2abs( $0 ) ); +} +use lib $t_path; + +use TestUtil; + +use Zonemaster::Backend::Config; +use Zonemaster::Backend::RPCAPI; +use Zonemaster::Backend::TestAgent; + +my $db_backend = TestUtil::db_backend(); + +my $tempdir = tempdir( CLEANUP => 1 ); +my $config = Zonemaster::Backend::Config->parse( <load_profiles( # + $config->PUBLIC_PROFILES, + $config->PRIVATE_PROFILES, +); + +my $dbclass = Zonemaster::Backend::DB->get_db_class( $db_backend ); +my $db = $dbclass->from_config( $config ); + +subtest 'newly created database' => sub { + $db->drop_tables(); + $db->create_schema(); + + my $schema_version = $db->get_schema_version; + like $schema_version, qr{^[1-9][0-9]*$}, 'should report schema version as an integer'; + + lives_ok { $db->assert_compatible_schema } 'should pass compatibility assertion'; + lives_ok { Zonemaster::Backend::TestAgent->new( { config => $config } ) } 'should be accepted by TestAgent constructor'; + lives_ok { Zonemaster::Backend::RPCAPI->new( config => $config, db => $config->new_DB, profiles => $profiles ) } 'should be accepted by RPCAPI constructor'; +}; + +subtest 'database with future schema version' => sub { + $db->drop_tables(); + $db->create_schema(); + $db->dbh->do( 'UPDATE schema_version SET version = ?', {}, $Zonemaster::Backend::DB::REQUIRED_SCHEMA_VERSION + 1 ); + + my $schema_version = $db->get_schema_version; + like $schema_version, qr{^[1-9][0-9]*$}, 'should report schema version as an integer'; + + dies_ok { $db->assert_compatible_schema } 'should fail compatibility assertion'; + dies_ok { Zonemaster::Backend::RPCAPI->new( config => $config, db => $config->new_DB, profiles => $profiles ) } 'should be rejected by RPCAPI constructor'; + dies_ok { Zonemaster::Backend::TestAgent->new( { config => $config } ) } 'should be rejected by TestAgent constructor'; +}; + +subtest 'database per Backend 11.2.0' => sub { + $db->drop_tables(); + $db->create_schema(); + $db->dbh->do( 'DROP TABLE schema_version' ); + + my $schema_version = $db->get_schema_version; + is $schema_version, 0, 'should be inferred as schema version 0'; + + dies_ok { $db->assert_compatible_schema } 'should fail compatibility assertion'; + dies_ok { Zonemaster::Backend::RPCAPI->new( config => $config, db => $config->new_DB, profiles => $profiles ) } 'should be rejected by RPCAPI constructor'; + dies_ok { Zonemaster::Backend::TestAgent->new( { config => $config } ) } 'should be rejected by TestAgent constructor'; +}; + +subtest 'database with unrecognized schema version table structure' => sub { + $db->drop_tables(); + $db->create_schema(); + $db->dbh->do( "DROP TABLE schema_version" ); + $db->dbh->do( "CREATE TABLE schema_version (foobar INTEGER)" ); + + dies_ok { $db->get_schema_version; } 'should die instead of reporting a schema version'; + dies_ok { $db->assert_compatible_schema } 'should fail compatibility assertion'; + dies_ok { Zonemaster::Backend::RPCAPI->new( config => $config, db => $config->new_DB, profiles => $profiles ) } 'should be rejected by RPCAPI constructor'; + dies_ok { Zonemaster::Backend::TestAgent->new( { config => $config } ) } 'should be rejected by TestAgent constructor'; +}; + +subtest 'database with empty schema version table' => sub { + $db->drop_tables(); + $db->create_schema(); + $db->dbh->do( "DELETE FROM schema_version" ); + + dies_ok { $db->get_schema_version; } 'should die instead of reporting a schema version'; + dies_ok { $db->assert_compatible_schema } 'should fail compatibility assertion'; + dies_ok { Zonemaster::Backend::RPCAPI->new( config => $config, db => $config->new_DB, profiles => $profiles ) } 'should be rejected by RPCAPI constructor'; + dies_ok { Zonemaster::Backend::TestAgent->new( { config => $config } ) } 'should be rejected by TestAgent constructor'; +}; + +had_no_warnings; +done_testing; From f4df2a4c9638a676ea21add047af6da615c651b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20P=C3=A4iv=C3=A4rinta?= Date: Tue, 9 Jun 2026 12:56:19 +0200 Subject: [PATCH 27/34] Remediate some techical debt --- MANIFEST | 1 + Makefile.PL | 29 ++++++------ t/log.t | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 t/log.t diff --git a/MANIFEST b/MANIFEST index 250d6dbb9..32ae027ad 100644 --- a/MANIFEST +++ b/MANIFEST @@ -82,6 +82,7 @@ t/db_schema_version.t t/idn.data t/idn.t t/lifecycle.t +t/log.t t/parameters_validation.t t/queue.t t/rpc_validation.t diff --git a/Makefile.PL b/Makefile.PL index 35f66d2c3..d50371ca6 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -35,14 +35,16 @@ requires 'Router::Simple::Declare' => 0, 'Starman' => 0, 'Try::Tiny' => 0.12, - 'Zonemaster::LDNS' => 5.000001, # v5.0.1 - 'Zonemaster::Engine' => 8.001000, # v8.1.0 + 'Zonemaster::LDNS' => 5.000001, # v5.0.1 + 'Zonemaster::Engine' => 8.001000, # v8.1.0 ; +test_requires 'Capture::Tiny'; test_requires 'DBD::SQLite' => 1.66; test_requires 'Test::Differences'; +test_requires 'Test::Fatal'; test_requires 'Test::Exception'; -test_requires 'Time::Local' => 1.26; +test_requires 'Time::Local' => 1.26; test_requires 'Test::NoWarnings' => 0; recommends 'DBD::mysql'; @@ -61,21 +63,20 @@ no_index directory => 'Doc'; # Make all platforms include inc/Module/Install/External.pm requires_external_bin 'find'; -if ($^O eq "freebsd") { +if ( $^O eq "freebsd" ) { requires_external_bin 'gmake'; -}; +} sub MY::postamble { my $text; - if ($^O eq "freebsd") { + if ( $^O eq "freebsd" ) { + # Make FreeBSD use gmake for share/Makefile - $text = 'GMAKE ?= "gmake"' . "\n" - . 'pure_all :: share/Makefile' . "\n" - . "\t" . 'cd share && $(GMAKE) all' . "\n"; - } else { - $text = 'pure_all :: share/Makefile' . "\n" - . "\t" . 'cd share && $(MAKE) all' . "\n"; - }; + $text = 'GMAKE ?= "gmake"' . "\n" . 'pure_all :: share/Makefile' . "\n" . "\t" . 'cd share && $(GMAKE) all' . "\n"; + } + else { + $text = 'pure_all :: share/Makefile' . "\n" . "\t" . 'cd share && $(MAKE) all' . "\n"; + } my $docker = <<'END_DOCKER'; docker-build: @@ -90,7 +91,7 @@ docker-tag-latest: END_DOCKER return $text . $docker; -}; +} install_share; diff --git a/t/log.t b/t/log.t new file mode 100644 index 000000000..8231fd97f --- /dev/null +++ b/t/log.t @@ -0,0 +1,134 @@ +#!perl +use strict; +use warnings FATAL => 'all'; +use Test::More; + +use Capture::Tiny qw( capture ); +use File::Slurp qw( slurp ); +use File::Temp qw( tempdir ); +use JSON::PP qw( decode_json ); +use Log::Any::Adapter; +use Test::Fatal qw( exception ); +use Zonemaster::Backend::Log; + +sub tmp_log_file { + my $dir = tempdir( CLEANUP => 1 ); + return "$dir/backend.log"; +} + +subtest 'render entry in text format' => sub { + my $stdout = capture { + my $logger = Zonemaster::Backend::Log->new; + $logger->structured( 'error', 'unit.test', 'text message', { request_id => 'abc123' }, ); + }; + + like $stdout, qr/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z \[\d+\] \[ERROR\] \[unit\.test\] text message/, 'text log entry contains timestamp, pid, level, category and message'; + + like $stdout, qr/Extra parameters:/, 'extra parameters are appended'; + like $stdout, qr/request_id/, 'extra parameter key is present'; + like $stdout, qr/abc123/, 'extra parameter value is present'; +}; + +subtest 'render entry in JSON format' => sub { + my $stdout = capture { + my $logger = Zonemaster::Backend::Log->new( json => 1 ); + $logger->structured( 'error', 'unit.test', 'json message', { request_id => 'def456' }, ); + }; + + my $entry = decode_json( $stdout ); + + is $entry->{level}, 'error', 'level is serialized'; + is $entry->{category}, 'unit.test', 'category is serialized'; + is $entry->{message}, 'json message', 'message is serialized'; + is $entry->{request_id}, 'def456', 'structured data is serialized'; + + like $entry->{timestamp}, qr/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/, 'timestamp is serialized as UTC ISO-like string'; + + is( $entry->{pid}, $$, 'pid is serialized' ); +}; + +subtest 'rejects unknown textual log level' => sub { + my $error = exception { + Zonemaster::Backend::Log->new( log_level => 'not-a-level' ); + }; + + like $error, qr/Unrecognized log level not-a-level/, 'unknown textual log level dies'; +}; + +subtest 'redirect output to stderr' => sub { + my ( $stdout, $stderr ) = capture { + my $logger = Zonemaster::Backend::Log->new( stderr => 1 ); + $logger->structured( 'error', 'unit.test', 'message' ); + }; + + is $stdout, '', 'nothing was written to stdout'; + like $stderr, qr/\[ERROR\]/, 'entry was written to stderr'; +}; + +subtest 'redirect output to file' => sub { + my $file = tmp_log_file(); + my ( $stdout, $stderr ) = capture { + my $logger = Zonemaster::Backend::Log->new( file => $file ); + $logger->structured( 'error', 'unit.test', 'message' ); + }; + my $content = slurp( $file ); + + is $stdout, '', 'nothing was written to stdout'; + is $stderr, '', 'nothing was written to stderr'; + like $content, qr/\[ERROR\]/, 'entry was written to file'; +}; + +subtest 'works as a Log::Any adapter' => sub { + my $file = tmp_log_file(); + + Log::Any::Adapter->set( + { lexically => \my $adapter_scope }, + '+Zonemaster::Backend::Log', + log_level => 'debug', + json => 1, + ); + + my $log = Log::Any->get_logger( category => 'zonemaster.backend.log.test' ); + + ok !$log->is_trace, 'trace is disabled through Log::Any'; + ok $log->is_debug, 'debug is enabled through Log::Any'; + ok $log->is_info, 'info is enabled through Log::Any'; + + my $stdout = capture { + $log->info( 'message through Log::Any', { request_id => 'abc123' }, ); + }; + + my $entry; + is + exception { $entry = decode_json( $stdout ) }, + undef, + 'a single valid JSON value was written'; + + is $entry->{level}, 'info', 'Log::Any level reached backend logger'; + is $entry->{category}, 'zonemaster.backend.log.test', 'Log::Any category reached backend logger'; + is $entry->{message}, 'message through Log::Any', 'message reached backend logger'; + is $entry->{request_id}, 'abc123', 'structured data reached backend logger'; + is $entry->{pid}, $$, 'pid was added by backend logger'; + ok $entry->{timestamp}, 'timestamp was added by backend logger'; +}; + +subtest 'Log::Any level detection and filtering use backend log_level' => sub { + Log::Any::Adapter->set( { lexically => \my $adapter_scope }, '+Zonemaster::Backend::Log', log_level => 'warning', ); + + my $log = Log::Any->get_logger( category => 'zonemaster.backend.log.test' ); + + ok !$log->is_info, 'info is disabled through Log::Any'; + ok $log->is_error, 'error is enabled through Log::Any'; + + my $stdout = capture { + $log->info( 'filtered message' ); + $log->warn( 'visible message 1' ); + $log->error( 'visible message 2' ); + }; + + like $stdout, qr/\[ERROR\]/, 'error entry was written'; + like $stdout, qr/\[WARNING\]/, 'warn entry was written'; + unlike $stdout, qr/\[INFO\]/, 'info entry was skipped'; +}; + +done_testing; From 833981e150efa0cf87ec5da4e372379a3b17ec23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20P=C3=A4iv=C3=A4rinta?= Date: Tue, 9 Jun 2026 15:41:28 +0200 Subject: [PATCH 28/34] Make migration script idempotent --- share/patch/patch_db_schema_version_1.pl | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/share/patch/patch_db_schema_version_1.pl b/share/patch/patch_db_schema_version_1.pl index f795dbdc3..37eb718bc 100644 --- a/share/patch/patch_db_schema_version_1.pl +++ b/share/patch/patch_db_schema_version_1.pl @@ -5,17 +5,23 @@ use Readonly; use Zonemaster::Backend::Config; -Readonly my $EXPECTED_SCHEMA_VERSION => 0; +Readonly my $TARGET_SCHEMA_VERSION => 1; +Readonly my $EXPECTED_SCHEMA_VERSION => $TARGET_SCHEMA_VERSION - 1; my $config = Zonemaster::Backend::Config->load_config(); say "Configured database engine: ", $config->DB_engine; my $db = $config->new_DB(); my $detected_version = $db->get_schema_version(); +say "Target database schema version: ", $TARGET_SCHEMA_VERSION; say "Expected pre-migration schema version: ", $EXPECTED_SCHEMA_VERSION; say "Detected database schema version: ", $detected_version; -if ( $detected_version ne $EXPECTED_SCHEMA_VERSION ) { +if ( $detected_version eq $TARGET_SCHEMA_VERSION ) { + say "Schema already at target version."; + exit 0; +} +elsif ( $detected_version ne $EXPECTED_SCHEMA_VERSION ) { say "Schema version requirement not met!"; exit 2; } @@ -30,6 +36,6 @@ CHECK (version >= 1) )" ); -$db->dbh->do( "INSERT INTO schema_version (id, version) VALUES (1, 1)" ); +$db->dbh->do( "INSERT INTO schema_version (id, version) VALUES (1, ?)", {}, $TARGET_SCHEMA_VERSION ); say "Migration done"; From 8d0a9b00b86402b801fa47f9c48768bdb68ff04e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20P=C3=A4iv=C3=A4rinta?= Date: Thu, 4 Jun 2026 11:10:20 +0200 Subject: [PATCH 29/34] Better systemd integration for zm-testagent * Let systemd take care of daemonization. Invoke zonemaster_backend_testagent with `foreground` instead of `start`/`stop`, --pidfile and --outfile. This allows journald to capture the output from STDOUT/STDERR. * Let journald collect logs. Setting --logfile=- causes logs to be written to STDOUT, which is captured by journald. * Let systemd take care of privilege dropping. By specifying User=zonemaster and Group=zonemaster, zonemaster_backend_testagent can execute entirely without root privileges. --- share/zm-testagent.service | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/share/zm-testagent.service b/share/zm-testagent.service index 6117875fe..5c00ecb74 100644 --- a/share/zm-testagent.service +++ b/share/zm-testagent.service @@ -4,10 +4,12 @@ After=network.target mariadb.service postgresql.service Wants=mariadb.service postgresql.service [Service] -Type=simple -ExecStart=/usr/local/bin/zonemaster_backend_testagent --logfile=/var/log/zonemaster/zm-testagent.log --outfile=/var/log/zonemaster/zm-testagent.out --pidfile=/run/zonemaster/zm-testagent.pid --user=zonemaster --group=zonemaster start -ExecStop=/usr/local/bin/zonemaster_backend_testagent --logfile=/var/log/zonemaster/zm-testagent.log --outfile=/var/log/zonemaster/zm-testagent.out --pidfile=/run/zonemaster/zm-testagent.pid --user=zonemaster --group=zonemaster stop -PIDFile=/run/zonemaster/zm-testagent.pid +Type=exec +User=zonemaster +Group=zonemaster +ExecStart=/usr/local/bin/zonemaster_backend_testagent \ + --logfile=- \ + foreground [Install] WantedBy=multi-user.target From cfcd52a3a2331a86600744049bfe586a6a31e779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20P=C3=A4iv=C3=A4rinta?= Date: Thu, 4 Jun 2026 11:30:13 +0200 Subject: [PATCH 30/34] Better systemd integration for zm-rpcapi * Let systemd take care of daemonization. Invoke zonemaster_backend_rpcapi.psgi without --daemonize and --pid. This allows journald to capture STDOUT/STDERR. * Let journald collect logs. Since journald already captures STDOUT/STDERR we can just write our logs there. * Let systemd take care of privilege dropping. By setting User= and Group= in the unit file, instead invoking zonemaster_backend_rpcapi.psgi with --user and --group, the process can be started without elevated privileges. --- share/zm-rpcapi.service | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/share/zm-rpcapi.service b/share/zm-rpcapi.service index 8765ddebe..8c5876c2d 100644 --- a/share/zm-rpcapi.service +++ b/share/zm-rpcapi.service @@ -4,10 +4,14 @@ After=network.target mariadb.service postgresql.service Wants=mariadb.service postgresql.service [Service] -Type=simple -ExecStart=/usr/local/bin/starman --listen=127.0.0.1:5000 --preload-app --user=zonemaster --group=zonemaster --pid=/run/zonemaster/zm-rpcapi.pid --error-log=/var/log/zonemaster/zm-rpcapi.log --daemonize /usr/local/bin/zonemaster_backend_rpcapi.psgi +Type=exec +User=zonemaster +Group=zonemaster KillSignal=SIGQUIT -PIDFile=/run/zonemaster/zm-rpcapi.pid +ExecStart=/usr/local/bin/starman \ + --listen=127.0.0.1:5000 \ + --preload-app \ + /usr/local/bin/zonemaster_backend_rpcapi.psgi [Install] WantedBy=multi-user.target From c5d3c18c77b56d1bb3c96e1fed5b3f36e9771a85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20P=C3=A4iv=C3=A4rinta?= Date: Mon, 8 Jun 2026 16:50:55 +0200 Subject: [PATCH 31/34] Tidy --- lib/Zonemaster/Backend/Log.pm | 67 +++++----- script/zonemaster_backend_rpcapi.psgi | 168 ++++++++++++++------------ script/zonemaster_backend_testagent | 58 ++++----- 3 files changed, 150 insertions(+), 143 deletions(-) diff --git a/lib/Zonemaster/Backend/Log.pm b/lib/Zonemaster/Backend/Log.pm index 52e30f339..d0716fd19 100644 --- a/lib/Zonemaster/Backend/Log.pm +++ b/lib/Zonemaster/Backend/Log.pm @@ -13,16 +13,15 @@ use Data::Dumper; use base qw(Log::Any::Adapter::Base); - -my $default_level = Log::Any::Adapter::Util::numeric_level('info'); +my $default_level = Log::Any::Adapter::Util::numeric_level( 'info' ); sub init { - my ($self) = @_; + my ( $self ) = @_; if ( defined $self->{log_level} && $self->{log_level} =~ /\D/ ) { $self->{log_level} = lc $self->{log_level}; my $numeric_level = Log::Any::Adapter::Util::numeric_level( $self->{log_level} ); - if ( !defined($numeric_level) ) { + if ( !defined( $numeric_level ) ) { croak "Error: Unrecognized log level " . $self->{log_level} . "\n"; } $self->{log_level} = $numeric_level; @@ -31,54 +30,51 @@ sub init { $self->{log_level} //= $default_level; my $fd; - if ( !exists $self->{file} || $self->{file} eq '-') { + if ( !exists $self->{file} || $self->{file} eq '-' ) { if ( $self->{stderr} ) { - $fd = fileno(STDERR); - } else { - $fd = fileno(STDOUT); + $fd = fileno( STDERR ); + } + else { + $fd = fileno( STDOUT ); } - } else { + } + else { open( $fd, '>>', $self->{file} ) or croak "Can't open log file: $!"; } $self->{handle} = IO::Handle->new_from_fd( $fd, "w" ) or croak "Can't fdopen file: $!"; - $self->{handle}->autoflush(1); + $self->{handle}->autoflush( 1 ); if ( !exists $self->{formatter} ) { if ( $self->{json} ) { $self->{formatter} = \&format_json; - } else { + } + else { $self->{formatter} = \&format_text; } } } sub format_text { - my ($self, $log_params) = @_; + my ( $self, $log_params ) = @_; my $msg; $msg .= sprintf "%s ", $log_params->{timestamp}; delete $log_params->{timestamp}; - $msg .= sprintf( - "[%d] [%s] [%s] %s", - delete $log_params->{pid}, - uc delete $log_params->{level}, - delete $log_params->{category}, - delete $log_params->{message} - ); + $msg .= sprintf( "[%d] [%s] [%s] %s", delete $log_params->{pid}, uc delete $log_params->{level}, delete $log_params->{category}, delete $log_params->{message} ); if ( %$log_params ) { local $Data::Dumper::Indent = 0; - local $Data::Dumper::Terse = 1; - my $data = Dumper($log_params); + local $Data::Dumper::Terse = 1; + my $data = Dumper( $log_params ); $msg .= " Extra parameters: $data"; } - return $msg + return $msg; } sub format_json { - my ($self, $log_params) = @_; + my ( $self, $log_params ) = @_; my $js = JSON::PP->new; $js->canonical( 1 ); @@ -86,41 +82,40 @@ sub format_json { return $js->encode( $log_params ); } - sub structured { - my ($self, $level, $category, $string, @items) = @_; + my ( $self, $level, $category, $string, @items ) = @_; - my $log_level = Log::Any::Adapter::Util::numeric_level($level); + my $log_level = Log::Any::Adapter::Util::numeric_level( $level ); return if $log_level > $self->{log_level}; my %log_params = ( timestamp => strftime( "%FT%TZ", gmtime ), - level => $level, - category => $category, - message => $string, - pid => $PID, + level => $level, + category => $category, + message => $string, + pid => $PID, ); for my $item ( @items ) { - if (ref($item) eq 'HASH') { - for my $key (keys %$item) { + if ( ref( $item ) eq 'HASH' ) { + for my $key ( keys %$item ) { $log_params{$key} = $item->{$key}; } } } - my $msg = $self->{formatter}->($self, \%log_params); - $self->{handle}->print($msg . "\n"); + my $msg = $self->{formatter}->( $self, \%log_params ); + $self->{handle}->print( $msg . "\n" ); } # From Log::Any::Adapter::File foreach my $method ( Log::Any::Adapter::Util::detection_methods() ) { no strict 'refs'; - my $base = substr($method,3); + my $base = substr( $method, 3 ); my $method_level = Log::Any::Adapter::Util::numeric_level( $base ); *{$method} = sub { - return !!( $method_level <= $_[0]->{log_level} ); + return !!( $method_level <= $_[0]->{log_level} ); }; } diff --git a/script/zonemaster_backend_rpcapi.psgi b/script/zonemaster_backend_rpcapi.psgi index 70217aad9..7a2c71dd4 100644 --- a/script/zonemaster_backend_rpcapi.psgi +++ b/script/zonemaster_backend_rpcapi.psgi @@ -20,7 +20,7 @@ use Try::Tiny; BEGIN { $ENV{PERL_JSON_BACKEND} = 'JSON::PP'; undef $ENV{LANGUAGE}; -}; +} use Zonemaster::Backend::RPCAPI; use Zonemaster::Backend::Config; @@ -31,12 +31,12 @@ local $| = 1; Log::Any::Adapter->set( '+Zonemaster::Backend::Log', log_level => $ENV{ZM_BACKEND_RPCAPI_LOGLEVEL}, - json => $ENV{ZM_BACKEND_RPCAPI_LOGJSON}, - stderr => 1 + json => $ENV{ZM_BACKEND_RPCAPI_LOGJSON}, + stderr => 1, ); $SIG{__WARN__} = sub { - $log->warning(map s/^\s+|\s+$//gr, map s/\n/ /gr, @_); + $log->warning( map s/^\s+|\s+$//gr, map s/\n/ /gr, @_ ); }; my $config = Zonemaster::Backend::Config->load_config(); @@ -45,7 +45,7 @@ my $profiles = Zonemaster::Backend::Config->load_profiles( # $config->PRIVATE_PROFILES, ); -Zonemaster::Backend::Metrics->setup($config->METRICS_statsd_host, $config->METRICS_statsd_port); +Zonemaster::Backend::Metrics->setup( $config->METRICS_statsd_host, $config->METRICS_statsd_port ); Zonemaster::Engine::init_engine(); builder { @@ -72,191 +72,209 @@ my $router = router { ############## FRONTEND #################### connect "version_info" => { handler => $handler, - action => "version_info" + action => "version_info" }; # Experimental connect "system_versions" => { handler => $handler, - action => "system_versions" + action => "system_versions" }; connect "profile_names" => { handler => $handler, - action => "profile_names" + action => "profile_names" }; # Experimental connect "conf_profiles" => { handler => $handler, - action => "conf_profiles" + action => "conf_profiles" }; connect "get_language_tags" => { handler => $handler, - action => "get_language_tags" + action => "get_language_tags" }; # Experimental connect "conf_languages" => { handler => $handler, - action => "conf_languages" + action => "conf_languages" }; connect "get_host_by_name" => { handler => $handler, - action => "get_host_by_name" + action => "get_host_by_name" }; # Experimental connect "lookup_address_records" => { handler => $handler, - action => "lookup_address_records" + action => "lookup_address_records" }; connect "get_data_from_parent_zone" => { handler => $handler, - action => "get_data_from_parent_zone" + action => "get_data_from_parent_zone" }; # Experimental connect "lookup_delegation_data" => { handler => $handler, - action => "lookup_delegation_data" + action => "lookup_delegation_data" }; connect "start_domain_test" => { handler => $handler, - action => "start_domain_test" + action => "start_domain_test" }; # Experimental connect "job_create" => { handler => $handler, - action => "job_create" + action => "job_create" }; connect "test_progress" => { handler => $handler, - action => "test_progress" + action => "test_progress" }; # Experimental connect "job_status" => { handler => $handler, - action => "job_status" + action => "job_status" }; connect "get_test_params" => { handler => $handler, - action => "get_test_params" + action => "get_test_params" }; # Experimental connect "job_params" => { handler => $handler, - action => "job_params" + action => "job_params" }; connect "get_test_results" => { handler => $handler, - action => "get_test_results" + action => "get_test_results" }; # Experimental connect "job_results" => { handler => $handler, - action => "job_results" + action => "job_results" }; connect "get_test_history" => { handler => $handler, - action => "get_test_history" + action => "get_test_history" }; # Experimental connect "domain_history" => { handler => $handler, - action => "domain_history" + action => "domain_history" }; connect "batch_status" => { handler => $handler, - action => "batch_status" + action => "batch_status" }; connect "get_tld_url" => { handler => $handler, - action => "get_tld_url" + action => "get_tld_url" }; }; if ( $config->RPCAPI_enable_user_create or $config->RPCAPI_enable_add_api_user ) { - $log->info('Enabling add_api_user method'); - $router->connect("add_api_user", { - handler => $handler, - action => "add_api_user" - }); - $router->connect("user_create", { - handler => $handler, - action => "user_create" - }); + $log->info( 'Enabling add_api_user method' ); + $router->connect( + "add_api_user", + { + handler => $handler, + action => "add_api_user" + } + ); + $router->connect( + "user_create", + { + handler => $handler, + action => "user_create" + } + ); } if ( $config->RPCAPI_enable_batch_create or $config->RPCAPI_enable_add_batch_job ) { - $log->info('Enabling add_batch_job method'); - $router->connect("add_batch_job", { - handler => $handler, - action => "add_batch_job" - }); - $router->connect("batch_create", { - handler => $handler, - action => "batch_create" - }); + $log->info( 'Enabling add_batch_job method' ); + $router->connect( + "add_batch_job", + { + handler => $handler, + action => "add_batch_job" + } + ); + $router->connect( + "batch_create", + { + handler => $handler, + action => "batch_create" + } + ); } -my $dispatch = JSON::RPC::Dispatch->new( - router => $router, -); +my $dispatch = JSON::RPC::Dispatch->new( router => $router, ); my $rpcapi_app = sub { - my $env = shift; - my $req = Plack::Request->new($env); - my $res = {}; - my $content = {}; + my $env = shift; + my $req = Plack::Request->new( $env ); + my $res = {}; + my $content = {}; my $json_error = ''; try { my $json = $req->content; - $content = decode_json($json); - } catch { - $json_error = (split /at \//, $_)[0]; + $content = decode_json( $json ); + } + catch { + $json_error = ( split /at \//, $_ )[0]; }; - if ($json_error eq '') { - my $errors = $handler->jsonrpc_validate($content); - if ($errors ne '') { - $res = Plack::Response->new(200); - $res->content_type('application/json'); - $res->body( encode_json($errors) ); - $res->finalize; - } else { + if ( $json_error eq '' ) { + my $errors = $handler->jsonrpc_validate( $content ); + if ( $errors ne '' ) { + $res = Plack::Response->new( 200 ); + $res->content_type( 'application/json' ); + $res->body( encode_json( $errors ) ); + $res->finalize; + } + else { local $log->context->{rpc_method} = $content->{method}; - $res = $dispatch->handle_psgi($env, $env->{REMOTE_ADDR}); - my $status = Zonemaster::Backend::Metrics->code_to_status(decode_json(@{@$res[2]}[0])->{error}->{code}); - Zonemaster::Backend::Metrics::increment("zonemaster.rpcapi.requests.$content->{method}.$status"); + $res = $dispatch->handle_psgi( $env, $env->{REMOTE_ADDR} ); + my $status = Zonemaster::Backend::Metrics->code_to_status( decode_json( @{ @$res[2] }[0] )->{error}->{code} ); + Zonemaster::Backend::Metrics::increment( "zonemaster.rpcapi.requests.$content->{method}.$status" ); $res; } - } else { - $res = Plack::Response->new(200); - $res->content_type('application/json'); - $res->body( encode_json({ + } + else { + $res = Plack::Response->new( 200 ); + $res->content_type( 'application/json' ); + $res->body( + encode_json( + { jsonrpc => '2.0', - id => undef, - error => { - code => '-32700', + id => undef, + error => { + code => '-32700', message => 'Invalid JSON was received by the server.', - data => "$json_error" - }}) ); + data => "$json_error" + } + } + ) + ); $res->finalize; } diff --git a/script/zonemaster_backend_testagent b/script/zonemaster_backend_testagent index bff1fd63b..bee25f5fa 100755 --- a/script/zonemaster_backend_testagent +++ b/script/zonemaster_backend_testagent @@ -17,7 +17,7 @@ use Pod::Usage; use Getopt::Long; use POSIX; use Time::HiRes qw[time sleep gettimeofday tv_interval]; -use sigtrap qw(die normal-signals); +use sigtrap qw(die normal-signals); ### ### Compile-time stuff. @@ -50,7 +50,7 @@ GetOptions( 'group=s' => \$group, 'logfile=s' => \$logfile, 'loglevel=s' => \$loglevel, - 'logjson!' => \$logjson, + 'logjson!' => \$logjson, 'outfile=s' => \$opt_outfile, ) or pod2usage( "Try '$0 --help' for more information." ); @@ -65,12 +65,12 @@ $loglevel = lc $loglevel; Log::Any::Adapter->set( '+Zonemaster::Backend::Log', log_level => $loglevel, - json => $logjson, - file => $logfile, + json => $logjson, + file => $logfile, ); $SIG{__WARN__} = sub { - $log->warning(map s/^\s+|\s+$//gr, map s/\n/ /gr, @_); + $log->warning( map s/^\s+|\s+$//gr, map s/\n/ /gr, @_ ); }; ### @@ -93,28 +93,25 @@ sub main { my $agent = Zonemaster::Backend::TestAgent->new( { config => $self->config } ); while ( !$caught_sigterm ) { - my $cleanup_timer = [ gettimeofday ]; + my $cleanup_timer = [gettimeofday]; $self->pm->reap_finished_children(); # Reaps terminated child processes $self->pm->on_wait(); # Sends SIGKILL to overdue child processes - Zonemaster::Backend::Metrics::gauge("zonemaster.testagent.maximum_processes", $self->pm->max_procs); - Zonemaster::Backend::Metrics::gauge("zonemaster.testagent.running_processes", scalar($self->pm->running_procs)); + Zonemaster::Backend::Metrics::gauge( "zonemaster.testagent.maximum_processes", $self->pm->max_procs ); + Zonemaster::Backend::Metrics::gauge( "zonemaster.testagent.running_processes", scalar( $self->pm->running_procs ) ); - Zonemaster::Backend::Metrics::timing("zonemaster.testagent.cleanup_duration_seconds", tv_interval($cleanup_timer) * 1000); + Zonemaster::Backend::Metrics::timing( "zonemaster.testagent.cleanup_duration_seconds", tv_interval( $cleanup_timer ) * 1000 ); - my $fetch_test_timer = [ gettimeofday ]; + my $fetch_test_timer = [gettimeofday]; my ( $test_id, $batch_id ); eval { - $self->db->process_unfinished_tests( - $self->config->ZONEMASTER_lock_on_queue, - $self->config->ZONEMASTER_max_zonemaster_execution_time, - ); + $self->db->process_unfinished_tests( $self->config->ZONEMASTER_lock_on_queue, $self->config->ZONEMASTER_max_zonemaster_execution_time, ); ( $test_id, $batch_id ) = $self->db->get_test_request( $self->config->ZONEMASTER_lock_on_queue ); - Zonemaster::Backend::Metrics::timing("zonemaster.testagent.fetchtests_duration_seconds", tv_interval($fetch_test_timer) * 1000); + Zonemaster::Backend::Metrics::timing( "zonemaster.testagent.fetchtests_duration_seconds", tv_interval( $fetch_test_timer ) * 1000 ); }; if ( $@ ) { $log->error( $@ ); @@ -126,22 +123,22 @@ sub main { $log->infof( "Test found: %s", $test_id ); if ( $self->pm->start( $test_id ) == 0 ) { # Forks off child process $log->infof( "Test starting: %s", $test_id ); - Zonemaster::Backend::Metrics::increment("zonemaster.testagent.tests_started"); - my $start_time = [ gettimeofday ]; + Zonemaster::Backend::Metrics::increment( "zonemaster.testagent.tests_started" ); + my $start_time = [gettimeofday]; eval { $agent->run( $test_id, $show_progress ) }; if ( $@ ) { chomp $@; - Zonemaster::Backend::Metrics::increment("zonemaster.testagent.tests_died"); + Zonemaster::Backend::Metrics::increment( "zonemaster.testagent.tests_died" ); $log->errorf( "Test died: %s: %s", $test_id, $@ ); - $self->db->process_dead_test( $test_id ) + $self->db->process_dead_test( $test_id ); } else { - Zonemaster::Backend::Metrics::increment("zonemaster.testagent.tests_completed"); + Zonemaster::Backend::Metrics::increment( "zonemaster.testagent.tests_completed" ); $log->infof( "Test completed: %s", $test_id ); } - Zonemaster::Backend::Metrics::timing("zonemaster.testagent.tests_duration_seconds", tv_interval($start_time) * 1000); + Zonemaster::Backend::Metrics::timing( "zonemaster.testagent.tests_duration_seconds", tv_interval( $start_time ) * 1000 ); $agent->reset(); - $self->pm->finish; # Terminates child process + $self->pm->finish; # Terminates child process } } else { @@ -157,11 +154,12 @@ sub main { } sub preflight_checks { + # Make sure we can load the configuration file - $log->debug("Starting pre-flight check"); + $log->debug( "Starting pre-flight check" ); my $initial_config = Zonemaster::Backend::Config->load_config(); - Zonemaster::Backend::Metrics->setup($initial_config->METRICS_statsd_host, $initial_config->METRICS_statsd_port); + Zonemaster::Backend::Metrics->setup( $initial_config->METRICS_statsd_host, $initial_config->METRICS_statsd_port ); # Validate the Zonemaster-Engine profile Zonemaster::Backend::Config->load_profiles( # @@ -171,20 +169,16 @@ sub preflight_checks { # Connect to the database $initial_config->new_DB(); - $log->debug("Completed pre-flight check"); + $log->debug( "Completed pre-flight check" ); return $initial_config; } - - my $initial_config; # Make sure the environment is alright before forking (only on startup) if ( grep /^foreground$|^restart$|^start$/, @ARGV ) { - eval { - $initial_config = preflight_checks(); - }; + eval { $initial_config = preflight_checks(); }; if ( $@ ) { $log->critical( "Aborting startup: $@" ); print STDERR "Aborting startup: $@"; @@ -220,8 +214,8 @@ my $daemon = Daemon::Control->with_plugins( qw( +Zonemaster::Backend::Config::DC ); $daemon->init_config( $ENV{PERLBREW_ROOT} . '/etc/bashrc' ) if ( $ENV{PERLBREW_ROOT} ); -$daemon->user($user) if $user; -$daemon->group($group) if $group; +$daemon->user( $user ) if $user; +$daemon->group( $group ) if $group; exit $daemon->run; From 2f2fd6710ab16df02e5b5db69fb51ab16a4882db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20P=C3=A4iv=C3=A4rinta?= Date: Tue, 9 Jun 2026 10:17:11 +0200 Subject: [PATCH 32/34] Remediate some more techical debt * Fix help text * Add forgotten dependency * Add documentation and unit test for Zonemaster::Backend::Log * Clean up unit test --- docker/Dockerfile | 1 + lib/Zonemaster/Backend/Log.pm | 51 +++++++++++++++++++++++++++++ script/zonemaster_backend_testagent | 2 +- t/log.t | 18 +++++----- 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 056bc58af..2c0e8b257 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -14,6 +14,7 @@ RUN apk add --no-cache \ RUN apk add --no-cache \ jq \ + perl-capture-tiny \ perl-class-method-modifiers \ perl-config-inifiles \ perl-dbd-sqlite \ diff --git a/lib/Zonemaster/Backend/Log.pm b/lib/Zonemaster/Backend/Log.pm index d0716fd19..e0e8bceaa 100644 --- a/lib/Zonemaster/Backend/Log.pm +++ b/lib/Zonemaster/Backend/Log.pm @@ -120,3 +120,54 @@ foreach my $method ( Log::Any::Adapter::Util::detection_methods() ) { } 1; + +=head1 NAME + +Zonemaster::Backend::Log + +=head1 SYNOPSIS + + Log::Any::Adapter->set( + '+Zonemaster::Backend::Log', + log_level => 'info', + json => 0, + file => '/path/to/logfile.log', + with_pid => 1, + with_timestamp => 1, + ); + +=head1 DESCRIPTION + +This is an adapter for Log::Any, tailored towards the needs of Zonemaster +Backend. + +The following attributes are supported. + +=over 4 + +=item file + +A string. The location of the log file to use. Default: C<->. + +The special value C<-> sends output to stdout or stderr depending on the +C attribute. + +=item stderr + +A boolean. True means log to stderr. False means log to stdout. Default: false. + +Ignored if C is anything other than C<->. + +=item log_level + +The threshold for emitting log entries. Default: info. + +The allowed values are specified at L. + +=item json + +A boolean. When true, logs are written in JSON format. Default: false. + +=back + +=cut diff --git a/script/zonemaster_backend_testagent b/script/zonemaster_backend_testagent index bee25f5fa..0cd8079b2 100755 --- a/script/zonemaster_backend_testagent +++ b/script/zonemaster_backend_testagent @@ -255,7 +255,7 @@ When FILE is -, the log is written to standard output. =item B<--loglevel=LEVEL> -The location of the log level to use. +The threshold for emitting log entries. The allowed values are specified at L. diff --git a/t/log.t b/t/log.t index 8231fd97f..e5fcb3489 100644 --- a/t/log.t +++ b/t/log.t @@ -11,18 +11,19 @@ use Log::Any::Adapter; use Test::Fatal qw( exception ); use Zonemaster::Backend::Log; -sub tmp_log_file { - my $dir = tempdir( CLEANUP => 1 ); - return "$dir/backend.log"; -} - subtest 'render entry in text format' => sub { my $stdout = capture { my $logger = Zonemaster::Backend::Log->new; $logger->structured( 'error', 'unit.test', 'text message', { request_id => 'abc123' }, ); }; - like $stdout, qr/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z \[\d+\] \[ERROR\] \[unit\.test\] text message/, 'text log entry contains timestamp, pid, level, category and message'; + like $stdout, qr{ + \A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z # timestamp + \s+\[\d+\] # pid + \s+\[ERROR\] # log level + \s+\[unit[.]test\] # category + \s+text[ ]message # message + }x, 'text log entry contains timestamp, pid, level, category and message',; like $stdout, qr/Extra parameters:/, 'extra parameters are appended'; like $stdout, qr/request_id/, 'extra parameter key is present'; @@ -66,7 +67,8 @@ subtest 'redirect output to stderr' => sub { }; subtest 'redirect output to file' => sub { - my $file = tmp_log_file(); + my $dir = tempdir( CLEANUP => 1 ); + my $file = "$dir/backend.log"; my ( $stdout, $stderr ) = capture { my $logger = Zonemaster::Backend::Log->new( file => $file ); $logger->structured( 'error', 'unit.test', 'message' ); @@ -79,8 +81,6 @@ subtest 'redirect output to file' => sub { }; subtest 'works as a Log::Any adapter' => sub { - my $file = tmp_log_file(); - Log::Any::Adapter->set( { lexically => \my $adapter_scope }, '+Zonemaster::Backend::Log', From 937130a8caf380d42cac410d16e29011e3b2a931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20P=C3=A4iv=C3=A4rinta?= Date: Tue, 9 Jun 2026 10:18:10 +0200 Subject: [PATCH 33/34] Avoid redundant logging of timestamp and pid in journald --- lib/Zonemaster/Backend/Log.pm | 44 +++++++++++++++++++++------ script/zonemaster_backend_rpcapi.psgi | 8 +++-- script/zonemaster_backend_testagent | 36 +++++++++++++++------- share/zm-rpcapi.service | 2 ++ share/zm-testagent.service | 2 ++ t/log.t | 30 ++++++++++++++++++ 6 files changed, 99 insertions(+), 23 deletions(-) diff --git a/lib/Zonemaster/Backend/Log.pm b/lib/Zonemaster/Backend/Log.pm index e0e8bceaa..26e9bce9c 100644 --- a/lib/Zonemaster/Backend/Log.pm +++ b/lib/Zonemaster/Backend/Log.pm @@ -45,6 +45,9 @@ sub init { $self->{handle} = IO::Handle->new_from_fd( $fd, "w" ) or croak "Can't fdopen file: $!"; $self->{handle}->autoflush( 1 ); + $self->{with_timestamp} //= 1; + $self->{with_pid} //= 1; + if ( !exists $self->{formatter} ) { if ( $self->{json} ) { $self->{formatter} = \&format_json; @@ -57,10 +60,19 @@ sub init { sub format_text { my ( $self, $log_params ) = @_; - my $msg; - $msg .= sprintf "%s ", $log_params->{timestamp}; - delete $log_params->{timestamp}; - $msg .= sprintf( "[%d] [%s] [%s] %s", delete $log_params->{pid}, uc delete $log_params->{level}, delete $log_params->{category}, delete $log_params->{message} ); + my $msg = ''; + + my $timestamp = delete $log_params->{timestamp}; + if ( defined $timestamp ) { + $msg .= sprintf "%s ", $timestamp; + } + + my $pid = delete $log_params->{pid}; + if ( defined $pid ) { + $msg .= sprintf "[%d] ", $pid; + } + + $msg .= sprintf( "[%s] [%s] %s", uc delete $log_params->{level}, delete $log_params->{category}, delete $log_params->{message} ); if ( %$log_params ) { local $Data::Dumper::Indent = 0; @@ -90,13 +102,19 @@ sub structured { return if $log_level > $self->{log_level}; my %log_params = ( - timestamp => strftime( "%FT%TZ", gmtime ), - level => $level, - category => $category, - message => $string, - pid => $PID, + level => $level, + category => $category, + message => $string, ); + if ( $self->{with_timestamp} ) { + $log_params{timestamp} = strftime( "%FT%TZ", gmtime ); + } + + if ( $self->{with_pid} ) { + $log_params{pid} = $PID; + } + for my $item ( @items ) { if ( ref( $item ) eq 'HASH' ) { for my $key ( keys %$item ) { @@ -168,6 +186,14 @@ The allowed values are specified at L. A boolean. When true, logs are written in JSON format. Default: false. +=item with_timestamp + +A boolean. Controls the inclusion of timestamp log entries. Default: true. + +=item with_pid + +A boolean. Controls the inclusion of PID in log entries. Default: true. + =back =cut diff --git a/script/zonemaster_backend_rpcapi.psgi b/script/zonemaster_backend_rpcapi.psgi index 7a2c71dd4..902a7337c 100644 --- a/script/zonemaster_backend_rpcapi.psgi +++ b/script/zonemaster_backend_rpcapi.psgi @@ -30,9 +30,11 @@ local $| = 1; Log::Any::Adapter->set( '+Zonemaster::Backend::Log', - log_level => $ENV{ZM_BACKEND_RPCAPI_LOGLEVEL}, - json => $ENV{ZM_BACKEND_RPCAPI_LOGJSON}, - stderr => 1, + log_level => $ENV{ZM_BACKEND_RPCAPI_LOGLEVEL}, + json => $ENV{ZM_BACKEND_RPCAPI_LOGJSON}, + stderr => 1, + with_pid => !$ENV{ZM_BACKEND_RPCAPI_NO_LOGPID}, + with_timestamp => !$ENV{ZM_BACKEND_RPCAPI_NO_LOGTIMESTAMP}, ); $SIG{__WARN__} = sub { diff --git a/script/zonemaster_backend_testagent b/script/zonemaster_backend_testagent index 0cd8079b2..d5d8ca61e 100755 --- a/script/zonemaster_backend_testagent +++ b/script/zonemaster_backend_testagent @@ -43,15 +43,19 @@ my $loglevel; my $logjson; my $opt_outfile; my $opt_help; +my $opt_logpid = 1; +my $opt_logtimestamp = 1; GetOptions( - 'help!' => \$opt_help, - 'pidfile=s' => \$pidfile, - 'user=s' => \$user, - 'group=s' => \$group, - 'logfile=s' => \$logfile, - 'loglevel=s' => \$loglevel, - 'logjson!' => \$logjson, - 'outfile=s' => \$opt_outfile, + 'help!' => \$opt_help, + 'pidfile=s' => \$pidfile, + 'user=s' => \$user, + 'group=s' => \$group, + 'logfile=s' => \$logfile, + 'loglevel=s' => \$loglevel, + 'logjson!' => \$logjson, + 'outfile=s' => \$opt_outfile, + 'logpid!' => \$opt_logpid, + 'logtimestamp!' => \$opt_logtimestamp, ) or pod2usage( "Try '$0 --help' for more information." ); pod2usage( -verbose => 1 ) if $opt_help; @@ -64,9 +68,11 @@ $loglevel = lc $loglevel; Log::Any::Adapter->set( '+Zonemaster::Backend::Log', - log_level => $loglevel, - json => $logjson, - file => $logfile, + log_level => $loglevel, + json => $logjson, + file => $logfile, + with_pid => $opt_logpid, + with_timestamp => $opt_logtimestamp, ); $SIG{__WARN__} = sub { @@ -263,6 +269,14 @@ The allowed values are specified at L. Enable JSON logging when specified. +=item B<--[no-]logtimestamp> + +Controls the inclusion of timestamp log entries. Default: enabled. + +=item B<--[no-]logpid> + +Controls the inclusion of PID in log entries. Default: enabled. + =item B One of the following: diff --git a/share/zm-rpcapi.service b/share/zm-rpcapi.service index 8c5876c2d..bbd8252ed 100644 --- a/share/zm-rpcapi.service +++ b/share/zm-rpcapi.service @@ -7,6 +7,8 @@ Wants=mariadb.service postgresql.service Type=exec User=zonemaster Group=zonemaster +Environment=ZM_BACKEND_RPCAPI_NO_LOGPID=1 +Environment=ZM_BACKEND_RPCAPI_NO_LOGTIMESTAMP=1 KillSignal=SIGQUIT ExecStart=/usr/local/bin/starman \ --listen=127.0.0.1:5000 \ diff --git a/share/zm-testagent.service b/share/zm-testagent.service index 5c00ecb74..272f7a767 100644 --- a/share/zm-testagent.service +++ b/share/zm-testagent.service @@ -9,6 +9,8 @@ User=zonemaster Group=zonemaster ExecStart=/usr/local/bin/zonemaster_backend_testagent \ --logfile=- \ + --no-logpid \ + --no-logtimestamp \ foreground [Install] diff --git a/t/log.t b/t/log.t index e5fcb3489..d511aa54d 100644 --- a/t/log.t +++ b/t/log.t @@ -30,6 +30,36 @@ subtest 'render entry in text format' => sub { like $stdout, qr/abc123/, 'extra parameter value is present'; }; +subtest 'render entry in text format without pid' => sub { + my $stdout = capture { + my $logger = Zonemaster::Backend::Log->new( with_pid => 0 ); + $logger->structured( 'error', 'unit.test', 'text message', { request_id => 'abc123' }, ); + }; + + like $stdout, qr{ + \A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z # timestamp + # no pid + \s+\[ERROR\] # log level + \s+\[unit[.]test\] # category + \s+text[ ]message # message + }x, 'text log entry contains timestamp, level, category and message, but no pid',; +}; + +subtest 'render entry in text format without timestamp' => sub { + my $stdout = capture { + my $logger = Zonemaster::Backend::Log->new( with_timestamp => 0 ); + $logger->structured( 'error', 'unit.test', 'text message', { request_id => 'abc123' }, ); + }; + + like $stdout, qr{ + # no timestamp + \A\[\d+\] # pid + \s+\[ERROR\] # log level + \s+\[unit[.]test\] # category + \s+text[ ]message # message + }x, 'text log entry contains pid, level, category and message, but no timestamp',; +}; + subtest 'render entry in JSON format' => sub { my $stdout = capture { my $logger = Zonemaster::Backend::Log->new( json => 1 ); From 91abe1f58054dccacbfc0324d5b8bb847b8df370 Mon Sep 17 00:00:00 2001 From: Michael Timbert Date: Tue, 16 Jun 2026 09:28:11 +0200 Subject: [PATCH 34/34] CI: force update --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c47d5f6af..eb8dd3d69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: with: perl-version: ${{ matrix.perl }} + - name: update + run: sudo apt update -y + - name: Install binary dependencies run: | # * These were taken from the installation instruction. @@ -143,6 +146,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: update + run: sudo apt update -y + - name: apt install run: sudo apt-get install -y build-essential git libmodule-install-perl gettext