feat: add full Zonemaster stack with Docker and Spanish UI

- Clone all 5 Zonemaster component repos (LDNS, Engine, CLI, Backend, GUI)
- Dockerfile.backend: 8-stage multi-stage build LDNS→Engine→CLI→Backend
- Dockerfile.gui: Astro static build served via nginx
- docker-compose.yml: backend (internal) + frontend (port 5353)
- nginx.conf: root redirects to /es/, /api/ proxied to backend
- zonemaster-gui/config.ts: defaultLanguage set to 'es' (Spanish)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 08:19:24 +02:00
commit 8d4eaa1489
1567 changed files with 204155 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env perl
use utf8;
use strict;
use warnings;
use open qw(:std :utf8);
use feature 'say';
use Locale::PO;
use Try::Tiny;
use Getopt::Long qw( GetOptions );
use Pod::Usage qw( pod2usage );
sub extract_perl_braces {
my $string = shift;
# Strip text before the first and after the last brace
$string =~ s/^[^{]*{?|}?[^}]*$//g;
# Extract the fields
my @fields = split /}[^{]*{/, $string;
return @fields;
}
sub diff_perl_braces {
my $got_string = shift // die 'undefined value for $got_string';
my $expected_string = shift // die 'undefined value for $expected_string';
my %got_fields = map { $_ => 1 } extract_perl_braces( $got_string );
my %expected_fields = map { $_ => 1 } extract_perl_braces( $expected_string );
my %missing = %expected_fields;
delete @missing{ keys %got_fields };
my %extra = %got_fields;
delete @extra{ keys %expected_fields };
return [ sort keys %missing ], [ sort keys %extra ];
}
sub check_perl_braces {
my $po = shift;
my $filename = shift;
my $msgid = $po->dequote( $po->msgid );
my $msgstr = $po->dequote( $po->msgstr );
my ( $missing, $extra ) = diff_perl_braces( $msgstr, $msgid );
if ( @{$missing} or @{$extra} ) {
say "";
say '# ' . $filename . ' line ' . $po->loaded_line_number;
print map { "#. $_\n" } split /\n/, $po->automatic // '';
say "msgid ", $po->msgid;
say "msgstr ", $po->msgstr;
for my $field ( @{$missing} ) {
say " Only in msgid: {$field}";
}
for my $field ( @{$extra} ) {
say " Only in msgstr: {$field}";
}
}
return;
}
my $opt_help = 0;
my $opt_man = 0;
GetOptions(
"help|h" => \$opt_help,
"man" => \$opt_man,
) or pod2usage( 2 );
pod2usage( 1 ) if $opt_help;
pod2usage( -verbose => 2 ) if $opt_man;
for my $filename ( @ARGV ) {
my $aref = Locale::PO->load_file_asarray( $filename, 'UTF-8' ) // die "Failed to load PO file '$filename'";
for my $po ( @$aref ) {
try {
if ( $po->has_flag( 'perl-brace-format' ) ) {
check_perl_braces( $po, $filename );
}
}
catch {
say STDERR "error processing $filename at line " . $po->loaded_line_number . ": " . $_;
exit 1;
};
}
}
=head1 NAME
check-msg-args - Verify that PO files have the same args in the msgid and msgstr of their perl-brace-format messages
=head1 SYNOPSIS
check-msg-args [options] [file ...]
=head1 OPTIONS
=over 4
=item B<--help>
Print a brief help message and exit.
=item B<--man>
Print the manual page and exit.
=back

View File

@@ -0,0 +1,240 @@
#!/usr/bin/env perl
use 5.14.2;
use warnings;
use strict;
use version; our $VERSION = version->declare("v1.0.1");
use Carp;
use English qw( -no_match_vars );
use Zonemaster::Engine::Profile;
use JSON::XS;
use Scalar::Util qw( reftype );
use File::Slurp;
use Getopt::Long;
use File::Basename;
use Clone qw(clone);
my $DEBUG = 0;
my $DEST_DIR = q{};
my $CONFIG_FILE = q{};
my $POLICY_FILE = q{};
my $scriptName = basename($PROGRAM_NAME);
my $dest_dir = q{./};
my $config_file = q{};
my $policy_file = q{};
process_options();
if ( $DEST_DIR ) {
$dest_dir = $DEST_DIR;
}
if ( $CONFIG_FILE ) {
$config_file = $CONFIG_FILE;
}
if ( $POLICY_FILE ) {
$policy_file = $POLICY_FILE;
}
if ($DEBUG) {
print "Debug Mode set ON\n";
print "Destination directory : $dest_dir\n\n";
}
#-------------------------------------------------
# STEP 0: Check Directory existence and Directory/Files permissions
#-------------------------------------------------
if ( ! -d $dest_dir ) {
printf "(\"%s --help\" for help)\n", $scriptName;
print "Directory $dest_dir does not exist.\n";
unless ( mkdir $dest_dir ) {
croak "Unable to create $dest_dir.";
}
}
if ( ! -w $dest_dir ) {
printf "(\"%s --help\" for help)\n", $scriptName;
print "Directory $dest_dir mode must be changed.\n";
unless ( chmod (oct(755), $dest_dir) ) {
croak "Cannot change directory mode.";
}
}
if ( ! $config_file ) {
printf "(\"%s --help\" for help)\n", $scriptName;
croak "A Config file must be provided.";
}
if ( ! -e $config_file ) {
printf "(\"%s --help\" for help)\n", $scriptName;
croak "Config file $config_file does not exists.";
}
if ( ! -r $config_file ) {
printf "(\"%s --help\" for help)\n", $scriptName;
croak "Config file $config_file is not readable.";
}
if ( ! $policy_file ) {
printf "(\"%s --help\" for help)\n", $scriptName;
croak "A Policy file must be provided.";
}
if ( ! -e $policy_file ) {
printf "(\"%s --help\" for help)\n", $scriptName;
croak "Policy file $policy_file does not exists.";
}
if ( ! -r $policy_file ) {
printf "(\"%s --help\" for help)\n", $scriptName;
croak "Policy file $policy_file is not readable.";
}
my $policy_json = read_file( $policy_file ) ;
my $policy = decode_json( $policy_json );
my $config_json = read_file( $config_file );
my $config = decode_json( $config_json );
my $default = Zonemaster::Engine::Profile->default;
my $profile = Zonemaster::Engine::Profile->new;
my %paths;
my %default_paths;
Zonemaster::Engine::Profile::_get_profile_paths(\%paths, $config);
Zonemaster::Engine::Profile::_get_profile_paths(\%default_paths, $default->{q{profile}});
delete $default_paths{ q{test_cases} };
delete $default_paths{ q{test_levels} };
#
# General options part
#
foreach my $property_name ( keys %default_paths ) {
my $value = Zonemaster::Engine::Profile::_get_value_from_nested_hash( $config, split /\./, $property_name );
if ( defined $value ) {
$profile->set( $property_name, $value );
}
}
#
# Test cases part
#
my @tc;
foreach my $tc ( @{ $default->get( q{test_cases} ) } ) {
if ( not defined $policy->{__testcases__}->{$tc} or $policy->{__testcases__}->{$tc} ) {
push @tc, $tc;
}
}
$profile->set( q{test_cases}, \@tc );
#
# Test levels part
#
my %tl;
foreach my $tl ( keys %{ $default->get( q{test_levels} ) } ) {
if ( exists $policy->{$tl} ) {
$tl{$tl} = clone $policy->{$tl};
}
}
$profile->set( q{test_levels}, \%tl );
my $filename = $dest_dir.q{/profile.json};
open(my $fh, '>', $filename) or die "Could not open file '$filename' $!";
my $json = JSON::XS->new->canonical->pretty;
print $fh $json->encode( $profile->{q{profile}} );
close $fh;
sub process_options {
my ( $opt_dest, $opt_config, $opt_policy, $opt_help, $opt_debug, $opt_version );
GetOptions(
q{dest-dir=s} => \$opt_dest, # Dest directory for generated profile file
q{config=s} => \$opt_config, # Config File
q{policy=s} => \$opt_policy, # Policy file
q{help} => \$opt_help, # Print Usage
q{debug} => \$opt_debug, # Set Debug MODE
q{version} => \$opt_version, # Print Version
);
if ( $opt_debug ) {
$DEBUG = 1;
}
if ( $opt_dest ) {
$DEST_DIR = $opt_dest;
}
if ( $opt_config ) {
$CONFIG_FILE = $opt_config;
}
if ( $opt_policy ) {
$POLICY_FILE = $opt_policy;
}
if ( $opt_help ) {
Usage();
}
if ( $opt_version ) {
Version();
}
return;
}
sub Usage {
my $_bold = "\e[1m";
my $_normal = "\e[0m";
my $_ul = "\e[4m";
my $scriptNameBlank = $scriptName;
$scriptNameBlank =~ s/./ /smxg;
print << "EOM";
${_bold}NAME${_normal}
${scriptName} - Convert Config/Policy files into Profile file.
${_bold}SYNOPSIS${_normal}
${scriptName} [ --help ] [ --dest-dir=${_ul}alternate_destination_directory${_normal} ] [ --config=${_ul}config_file${_normal} ] [ --policy=${_ul}policy_file${_normal} ] [ --debug ]
${_bold}OPTIONS${_normal}
--help
Print this message and exit.
--config
Name of the Config file to convert.
--policy
Name of the Policy file to convert.
--dest-dir
Name of an alternate directory to save generated profile file.
${_bold}DEFAULT${_normal} is $dest_dir.
--debug
Set Debug mode ON.
--version
Print version of this program.
EOM
exit 0;
}
sub Version {
printf "%s %s\n", $scriptName, $VERSION;
exit 0;
}

109
zonemaster-engine/util/data2dig Executable file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env perl
use strict;
use warnings;
use feature 'say';
use Zonemaster::Engine::Packet;
use JSON::PP;
use MIME::Base64;
use Module::Find qw[useall];
use Readonly;
use Scalar::Util qw[blessed];
useall 'Zonemaster::LDNS::RR';
# Decoder taken from Zonemaster::Engine::Nameserver->restore
Readonly my $decoder => JSON::PP->new->filter_json_single_key_object(
'Zonemaster::LDNS::Packet' => sub {
my ( $ref ) = @_;
## no critic (Modules::RequireExplicitInclusion)
my $obj = Zonemaster::LDNS::Packet->new_from_wireformat( decode_base64( $ref->{data} ) );
$obj->answerfrom( $ref->{answerfrom} );
$obj->timestamp( $ref->{timestamp} );
$obj->querytime( $ref->{querytime} );
return $obj;
}
)->filter_json_single_key_object(
'Zonemaster::Engine::Packet' => sub {
my ( $ref ) = @_;
return Zonemaster::Engine::Packet->new( { packet => $ref } );
}
);
# Decode input into packets
my @packets;
while ( my $line = <> ) {
my ( $name, $addr, $data ) = split( / /, $line, 3 );
my $tree = deserialize( $data );
push @packets, packets( $tree );
}
# Order packets chronologically
@packets = sort { $a->timestamp cmp $b->timestamp } @packets;
# Print delimited packets
my $delim = ";" x 78;
for my $packet ( @packets ) {
say $delim;
say $packet->string;
$delim = "\n" . ";" x 78;
}
=head1 NAME
data2dig - Export saved Zonemaster::Engine cache files to a dig format
=head1 SYNOPSIS
data2dig foo.data
=head1 DESCRIPTION
B<data2dig> exports saved Zonemaster::Engine cache files to human readable
format as chronologically ordered response packets in dig format.
=head1 SUBROUTINES
=head2 deserialize
Deserialize a string in Zonemaster::Engine saved cache format.
Returns a tree of nested HASHREFs with decoded Zonemaster::Engine::Packet
objects.
=cut
sub deserialize {
my $data = shift;
return $decoder->decode( $data );
}
=head2 packets
Return all Zonemaster::Engine::Packet objects from a tree of nested HASHREFs.
=cut
sub packets {
my ( $data ) = @_;
if ( ref $data eq 'HASH' && %{$data} && not blessed $data ) {
my @packets;
for my $key ( sort keys %$data ) {
push @packets, packets( $data->{$key} );
}
return @packets;
}
elsif ( blessed $data && $data->isa( 'Zonemaster::Engine::Packet' ) ) {
return ( $data );
}
else {
return ();
}
}

View File

@@ -0,0 +1,203 @@
#!/usr/bin/env perl
use 5.14.2;
use warnings;
use strict;
use version; our $VERSION = version->declare("v1.0.1");
use Carp;
use English qw( -no_match_vars );
use LWP::Simple;
use File::Find;
use File::chmod;
use File::Copy qw(copy);
use File::Temp qw(tempfile);
use Getopt::Long;
use File::Basename;
use FindBin;
use Text::CSV;
my $DEBUG = 0;
my $DEST_DIR = q{};
my $iana_url = q{http://www.iana.org/assignments/};
my $dest_dir = qq{$FindBin::Bin/../share};
my @files_details = (
{ name => q{iana-ipv4-special-registry.csv}, url => $iana_url.q{/iana-ipv4-special-registry/iana-ipv4-special-registry-1.csv}, ip_version => 4 },
{ name => q{iana-ipv6-special-registry.csv}, url => $iana_url.q{/iana-ipv6-special-registry/iana-ipv6-special-registry-1.csv}, ip_version => 6 },
);
process_options();
if ( $DEST_DIR ) {
$dest_dir = $DEST_DIR;
}
if ($DEBUG) {
print "Debug Mode set ON\n";
print "Destination directory : $dest_dir\n\n";
}
#-------------------------------------------------
# STEP 0: Check Directory existence and Directory/Files permissions
#-------------------------------------------------
if ( ! -d $dest_dir ) {
print "Directory $dest_dir does not exist.\n";
unless ( mkdir $dest_dir ) {
croak "Unable to create $dest_dir.";
}
}
if ( ! -w $dest_dir ) {
print "Directory $dest_dir mode must be changed.\n";
unless ( chmod (oct(755), $dest_dir) ) {
croak "Cannot change directory mode.";
}
}
foreach my $file_details ( @files_details ) {
my $fn = $dest_dir.q{/}.${$file_details}{name};
if ( -e $fn and ! -w $fn ) {
print "File $fn mode must be changed.\n";
unless ( chmod (oct(664), $fn) ) {
croak "Cannot change file mode.";
}
}
}
#-------------------------------------------------
# STEP 1: If they exist, save original files
#-------------------------------------------------
foreach my $file_details ( @files_details ) {
my $fn = $dest_dir.q{/}.${$file_details}{name};
if ( -e $fn ) {
my ($fh, $filename) = tempfile();
${$file_details}{backup_filename} = $filename;
unless ( copy $fn, $filename ) {
croak "The Copy operation failed: $ERRNO";
}
}
}
#-------------------------------------------------
# STEP 2: Retrieve remote files in temporary files
#-------------------------------------------------
foreach my $file_details ( @files_details ) {
my ($fh, $filename) = tempfile();
${$file_details}{new_filename} = $filename;
my $rc = getstore(${$file_details}{url}, $filename);
}
#-------------------------------------------------
# STEP 3: Check downloaded files integrity
#-------------------------------------------------
foreach my $file_details ( @files_details ) {
my $fn = ${$file_details}{new_filename};
my $csv = Text::CSV->new({ binary => 1, auto_diag => 1, sep_char => q{,} }) or croak "Cannot use CSV: ".Text::CSV->error_diag ();
open my $fh, "<:encoding(utf8)", $fn or croak "$fn: $ERRNO";
while ( my $row = $csv->getline( $fh ) ) {
}
$csv->eof or croak $csv->error_diag();
close $fh or croak "$fn: $ERRNO";
}
#-------------------------------------------------
# STEP 4: Copy Files on their final destination
#-------------------------------------------------
foreach my $file_details ( @files_details ) {
my $fn = $dest_dir.q{/}.${$file_details}{name};
if ( -e ${$file_details}{new_filename} ) {
unless ( copy ${$file_details}{new_filename}, $fn ) {
croak "The Copy operation failed: $ERRNO";
}
}
}
#-------------------------------------------------
# STEP 5: Delete backup, temporary files
#-------------------------------------------------
clean_temporary_files();
sub clean_temporary_files {
foreach my $file_details ( @files_details ) {
if ($DEBUG) {
print "${$file_details}{name} Details : \n";
print "Backup file : ${$file_details}{backup_filename}\n";
print "Downloaded file : ${$file_details}{new_filename}\n\n";
} else {
unlink ${$file_details}{backup_filename} or carp "Could not unlink ${$file_details}{backup_filename}: $ERRNO";
unlink ${$file_details}{new_filename} or carp "Could not unlink ${$file_details}{new_filename}: $ERRNO";
}
}
return;
}
sub process_options {
my ( $opt_dest, $opt_help, $opt_debug );
GetOptions(
q{dest-dir=s} => \$opt_dest, # Dest directory for downloaded files
q{help} => \$opt_help, # Print Usage
q{debug} => \$opt_debug, # Set Debug MODE
);
if ( $opt_debug ) {
$DEBUG = 1;
}
if ( $opt_dest ) {
$DEST_DIR = $opt_dest;
}
if ( $opt_help ) {
Usage();
}
return;
}
sub Usage {
my $_bold = "\e[1m";
my $_normal = "\e[0m";
my $_ul = "\e[4m";
my $scriptName = basename($PROGRAM_NAME);
my $scriptNameBlank = $scriptName;
$scriptNameBlank =~ s/./ /smxg;
print << "EOM";
${_bold}NAME${_normal}
${scriptName} - Download IANA Address Space Registries
${_bold}SYNOPSIS${_normal}
${scriptName} [ --help ] [ --dest-dir=${_ul}alternate_destination_directory${_normal} ] [ --debug ]
${_bold}DESCRIPTION${_normal}
${scriptName} is a tool to download official IANA Address Space registries.
Although these files are part of Zonemaster distribution, they are subject to changes and it is important that Zonemaster use last versions in order to give more accurate tests results.
That script should be called on a regular frequency basis to keep synchronization with IANA registries.
${_bold}OPTIONS${_normal}
--help
Print this message and exit.
--dest-dir
Name of an alternate directory to save downloaded files.
${_bold}DEFAULT${_normal} is $dest_dir.
--debug
Set Debug mode ON. Temporary files will not be deleted.
EOM
exit 0;
}

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env perl
use 5.14.2;
use warnings;
use JSON::PP;
my $json = JSON::PP->new->canonical->pretty->utf8;
say $json->encode($json->decode(join('',<>)));