spf-dkim-dmarc-demo/dns/web-api/lib/Email/SpoofingDemo/DNS/ZoneFile.pm

335 lines
7.9 KiB
Perl
Raw Permalink Normal View History

2023-10-25 15:50:33 +02:00
#
# SPDX-FileCopyrightText: 2023 Afnic
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
2023-10-25 15:50:22 +02:00
package Email::SpoofingDemo::DNS::ZoneFile;
use strict;
use warnings;
use utf8;
use v5.10;
use Exporter qw(import);
use IPC::Open2;
2023-10-25 15:50:22 +02:00
use File::Slurp;
use Try::Tiny;
our @EXPORT_OK = qw(get_txt get_zone_file import_zone_file remove_txt set_txt
zone_file_path);
sub safe_system {
my @argv = @_;
my $retval = system @argv;
die $! if ($retval == -1);
if ($retval != 0) {
my $status = $retval >> 8;
die join(" ", @argv) . " exited with status $status\n";
};
return 1;
}
sub is_dynamic_zone {
my ($zone, $dynamic_zones) = @_;
return grep { $_ eq $zone } @$dynamic_zones;
}
sub rndc_freeze {
my ($zone) = @_;
safe_system('rndc', 'freeze', $zone);
}
sub rndc_thaw {
my ($zone) = @_;
safe_system('rndc', 'thaw', $zone);
}
sub rndc_reload {
my ($zone) = @_;
safe_system('rndc', 'reload', $zone);
}
sub encode_txt {
my ($text) = @_;
my $chunk_length = 255;
my $text_bytes = $text;
utf8::encode($text_bytes);
my @parts;
for (my $i = 0; $i < length($text_bytes); $i += $chunk_length) {
my $part = substr($text_bytes, $i, 255);
$part =~ s/[\x00-\x1f"\x7f-\xff]/sprintf('\\%03d', ord($0))/eg;
push @parts, qq{"$part"};
}
return join(" ", @parts);
}
sub relative_to_absolute {
my ($zone, $relative_owner) = @_;
if ($relative_owner eq '') {
return $zone;
}
else {
return "$relative_owner.$zone";
}
}
sub nsupdate {
my ($zone, @instructions) = @_;
open(my $pipe, '|-', 'nsupdate') or die "Cant fork: $!";
say $pipe qq{zone $zone};
say $pipe $_ foreach (@instructions);
close($pipe);
return (($? >> 8) == 0);
}
sub get_txt {
my ($zone, $relative_owner, $filter, $sublabels_ok) = @_;
$filter //= qr{.*};
$sublabels_ok //= 0;
my @dig_opts = qw(+nottlid +noclass +norecurse +noall +answer @127.0.0.1);
my $owner_suffix = relative_to_absolute($zone, $relative_owner);
my $qname = ($sublabels_ok ? $zone : $owner_suffix);
my $qtype = ($sublabels_ok ? 'AXFR' : 'TXT');
my $owner_match_re = ($sublabels_ok ? qr/^([^.]+\.)*$owner_suffix\.$/i : qr/^$qname\.$/i);
my @result;
open(my $pipe, '-|', 'dig', @dig_opts, $qtype, $qname) or die "Cant fork: $!";
while (<$pipe>) {
chomp;
next if /^;/;
next if /^\s*$/;
my ($owner, $type, $data) = split(" ", $_, 3);
unless ($type eq 'TXT') {
next;
}
unless ($owner =~ $owner_match_re) {
next;
}
my $concat_data = ($data =~ s/"((?:[^\\"]|\\"|\\\d{3})*)"\s*/$1/gr);
unless ($concat_data =~ $filter) {
next;
}
push @result, { owner => ($owner =~ s/\.$//r), txt => $concat_data };
}
close($pipe);
if (wantarray) {
return @result;
}
else {
return $result[0];
}
}
sub set_txt {
my ($zone, $relative_owner, $txt) = @_;
my $owner = relative_to_absolute($zone, $relative_owner);
my $txt_encoded = encode_txt($txt);
nsupdate(
$zone,
"update delete $owner 0 IN TXT",
"update add $owner IN TXT $txt_encoded",
"send");
}
sub remove_txt {
my ($zone, $relative_owner) = @_;
my $owner = relative_to_absolute($zone, $relative_owner);
nsupdate(
$zone,
"update delete $owner 0 IN TXT",
"send");
}
sub parse_named_checkzone_output {
my ($line, $previous_messages) = @_;
chomp $line;
if ($line =~ /^OK$/) {
# On regarde déjà le code de sortie de la commande named-checkzone,
# donc un message "OK" najoute aucune information.
return undef;
}
elsif ($line =~ /^zone [^:]+: (.*)$/) {
my $msg = $1;
# Ce pattern accompagne parfois des messages derreur précédents, mais
# pas toujours. On ne conserve ce message que sil ny en a pas eu
# dautres avant.
if ($msg =~ /loading from master file \(null\) failed: (.*)$/) {
my $zone_error = $1;
if (grep { $_ =~ qr/\Q$zone_error\E$/ } @$previous_messages) {
return undef;
}
else {
return $zone_error;
}
}
# Ce pattern accompagne des messages derreur précédents,
# et najoute donc aucune information.
return undef if ($msg eq 'not loaded due to errors.');
# Renvoyé en cas de réussite
return undef if ($msg =~ /loaded serial \d+/);
# Cas de tout autre message
return $line;
}
elsif ($line =~ /stream-0x[0-9a-f]+/) {
# named-checkzone préfixe parfois ses messages derreur par des noms
# de fonctions internes. On ne les conserve pas, car ce sont des
# détails internes pas très user-friendly.
$line =~ s/^dns_(?:master_load|rdata_fromtext): //;
# Comme named-checkzone a lu le fichier de zones à travers un pipe,
# named-checkzone rapporte en guise de nom de fichier un truc opaque
# pas très user-friendly quil vaut mieux supprimer. Mais on conserve
# bien entendu le numéro de la ligne !
$line =~ s/stream-0x[0-9a-f]+:(\d+):/Ligne $1:/;
$line =~ s/stream-0x[0-9a-f]+:\s*//;
# Est-ce si grave si le fichier ne finit pas par une fin de ligne ?
return undef if $line =~ /file does not end with newline/;
return $line;
}
else {
return $line;
}
}
2023-10-25 15:50:22 +02:00
sub check_zone {
my ($zone_contents, $origin) = @_;
if ($zone_contents =~ /^\$INCLUDE\s+/ms) {
return 'failure', ['Refusing to load zone file containing $INCLUDE'];
}
2023-10-25 15:50:22 +02:00
my $pid = open2(my $pipe_out, my $pipe_in,
'named-checkzone', $origin, '-');
print $pipe_in $zone_contents;
close($pipe_in);
my @messages;
while (<$pipe_out>)
{
chomp;
my $line = parse_named_checkzone_output($_, \@messages);
push @messages, $line if defined $line;
}
waitpid($pid, 0);
my $exit_status = ($? >> 8);
my $outcome;
if ($exit_status != 0) {
$outcome = 'failure';
}
elsif (scalar @messages) {
$outcome = 'ok_with_warnings';
}
else {
$outcome = 'ok';
}
return ($outcome, \@messages);
2023-10-25 15:50:22 +02:00
}
sub zone_file_path {
my ($zone, $base_dir, $force) = @_;
my @components = reverse(split(/\./, $zone));
my $path = $base_dir . '/' . (join('/', @components)) . ".zone";
if (not -f $path and not $force) {
return undef;
}
return $path;
}
sub get_zone_file {
my ($zone, $base_dir, $dynamic_zones) = @_;
my $path = zone_file_path($zone, $base_dir);
return undef if not defined $path;
rndc_freeze($zone) if is_dynamic_zone($zone, $dynamic_zones);
my $contents;
try {
$contents = read_file($path);
}
finally {
rndc_thaw($zone) if is_dynamic_zone($zone, $dynamic_zones);
};
return $contents;
}
sub import_zone_file {
my ($zone, $base_dir, $contents, $dynamic_zones) = @_;
if ($contents =~ /^\s*$/) {
return "failure", ["Zone to import is empty"];
2023-10-25 15:50:22 +02:00
}
my $path = zone_file_path($zone, $base_dir);
if (not defined $path) {
return "failure", ["No zone file for $zone in $base_dir"];
2023-10-25 15:50:22 +02:00
}
my ($outcome, $messages) = check_zone($contents, $zone);
2023-10-25 15:50:22 +02:00
if ($outcome ne 'failure') {
rndc_freeze($zone) if is_dynamic_zone($zone, $dynamic_zones);
2023-10-25 15:50:22 +02:00
try {
write_file($path, $contents);
unless (is_dynamic_zone($zone, $dynamic_zones)) {
rndc_reload($zone);
}
}
catch {
return 'failure', [$_];
}
finally {
if (is_dynamic_zone($zone, $dynamic_zones)) {
rndc_thaw($zone);
}
};
2023-10-25 15:50:22 +02:00
}
return $outcome, $messages;
2023-10-25 15:50:22 +02:00
}
1;