move config data to the control plane

This commit is contained in:
2026-01-24 14:33:31 -05:00
parent 34e0e2843d
commit 27ba76a1b8
6 changed files with 516 additions and 322 deletions

View File

@@ -11,35 +11,44 @@ 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
# - Creates missing zonefiles for any emitted zones
# - _vhosts: full vhost template using config defaults (and optional per-zone override)
# - other: minimal valid skeleton
# - Emits DDNS policy for ALL zones:
# update-policy grants master-ddns-tsig zonesub ANY
# plus additional ddns_keys[] from per-zone JSON payload
# - Optionally reconciles _vhost A records via DDNS (--reconcile-all)
#
# 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).
# Control plane (private.invalid) remains static and is NOT generated here.
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';
my $out = '/etc/bind/unified.zones.9';
my $self_master = undef; # if unset, uses $ddns->default_master() (from _config)
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 = 0; # default to reload (safer; reconfig requires includes to be perfect)
my $reconcile_all = 0;
my $verbose = 0;
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";
'out=s' => \$out,
'self=s' => \$self_master,
'force-zonefiles!' => \$force_zf,
'force!' => \$force_out,
'reload-cmd=s' => \$reload_cmd,
'reconfig!' => \$reconfig,
'reconcile-all!' => \$reconcile_all,
'verbose|v!' => \$verbose,
) or die "Usage: $0 [--out PATH] [--self IP] [--force-zonefiles] [--force] [--reconcile-all] [-v|--verbose] [--reload-cmd /path/to/rndc] [--reconfig]\n";
my $ddns = Martnet::DDNS->new();
my $cfg = $ddns->get_config();
$self_master = $ddns->default_master() unless defined $self_master;
my $default_ddns_keyname = $cfg->{master_ddns_keyname} // 'master-ddns-tsig';
my $ddns_keyfile = $cfg->{master_ddns_keyfile} // '/etc/bind/master-ddns-tsig.key';
# Pull all control-plane records and select the zones this hidden master should serve as MASTER.
my @all = $ddns->get();
@@ -47,33 +56,29 @@ my @zones;
for my $r (@all) {
next unless defined $r->{zone} && $r->{zone} ne '';
# Core control plane zone should remain static, not autogenerated.
# Skip the control plane itself
next if $r->{zone} eq 'private.invalid';
# Skip explicitly slave-only records.
# 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.
# Skip config records (not BIND zones)
next if defined($r->{type}) && $r->{type} eq '_config';
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
@@ -84,7 +89,6 @@ sub zonefile_for {
} elsif ($type eq '_custom') {
$dir = '/var/lib/bind/custom';
} else {
# Unknown types: default to custom; keep predictable.
$dir = '/var/lib/bind/custom';
}
}
@@ -92,34 +96,131 @@ sub zonefile_for {
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
sub ensure_dir {
my ($file) = @_;
if ($file =~ m{^(.+)/[^/]+$}) {
my $dir = $1;
if (!-d $dir) {
mkdir $dir or die "Cannot mkdir $dir: $!\n";
}
}
}
# Ensure missing zonefiles exist (or replace if forced).
# This is intentionally conservative: it creates only if absent unless --force-zonefiles.
sub write_minimal_zonefile {
my ($fh, $zone, $cfg) = @_;
my $fqdn = $zone . '.';
my $serial = strftime('%Y%m%d01', localtime());
my $soa_mname = $cfg->{soa_mname} // 'mister-dns.martnet.com.';
my $soa_rname = $cfg->{soa_rname} // 'root.martnet.com.';
my @ns = ('ns1.martnet.com.', 'ns2.martnet.com.');
if (ref($cfg->{ns}) eq 'ARRAY' && @{$cfg->{ns}}) {
@ns = @{$cfg->{ns}};
}
print $fh ";\n";
print $fh "; Autogenerated minimal zone file. Do not edit by hand.\n";
print $fh ";\n";
print $fh "\$TTL 43200\n";
print $fh "$fqdn\tIN\tSOA\t$soa_mname $soa_rname (\n";
print $fh "\t\t$serial\t; Serial\n";
print $fh "\t\t43200\t; Refresh 12h\n";
print $fh "\t\t3600\t; Retry 1h\n";
print $fh "\t\t604800\t; Expire 7d\n";
print $fh "\t\t43200 )\t; Negative cache TTL 12h\n\n";
print $fh "; authoritative name servers\n";
for my $n (@ns) {
print $fh "$fqdn\tIN\tNS\t$n\n";
}
print $fh "\n";
}
sub write_vhost_zonefile {
my ($fh, $zone, $webip, $cfg) = @_;
my $fqdn = $zone . '.';
my $serial = strftime('%Y%m%d01', localtime());
my $soa_mname = $cfg->{soa_mname} // 'mister-dns.martnet.com.';
my $soa_rname = $cfg->{soa_rname} // 'root.martnet.com.';
my @ns = ('ns1.martnet.com.', 'ns2.martnet.com.');
if (ref($cfg->{ns}) eq 'ARRAY' && @{$cfg->{ns}}) {
@ns = @{$cfg->{ns}};
}
my @mx = (
{ priority => 5, host => '@' },
{ priority => 10, host => 'mx2.martnet.com.' },
{ priority => 15, host => 'mx3.martnet.com.' },
);
if (ref($cfg->{vhost_mx}) eq 'ARRAY' && @{$cfg->{vhost_mx}}) {
@mx = @{$cfg->{vhost_mx}};
}
print $fh ";\n";
print $fh "; This is an automatically-generated file. Do not edit by hand; it will be overwritten.\n";
print $fh ";\n";
print $fh "\$TTL\t43200\n";
print $fh "$fqdn\tIN\tSOA\t$soa_mname $soa_rname (\n";
print $fh " $serial ; Serial\n";
print $fh "\t43200 ; Refresh every 12 hours\n";
print $fh "\t3600 ; Retry every hour\n";
print $fh "\t604800 ; Expire after a week\n";
print $fh "\t43200 ) ; Negative Cache TTL 12 hours\n\n";
print $fh "; define name servers\n";
for my $n (@ns) {
print $fh "$fqdn\tIN\tNS\t$n\n";
}
print $fh "\n";
print $fh "; define localhost\n";
print $fh "localhost\tIN\tA\t127.0.0.1\n\n";
print $fh "; define machine names\n";
print $fh "$fqdn\tIN\tA\t$webip\n";
for my $mx (@mx) {
my $prio = $mx->{priority};
my $host = $mx->{host};
$host = $fqdn if ($host eq '@');
print $fh "$fqdn\tIN\tMX\t$prio\t$host\n";
}
print $fh "\n";
print $fh "*.$fqdn\tIN\tA\t$webip\n";
for my $mx (@mx) {
my $prio = $mx->{priority};
my $host = $mx->{host};
$host = $fqdn if ($host eq '@');
print $fh "*.$fqdn\tIN\tMX\t$prio\t$host\n";
}
print $fh "\n";
}
sub uniq_keys {
my (@k) = @_;
my %seen;
return grep { defined($_) && $_ ne '' && !$seen{$_}++ } @k;
}
sub zone_extra_ddns_keys {
my ($r) = @_;
my $p = $r->{payload};
return () unless (defined $p && ref($p) eq 'HASH');
return () unless (defined $p->{ddns_keys});
if (ref($p->{ddns_keys}) eq 'ARRAY') {
return @{$p->{ddns_keys}};
}
# allow string fallback
return ($p->{ddns_keys});
}
# Ensure zonefiles exist; create minimal or vhost templates as appropriate.
my $zf_changes = 0;
for my $r (@zones) {
my $zone = $r->{zone};
@@ -127,22 +228,23 @@ for my $r (@zones) {
my $zf = zonefile_for($zone, $type);
if ($force_zf || !-f $zf) {
print "Creating minimal zone file for $zone at $zf\n";
print "Creating zone file for $zone at $zf\n" if $verbose;
# Ensure directory exists (best-effort)
if ($zf =~ m{^(.+)/db\..+$}) {
my $dir = $1;
if (!-d $dir) {
mkdir $dir or die "Cannot mkdir $dir: $!\n";
ensure_dir($zf);
open(my $fh, '>', $zf) or die "Can't create zone file $zf: $!\n";
if ($type eq '_vhosts') {
my $webip = $cfg->{vhost_default_web_ip} // '190.92.153.173';
my $p = $r->{payload};
if (defined $p && ref($p) eq 'HASH' && defined $p->{webserver_ip} && $p->{webserver_ip} ne '') {
$webip = $p->{webserver_ip};
}
write_vhost_zonefile($fh, $zone, $webip, $cfg);
} else {
write_minimal_zonefile($fh, $zone, $cfg);
}
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;
@@ -150,7 +252,76 @@ for my $r (@zones) {
}
}
# Render unified include content
# Optional reconcile for vhost A records via DDNS
sub dig_a {
my ($name) = @_;
my $ans = '';
my $cmd = "dig +short -t A \@127.0.0.1 $name";
open(my $fh, "$cmd |") or die "Can't run dig: $!";
while (<$fh>) {
chomp;
next unless $_;
$ans = $_;
last;
}
close $fh;
return $ans;
}
sub nsupdate_vhost_ip {
my ($zone, $ip, $keyfile) = @_;
my ($tmpfh, $tmpname) = tempfile('nsupdate.XXXXXX', DIR => '/tmp', UNLINK => 0);
print $tmpfh "server 127.0.0.1\n";
print $tmpfh "zone $zone.\n";
# Replace apex A
print $tmpfh "update delete $zone. A\n";
print $tmpfh "update add $zone. 60 A $ip\n";
# Replace wildcard A
print $tmpfh "update delete *.$zone. A\n";
print $tmpfh "update add *.$zone. 60 A $ip\n";
print $tmpfh "send\n";
close $tmpfh;
system("nsupdate -k $keyfile $tmpname") == 0
or die "nsupdate failed for $zone: $?\n";
unlink $tmpname;
}
my $reconcile_changes = 0;
if ($reconcile_all) {
my $total = scalar(@zones);
my $idx = 0;
for my $r (@zones) {
$idx++;
next unless (($r->{type} // '') eq '_vhosts');
my $zone = $r->{zone};
my $p = $r->{payload};
my $desired = $cfg->{vhost_default_web_ip} // '190.92.153.173';
if (defined $p && ref($p) eq 'HASH' && defined $p->{webserver_ip} && $p->{webserver_ip} ne '') {
$desired = $p->{webserver_ip};
}
print "Reconcile [$idx/$total] $zone -> $desired\n" if $verbose;
my $apex = dig_a("$zone.");
my $wild = dig_a("*.$zone.");
# If either is missing or wrong, reconcile both.
if (($apex // '') ne $desired || ($wild // '') ne $desired) {
print " Updating A records (apex='$apex' wildcard='$wild')\n" if $verbose;
nsupdate_vhost_ip($zone, $desired, $ddns_keyfile);
$reconcile_changes++;
} else {
print " OK\n" if $verbose;
}
}
}
# Render unified include content (with DDNS policy)
my $content = "";
$content .= "#\n";
$content .= "# Autogenerated. DO NOT EDIT.\n";
@@ -162,38 +333,23 @@ for my $r (@zones) {
my $type = $r->{type} // '';
my $zf = zonefile_for($zone, $type);
my @keys = uniq_keys($default_ddns_keyname, zone_extra_ddns_keys($r));
# 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;
}
my $allow_transfer = 'allow-transfer { trusted;';
for my $k (@keys) {
$allow_transfer .= " key \"$k\";";
}
}
# Deduplicate while preserving order.
my %seen;
@keys = grep { !$seen{$_}++ } @keys;
$allow_transfer .= ' };';
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 $update_policy = 'update-policy {';
for my $k (@keys) {
$update_policy .= " grant $k zonesub ANY;";
}
$update_policy .= ' };';
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;' : '';
my $journal = "journal \"$zf.jnl\";";
$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
);
$content .= "zone \"$zone\" { type master; file \"$zf\"; $allow_transfer $update_policy $journal };\n";
}
# Decide whether unified include changed
@@ -214,7 +370,6 @@ if ($out_changed) {
print $tmpfh $content;
close $tmpfh;
# Preserve mode if possible
my $mode = 0644;
if (-e $out) {
my @st = stat($out);
@@ -222,17 +377,15 @@ if ($out_changed) {
}
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";
print "Wrote $out (" . scalar(@zones) . " zones).\n" if $verbose;
} else {
print "No changes to $out.\n";
print "No changes to $out.\n" if $verbose;
}
# Reload only if something changed (zonefile creation or include change)
if ($zf_changes || $out_changed) {
# Reload only if something changed
if ($zf_changes || $out_changed || $reconcile_changes) {
if ($reconfig) {
system($reload_cmd, 'reconfig') == 0
or die "rndc reconfig failed: $?\n";
@@ -240,7 +393,7 @@ if ($zf_changes || $out_changed) {
system($reload_cmd, 'reload') == 0
or die "rndc reload failed: $?\n";
}
print "Reloaded named.\n";
print "Reloaded named.\n" if $verbose;
}
exit 0;