#!/usr/bin/perl use strict; use warnings; use File::Temp qw/tempfile/; use Getopt::Long qw/GetOptions/; use POSIX qw/strftime/; use Martnet::DDNS; # Hidden-master unified include generator. # # - Generates /etc/bind/unified.zones.9 # - Creates missing zonefiles for any emitted zones (minimal skeleton) # - Deterministic output; only rewrites include file if content changes # # Notes: # - Does NOT generate per-zone content beyond a minimal, valid zone skeleton. # - Does NOT attempt to infer DNSSEC (you are removing _dnssec entirely). # - Filters out private.invalid (core static config) and _pureslave (slave-only). my $out = '/etc/bind/unified.zones.9'; my $self_master = undef; # if unset, uses $ddns->default_master() my $force_zf = 0; # overwrite existing zonefiles too my $force_out = 0; # rewrite unified include even if identical my $reload_cmd = '/usr/sbin/rndc'; my $reconfig = 1; # use rndc reconfig (safer for include file updates) my $default_ddns_key = 'master-ddns-tsig'; GetOptions( 'out=s' => \$out, 'self=s' => \$self_master, 'force-zonefiles!' => \$force_zf, 'force!' => \$force_out, 'reload-cmd=s' => \$reload_cmd, 'reconfig!' => \$reconfig, ) or die "Usage: $0 [--out PATH] [--self IP] [--force-zonefiles] [--force] [--reload-cmd /path/to/rndc] [--reconfig]\n"; my $ddns = Martnet::DDNS->new(); $self_master = $ddns->default_master() unless defined $self_master; # Pull all control-plane records and select the zones this hidden master should serve as MASTER. my @all = $ddns->get(); my @zones; for my $r (@all) { next unless defined $r->{zone} && $r->{zone} ne ''; # Core control plane zone should remain static, not autogenerated. next if $r->{zone} eq 'private.invalid'; # Skip explicitly slave-only records. next if defined($r->{type}) && $r->{type} eq '_pureslave'; # Must have master(s) information to decide authority and to generate stable include. next unless defined $r->{master} && $r->{master} ne ''; # master field is normalized to "ip1;ip2;ip3" by DDNS.pm (string or JSON input) my %masters = map { $_ => 1 } grep { $_ ne '' } split(/;/, $r->{master}); # Only emit zones where THIS host is listed as master. next unless $masters{$self_master}; push @zones, $r; } # Deterministic ordering @zones = sort { ($a->{zone} cmp $b->{zone}) || (($a->{type} // '') cmp ($b->{type} // '')) } @zones; # Map control-plane type to zonefile directory. # Adjust if you later collapse storage locations. sub zonefile_for { my ($zone, $type) = @_; my $dir = '/var/lib/bind/custom'; # default if (defined $type) { if ($type eq '_vhosts') { $dir = '/var/lib/bind/vhost'; } elsif ($type eq '_custom') { $dir = '/var/lib/bind/custom'; } else { # Unknown types: default to custom; keep predictable. $dir = '/var/lib/bind/custom'; } } return "$dir/db.$zone"; } # Minimal zonefile skeleton: valid SOA + NS, no service assumptions. sub write_minimal_zonefile { my ($fh, $zone) = @_; my $fqdn = $zone . '.'; my $serial = strftime('%Y%m%d01', localtime()); print $fh <{zone}; my $type = $r->{type} // ''; my $zf = zonefile_for($zone, $type); if ($force_zf || !-f $zf) { print "Creating minimal zone file for $zone at $zf\n"; # Ensure directory exists (best-effort) if ($zf =~ m{^(.+)/db\..+$}) { my $dir = $1; if (!-d $dir) { mkdir $dir or die "Cannot mkdir $dir: $!\n"; } } open(my $fh, '>', $zf) or die "Can't create zone file $zf: $!\n"; write_minimal_zonefile($fh, $zone); close $fh; # Ownership/permissions: match bind patterns as best we can without assuming install(1). # If bind user/group doesn't exist in some environments, leave as root-owned. system('/usr/bin/chown', 'bind:bind', $zf); chmod 0644, $zf; $zf_changes++; } } # Render unified include content my $content = ""; $content .= "#\n"; $content .= "# Autogenerated. DO NOT EDIT.\n"; $content .= "# Generated by sync-unified-master from control-plane data.\n"; $content .= "#\n\n"; for my $r (@zones) { my $zone = $r->{zone}; my $type = $r->{type} // ''; my $zf = zonefile_for($zone, $type); # Always enable DDNS updates via the default key, plus any additional keys recorded in JSON. my @keys = ($default_ddns_key); if (defined($r->{payload}) && ref($r->{payload}) eq 'HASH') { my $ak = $r->{payload}->{ddns_keys}; if (defined $ak) { if (ref($ak) eq 'ARRAY') { push @keys, grep { defined($_) && $_ ne '' } @$ak; } elsif (!ref($ak) && $ak ne '') { push @keys, $ak; } } } # Deduplicate while preserving order. my %seen; @keys = grep { !$seen{$_}++ } @keys; my $allow_transfer = 'allow-transfer { trusted; ' . join(' ', map { 'key "' . $_ . '";' } @keys) . ' };'; my $update_policy = 'update-policy { ' . join(' ', map { 'grant ' . $_ . ' zonesub ANY;' } @keys) . ' };'; my $journal = 'journal "' . $zf . '.jnl";'; my $serial_update = 'serial-update-method unixtime;'; my $dnssec = 0; if (defined($r->{payload}) && ref($r->{payload}) eq 'HASH' && $r->{payload}->{dnssec}) { $dnssec = 1; } my $dnssec_opts = $dnssec ? ' auto-dnssec maintain; inline-signing yes;' : ''; $content .= sprintf( "zone \"%s\" { type master; file \"%s\";%s %s %s %s %s };\n", $zone, $zf, $dnssec_opts, $allow_transfer, $update_policy, $journal, $serial_update ); } # Decide whether unified include changed my $out_changed = 1; if (!$force_out && -e $out) { open my $fh, '<', $out or die "Cannot read $out: $!\n"; local $/; my $existing = <$fh>; close $fh; if (defined $existing && $existing eq $content) { $out_changed = 0; } } if ($out_changed) { my ($tmpfh, $tmpname) = tempfile('unified.zones.9.XXXXXX', DIR => '/tmp', UNLINK => 0); print $tmpfh $content; close $tmpfh; # Preserve mode if possible my $mode = 0644; if (-e $out) { my @st = stat($out); $mode = $st[2] & 07777 if @st; } chmod $mode, $tmpname; # Try to preserve bind ownership on the include system('/usr/bin/chown', 'bind:bind', $tmpname); rename($tmpname, $out) or die "Failed to install $out: $!\n"; print "Wrote $out (" . scalar(@zones) . " zones).\n"; } else { print "No changes to $out.\n"; } # Reload only if something changed (zonefile creation or include change) if ($zf_changes || $out_changed) { if ($reconfig) { system($reload_cmd, 'reconfig') == 0 or die "rndc reconfig failed: $?\n"; } else { system($reload_cmd, 'reload') == 0 or die "rndc reload failed: $?\n"; } print "Reloaded named.\n"; } exit 0;