Files
martnet-ddns/bin/sync-unified-master
2026-01-24 13:45:40 -05:00

247 lines
7.4 KiB
Perl
Executable File

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