diff --git a/Makefile.PL b/Makefile.PL index 29b0603..a4e7f3a 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -24,6 +24,7 @@ WriteMakefile( 'bin/sync-unified-master', 'bin/dump-config', 'bin/update-config', + 'bin/validate-unified-files', ], 'AUTHOR' => 'Jorj Bauer ', ); diff --git a/bin/validate-unified-files b/bin/validate-unified-files new file mode 100755 index 0000000..8190cb6 --- /dev/null +++ b/bin/validate-unified-files @@ -0,0 +1,239 @@ +#!/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+*.key / K+*.private +# * dsset-. / keyset-. (common dnssec-signzone outputs) +# * .signed (already covered via zonefile sibling rules if zonefile is db.) +# * *.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. in the validated dirs, but unified points +# to /var/lib/bind/custom/db., then basenames already match. +# If you ALSO keep DNSSEC-signed zonefiles as db..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 { + print "validate-unified-files: OK (no stray files)\n"; + } +} + +exit(($strict && $warnings) ? 2 : 0);