From 9e090c2a9fcb2bfa563f2173bec6e83c3698af17 Mon Sep 17 00:00:00 2001 From: Jorj Bauer Date: Sat, 24 Jan 2026 12:41:41 -0500 Subject: [PATCH] build new unified sync mechanism --- Makefile.PL | 4 +- bin/sync-master-vhosts | 3 - bin/sync-unified-master | 217 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 bin/sync-unified-master diff --git a/Makefile.PL b/Makefile.PL index bb5dfba..7e648b5 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -20,6 +20,8 @@ WriteMakefile( 'bin/sync-slave', 'bin/list-all', 'bin/is-managed', - 'bin/validate-master', ], + 'bin/validate-master', + 'bin/sync-unified-master', + ], 'AUTHOR' => 'Jorj Bauer ', ); diff --git a/bin/sync-master-vhosts b/bin/sync-master-vhosts index a0746ad..b763034 100755 --- a/bin/sync-master-vhosts +++ b/bin/sync-master-vhosts @@ -93,11 +93,8 @@ $zone IN SOA mister-dns.martnet.com. root.martnet.com. ( 43200 ) ; Negative Cache TTL 12 hours ; define name servers -$zone IN NS ns.martnet.com. $zone IN NS ns1.martnet.com. $zone IN NS ns2.martnet.com. -$zone IN NS ns3.martnet.com. -$zone IN NS ns4.martnet.com. ; define localhost localhost IN A 127.0.0.1 diff --git a/bin/sync-unified-master b/bin/sync-unified-master new file mode 100644 index 0000000..65985bb --- /dev/null +++ b/bin/sync-unified-master @@ -0,0 +1,217 @@ +#!/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 /var/lib/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 = '/var/lib/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) + +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); + + # Stable, single-line formatting + $content .= sprintf( + "zone \"%s\" { type master; file \"%s\"; };\n", + $zone, $zf + ); +} + +# 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;