build new unified sync mechanism

This commit is contained in:
2026-01-24 12:41:41 -05:00
parent 006a7004b7
commit 9e090c2a9f
3 changed files with 220 additions and 4 deletions

217
bin/sync-unified-master Normal file
View File

@@ -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 <<EOF;
;
; Autogenerated minimal zone file. Do not edit by hand.
;
\$TTL 43200
$fqdn IN SOA mister-dns.martnet.com. root.martnet.com. (
$serial ; Serial
43200 ; Refresh 12h
3600 ; Retry 1h
604800 ; Expire 7d
43200 ) ; Negative cache TTL 12h
; authoritative name servers
$fqdn IN NS ns1.martnet.com.
$fqdn IN NS ns2.martnet.com.
EOF
}
# Ensure missing zonefiles exist (or replace if forced).
# This is intentionally conservative: it creates only if absent unless --force-zonefiles.
my $zf_changes = 0;
for my $r (@zones) {
my $zone = $r->{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;