#!/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 { # This runs from cron; don't print anything on success # print "validate-unified-files: OK (no stray files)\n"; } } exit(($strict && $warnings) ? 2 : 0);