add validation script to find configs to remove

This commit is contained in:
2026-02-01 21:15:13 +00:00
parent 1155d4e43e
commit d51b2eb9bd
2 changed files with 240 additions and 0 deletions

View File

@@ -24,6 +24,7 @@ WriteMakefile(
'bin/sync-unified-master',
'bin/dump-config',
'bin/update-config',
'bin/validate-unified-files',
],
'AUTHOR' => 'Jorj Bauer <jorj@jorj.org>',
);

239
bin/validate-unified-files Executable file
View File

@@ -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<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 {
print "validate-unified-files: OK (no stray files)\n";
}
}
exit(($strict && $warnings) ? 2 : 0);