#!/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);
