241 lines
7.5 KiB
Perl
Executable File
241 lines
7.5 KiB
Perl
Executable File
#!/usr/bin/perl
|
|
#
|
|
# validate-unified-files
|
|
#
|
|
# Validate that the only files present in one or more BIND directories are
|
|
# "related to" zones described in a unified zones config (typically generated by
|
|
# sync-unified-master).
|
|
#
|
|
# - Reads unified config (default: /etc/bind/unified.zones.9)
|
|
# - Extracts:
|
|
# * zone names: zone "example.org" { ... }
|
|
# * referenced paths in: file "..." ; journal "..." ; include "..."
|
|
# - Builds an allow-set of basenames:
|
|
# * the exact referenced basenames
|
|
# * "related" siblings: .jnl, .signed, .jbk, backups of those
|
|
# - Additionally allows DNSSEC-related artifacts for zones that exist in unified:
|
|
# * K<zone>+*.key / K<zone>+*.private
|
|
# * dsset-<zone>. / keyset-<zone>. (common dnssec-signzone outputs)
|
|
# * <zone>.signed (already covered via zonefile sibling rules if zonefile is db.<zone>)
|
|
# * *.ds / *.dnskey / *.keyset / *.dsset variants that are zone-specific
|
|
#
|
|
# Usage:
|
|
# validate-unified-files [--unified /path] [--dir DIR --dir DIR ...] [--strict] [--quiet]
|
|
#
|
|
# Exit:
|
|
# 0: OK (or warnings but not strict)
|
|
# 2: warnings and --strict
|
|
#
|
|
|
|
use strict;
|
|
use warnings;
|
|
|
|
use Getopt::Long qw/GetOptions/;
|
|
use File::Basename qw/basename/;
|
|
|
|
my $unified = '/etc/bind/unified.zones.9';
|
|
my @dirs = ('/etc/bind/custom', '/etc/bind/vhost');
|
|
my $strict = 0;
|
|
my $quiet = 0;
|
|
|
|
GetOptions(
|
|
'unified=s' => \$unified,
|
|
'dir=s@' => \@dirs, # may be repeated
|
|
'strict!' => \$strict, # exit nonzero if warnings
|
|
'quiet|q!' => \$quiet,
|
|
) or die "Usage: $0 [--unified /path/to/unified.zones.9] [--dir DIR --dir DIR] [--strict] [-q]\n";
|
|
|
|
sub _esc_re {
|
|
my ($s) = @_;
|
|
$s =~ s/([\\.^\$|(){}\[\]*+?])/\\$1/g;
|
|
return $s;
|
|
}
|
|
|
|
sub allow_related_by_base {
|
|
my ($base, $allowed_ref) = @_;
|
|
return if !defined $base || $base eq '';
|
|
|
|
# Always allow the thing itself
|
|
$allowed_ref->{$base} = 1;
|
|
|
|
# Helper: add common backup suffixes for a given filename
|
|
my $add_backups = sub {
|
|
my ($fn) = @_;
|
|
$allowed_ref->{$fn} = 1;
|
|
$allowed_ref->{"$fn.jbk"} = 1;
|
|
$allowed_ref->{"$fn.bak"} = 1;
|
|
$allowed_ref->{"$fn.old"} = 1;
|
|
$allowed_ref->{"$fn.tmp"} = 1;
|
|
};
|
|
|
|
# If base is a zonefile (neither .jnl nor .signed nor include), allow common siblings.
|
|
# We handle .jnl and .signed specially below.
|
|
if ($base !~ /\.jnl$/ && $base !~ /\.signed$/) {
|
|
# base zonefile variants
|
|
$add_backups->($base);
|
|
|
|
# journal of base
|
|
$add_backups->("$base.jnl");
|
|
|
|
# signed zonefile + its journal (THIS is the one you hit)
|
|
$add_backups->("$base.signed");
|
|
$add_backups->("$base.signed.jnl");
|
|
|
|
return;
|
|
}
|
|
|
|
# If base is a journal, allow the corresponding base zonefile + signed siblings
|
|
if ($base =~ /\.jnl$/) {
|
|
(my $zf = $base) =~ s/\.jnl$//;
|
|
if ($zf ne $base) {
|
|
$add_backups->($zf);
|
|
$add_backups->("$zf.signed");
|
|
$add_backups->("$zf.signed.jnl");
|
|
$add_backups->($base); # the journal itself
|
|
}
|
|
return;
|
|
}
|
|
|
|
# If base is a signed zonefile, allow its underlying zonefile + journals
|
|
if ($base =~ /\.signed$/) {
|
|
(my $zf = $base) =~ s/\.signed$//;
|
|
if ($zf ne $base) {
|
|
$add_backups->($zf);
|
|
$add_backups->("$zf.jnl");
|
|
$add_backups->($base);
|
|
$add_backups->("$base.jnl");
|
|
}
|
|
return;
|
|
}
|
|
|
|
# Fallback: just allow the file itself (already done)
|
|
}
|
|
|
|
sub allow_dnssec_artifacts_for_zone {
|
|
my ($zone, $allowed_ref, $allowed_regex_ref) = @_;
|
|
return if !defined $zone || $zone eq '';
|
|
|
|
# Normalize: ensure trailing dot for keyset/dsset patterns is optional
|
|
# (tools vary in whether they include a trailing dot in filenames).
|
|
my $z_no_dot = $zone;
|
|
$z_no_dot =~ s/\.$//;
|
|
|
|
# 1) DNSSEC key files produced by dnssec-keygen:
|
|
# Kexample.org.+013+12345.key / .private
|
|
# Some setups also create "state" files; we allow those too if present.
|
|
my $zre = _esc_re($z_no_dot);
|
|
push @$allowed_regex_ref, qr/^K$zre\+\d+\+\d+\.(key|private)$/;
|
|
push @$allowed_regex_ref, qr/^K$zre\+\d+\+\d+(\.state|\.signed|\.published|\.removed)?$/; # very conservative extras
|
|
|
|
# 2) dnssec-signzone outputs:
|
|
# dsset-example.org. / keyset-example.org.
|
|
# Sometimes with/without trailing dot.
|
|
my $zre_dot_optional = _esc_re($z_no_dot) . '\.?';
|
|
push @$allowed_regex_ref, qr/^(dsset|keyset)-$zre_dot_optional$/;
|
|
|
|
# 3) Occasionally you might keep DS or DNSKEY exports as separate files:
|
|
# example.org.ds / example.org.dnskey (or .dnskeys)
|
|
push @$allowed_regex_ref, qr/^$zre(\.ds|\.dnskey|\.dnskeys)$/;
|
|
|
|
# 4) Some workflows store per-zone keyset/dsset with suffixes/backups:
|
|
push @$allowed_regex_ref, qr/^(dsset|keyset)-$zre_dot_optional(\.jbk|\.bak|\.old|\.tmp)$/;
|
|
|
|
# Note: we intentionally do NOT blanket-allow "any *.key" etc.
|
|
# Everything remains tied to a zone that exists in the unified file.
|
|
}
|
|
|
|
# --- Parse unified config, building allowed basenames and zone names ---
|
|
open(my $fh, '<', $unified) or die "Can't open $unified: $!\n";
|
|
|
|
my %allowed; # exact basenames allowed
|
|
my %zones_in_unified; # zone names encountered
|
|
my @allowed_regex; # regex-based allows (DNSSEC artifacts, etc.)
|
|
|
|
while (my $line = <$fh>) {
|
|
chomp $line;
|
|
|
|
# Skip comment-only lines quickly
|
|
next if $line =~ /^\s*#/;
|
|
next if $line =~ /^\s*;/;
|
|
|
|
# Capture zone names: zone "k8s.jorj.org" { ... };
|
|
# This works even if multiple zones are on one line.
|
|
while ($line =~ /\bzone\s+"([^"]+)"\s*\{/g) {
|
|
my $z = $1;
|
|
$zones_in_unified{$z} = 1;
|
|
allow_dnssec_artifacts_for_zone($z, \%allowed, \@allowed_regex);
|
|
}
|
|
|
|
# Collect referenced paths: file/journal/include "..."
|
|
while ($line =~ /\b(file|journal|include)\s+"([^"]+)"/g) {
|
|
my $path = $2;
|
|
my $base = basename($path);
|
|
allow_related_by_base($base, \%allowed);
|
|
}
|
|
}
|
|
close $fh;
|
|
|
|
# Extra: If you keep zonefiles named db.<zone> in the validated dirs, but unified points
|
|
# to /var/lib/bind/custom/db.<zone>, then basenames already match.
|
|
# If you ALSO keep DNSSEC-signed zonefiles as db.<zone>.signed, those are allowed via siblings.
|
|
|
|
# --- Scan target dirs and warn about anything not allowed ---
|
|
my $warnings = 0;
|
|
|
|
DIR:
|
|
for my $dir (@dirs) {
|
|
if (!-d $dir) {
|
|
print "WARNING: directory not found: $dir\n" unless $quiet;
|
|
$warnings++;
|
|
next DIR;
|
|
}
|
|
|
|
opendir(my $dh, $dir) or do {
|
|
print "WARNING: cannot open directory $dir: $!\n" unless $quiet;
|
|
$warnings++;
|
|
next DIR;
|
|
};
|
|
|
|
while (my $name = readdir($dh)) {
|
|
next if $name eq '.' || $name eq '..';
|
|
|
|
my $path = "$dir/$name";
|
|
|
|
# Only care about files and symlinks; skip subdirectories.
|
|
next if -d $path;
|
|
|
|
my $ok = 0;
|
|
|
|
# Exact match allow
|
|
if ($allowed{$name}) {
|
|
$ok = 1;
|
|
} else {
|
|
# Regex allow (DNSSEC-related, zone-tied artifacts)
|
|
for my $re (@allowed_regex) {
|
|
if ($name =~ $re) {
|
|
$ok = 1;
|
|
last;
|
|
}
|
|
}
|
|
}
|
|
|
|
next if $ok;
|
|
|
|
print "WARNING: stray file not referenced by $unified: $path\n";
|
|
$warnings++;
|
|
}
|
|
|
|
closedir $dh;
|
|
}
|
|
|
|
if (!$quiet) {
|
|
if ($warnings) {
|
|
print "validate-unified-files: $warnings warning(s)\n";
|
|
} else {
|
|
# This runs from cron; don't print anything on success
|
|
# print "validate-unified-files: OK (no stray files)\n";
|
|
}
|
|
}
|
|
|
|
exit(($strict && $warnings) ? 2 : 0);
|