From 27ba76a1b897ac7746b3e846945c8050a9c9bd44 Mon Sep 17 00:00:00 2001 From: Jorj Bauer Date: Sat, 24 Jan 2026 14:33:31 -0500 Subject: [PATCH] move config data to the control plane --- DDNS.pm | 247 ++++++++++++++------------- Makefile.PL | 2 + bin/add-custom | 74 ++++---- bin/add-slave | 74 ++++---- bin/add-vhost | 80 +++++---- bin/sync-unified-master | 361 ++++++++++++++++++++++++++++------------ 6 files changed, 516 insertions(+), 322 deletions(-) diff --git a/DDNS.pm b/DDNS.pm index 4f57a82..46d1f64 100644 --- a/DDNS.pm +++ b/DDNS.pm @@ -4,32 +4,35 @@ use strict; use warnings; use File::Temp qw/tempfile/; use Memoize; -use JSON::PP qw/decode_json/; +use JSON::PP qw/decode_json encode_json/; memoize('_gethosts'); -our $VERSION = '0.5'; +our $VERSION = '0.6'; -# our $misterdns = '198.251.79.234'; -our $misterdns = '5.78.68.64'; -our $defaultmaster = '5.78.68.64'; # could be v4 and v6 like '198.251.79.234;2607:f1c0:86e:b66f:6b86:babb:c367:b0dc' +# Control plane update keyfile (zone-management-key) our $keyfile = '/etc/bind/zone-management.key'; sub new { my $me = shift; my %opts = @_; - + + # 'server' is where dig/axfr queries go for reading the control plane. + # On the hidden master, localhost is correct. On other hosts, you may + # pass 'server' => ''. + my $server = $opts{server} // 'localhost'; + return bless { - 'keyfile' => $keyfile, - %opts + 'keyfile' => $keyfile, + 'server' => $server, + %opts }, $me; } sub _validateTypeOrDie { my ($t) = @_; - die "Invalid type" - unless ($t =~ /^_(vhosts|pureslave|custom)$/); + unless ($t =~ /^_(vhosts|pureslave|custom|config)$/); } sub _fqdn { @@ -37,7 +40,6 @@ sub _fqdn { $type ||= '_vhosts'; _validateTypeOrDie($type); - return $dom . "$type.private.invalid."; } @@ -45,38 +47,33 @@ sub _validateOrDie { my ($dom) = @_; die "No domain provided" - unless $dom; + unless $dom; die "Invalid domain name (must end in a dot)" - unless ($dom =~ /^[a-zA-Z0-9\.\-\_]+\.$/); + unless ($dom =~ /^[a-zA-Z0-9\.\-\_]+\.$/); } sub _lookupOrDie { - my ($dom, $type) = @_; + my ($this, $dom, $type) = @_; _validateOrDie($dom); my $fqdn = _fqdn($dom, $type); - + my $answer; my $all = ''; my $fh; - open($fh, "dig +short -t txt \@${misterdns} $fqdn |") - || die "Can't open dig: $!"; + open($fh, "dig +short -t txt \@$this->{server} $fqdn |") + || die "Can't open dig: $!"; while (<$fh>) { - $all .= $_; - if ($_ =~ /^\"(.+)\"$/) { - $answer = $1; - } + $all .= $_; + if ($_ =~ /^\"(.+)\"$/) { + $answer = $1; + } } if ($answer) { - # We found a record; that's all I care about. - return 1; + return 1; + } else { + die "query failed: $all\n"; } - else { - die "query failed: $all\n"; - } - - # Unreached - die "Failed to find existing DNS record for $fqdn"; } sub __docmd { @@ -84,17 +81,17 @@ sub __docmd { my ($tmpfh, $filename) = tempfile(); close $tmpfh; - + my $fh; open($fh, "|nsupdate -k $this->{keyfile} > $filename") - || die "Can't open nsupdate: $!"; - + || die "Can't open nsupdate: $!"; + print $fh "server localhost\nzone private.invalid.\n$cmd\nshow\nsend\n"; close $fh; open($fh, $filename) || die "Can't re-open tmpfile $filename: $!"; while (<$fh>) { - print; + print; } close $fh; @@ -119,25 +116,21 @@ sub _txt_from_line { return $txt; } - sub _parse_txt_payload { my ($raw) = @_; - return (undef, undef) unless defined $raw; + return ('', undef) unless defined $raw; my $v = $raw; $v =~ s/^\s+|\s+$//g; - # Legacy format: plain string (single master or ';' separated list) my $payload; if ($v =~ /^[\[{]/) { my $obj; - eval { $obj = decode_json($v); 1 } or do { - # If JSON decoding fails, treat as legacy string. - $obj = undef; - }; + eval { $obj = decode_json($v); 1 } or $obj = undef; if (defined $obj) { $payload = $obj if ref($obj) eq 'HASH'; + # Normalize masters string from supported JSON shapes. if (ref($obj) eq 'HASH') { if (defined $obj->{masters}) { @@ -157,7 +150,6 @@ sub _parse_txt_payload { } } - # Normalize formatting for BIND masters blocks: no spaces, no trailing ';' $v = '' unless defined $v; $v =~ s/\s+//g; $v =~ s/;+$//; @@ -173,51 +165,37 @@ sub _quote_txt_rdata { return "\"$s\""; } - { - my ($s) = @_; - $s = '' unless defined $s; - $s =~ s/\\/\\\\/g; - $s =~ s/\"/\\\"/g; - return "\"$s\""; -} - - sub _gethosts { my ($this, $type) = @_; - unless (!defined($type)) { - _validateTypeOrDie($type); + unless (!defined($type) || $type eq '') { + _validateTypeOrDie($type); } my $fh; - open($fh, "dig -t AXFR \@${misterdns} private.invalid. |") - || die "Can't open dig: $!"; + open($fh, "dig -t AXFR \@$this->{server} private.invalid. |") + || die "Can't open dig: $!"; my @vh; - while (<$fh>) { - if ($type) { - if (/^(\S+)\.$type\.private\.invalid\.\s+\d+\s+IN\s+TXT\s+/) { - my $z = $1; - my ($m, $payload) = _parse_txt_payload(_txt_from_line($_)); - push (@vh, { zone => $z, type => $type, master => $m, payload => $payload }); - } - } else { - # Querying everything - if (/^(\S+)\.(\S+)\.private\.invalid\.\s+\d+\s+IN\s+TXT\s+/) { - my ($z, $t) = ($1, $2); - my ($m, $payload) = _parse_txt_payload(_txt_from_line($_)); - push (@vh, { zone => $z, type => $t, master => $m, payload => $payload }); - } - } + if ($type) { + if (/^(\S+)\.$type\.private\.invalid\.\s+\d+\s+IN\s+TXT\s+/) { + my $z = $1; + my ($m, $payload) = _parse_txt_payload(_txt_from_line($_)); + push(@vh, { zone => $z, type => $type, master => $m, payload => $payload }); + } + } else { + if (/^(\S+)\.(\S+)\.private\.invalid\.\s+\d+\s+IN\s+TXT\s+/) { + my ($z, $t) = ($1, $2); + my ($m, $payload) = _parse_txt_payload(_txt_from_line($_)); + push(@vh, { zone => $z, type => $t, master => $m, payload => $payload }); + } + } } - + return @vh; } -# Find the type of domin that $dom is. If we don't find it, return -# undef. (The domain $dom ends in a dot; the DNS info we find won't; -# hence the concat of the extra "." after the lc.) sub type { my ($this, $dom) = @_; @@ -225,55 +203,42 @@ sub type { my @vh = $this->get(); foreach my $i (@vh) { - if (lc($i->{zone})."." eq lc($dom)) { - return $i->{type} - ; - } + if (lc($i->{zone}) . "." eq lc($dom)) { + return $i->{type}; + } } - - # Didn't find it. return undef; } - -# Set (create or replace) an existing control-plane record. This is used by -# the add-* scripts when --edit is specified. -# It deletes the existing TXT RDATA for the fqdn and writes the new value. -sub set { - my ($this, $dom, $value, $type) = @_; - _validateOrDie($dom); - my $fqdn = _fqdn($dom, $type); - - # If it doesn't exist, fail explicitly; callers can decide whether to add. - my $existingtype = $this->type($dom); - die "Domain $dom does not exist; cannot edit" - unless $existingtype; - die "Domain $dom exists as type $existingtype; cannot edit as $type" - if (defined $type && $existingtype ne $type); - - $this->__docmd("update delete $fqdn TXT"); - $this->__docmd("update add $fqdn 60 TXT " . _quote_txt_rdata($value)); - $this->cleanup(); -} - sub add { - my ($this, $dom, $master, $type) = @_; + my ($this, $dom, $txt_payload, $type) = @_; _validateOrDie($dom); my $fqdn = _fqdn($dom, $type); if (my $existingtype = $this->type($dom)) { - die "Domain $dom already exists [of type $existingtype]"; + die "Domain $dom already exists [of type $existingtype]"; } - - # TXT RDATA must be quoted for JSON (and is safe for legacy strings too). - $this->__docmd("update add $fqdn 60 TXT " . _quote_txt_rdata($master)); + + $this->__docmd("update add $fqdn 60 TXT " . _quote_txt_rdata($txt_payload)); + $this->cleanup(); +} + +sub set { + my ($this, $dom, $txt_payload, $type) = @_; + _validateOrDie($dom); + + # Ensure it exists (for friendly errors) + _lookupOrDie($this, $dom, $type); + my $fqdn = _fqdn($dom, $type); + + $this->__docmd("update delete $fqdn TXT\nupdate add $fqdn 60 TXT " . _quote_txt_rdata($txt_payload)); $this->cleanup(); } sub del { my ($this, $dom, $type) = @_; - _lookupOrDie($dom, $type); + _lookupOrDie($this, $dom, $type); my $fqdn = _fqdn($dom, $type); $this->__docmd("update delete $fqdn TXT"); @@ -282,24 +247,20 @@ sub del { sub get { my ($this, $type) = @_; - return $this->_gethosts($type); } sub cleanup { my ($this) = @_; - # Merge the .jnl file in with the domain file system("rndc sync -clean private.invalid"); - # Rebuild and reload the master vhosts files system("/usr/local/bin/sync-unified-master"); } - sub is_dnssec { my ($this, $dom) = @_; _validateOrDie($dom); - $dom =~ s/^(.+)\.$/$1/; # remove trailing dot + $dom =~ s/^(.+)\.$/$1/; my @vh = $this->get(); foreach my $i (@vh) { @@ -311,11 +272,67 @@ sub is_dnssec { return 0; } -sub default_master { - my ($this, $type) = @_; +sub _default_config { + return { + default_master => '5.78.68.64', + vhost_default_web_ip => '190.92.153.173', + soa_mname => 'mister-dns.martnet.com.', + soa_rname => 'root.martnet.com.', + ns => [ 'ns1.martnet.com.', 'ns2.martnet.com.' ], + vhost_mx => [ + { priority => 5, host => '@' }, + { priority => 10, host => 'mx2.martnet.com.' }, + { priority => 15, host => 'mx3.martnet.com.' }, + ], + master_ddns_keyname => 'master-ddns-tsig', + master_ddns_keyfile => '/etc/bind/master-ddns-tsig.key', + }; +} - # This could return different masters for different types - return $defaultmaster; +sub get_config { + my ($this) = @_; + + # Stored at: global._config.private.invalid. + my @cfg = $this->get('_config'); + foreach my $r (@cfg) { + next unless lc($r->{zone}) eq 'global'; + my $p = $r->{payload}; + if (defined $p && ref($p) eq 'HASH') { + # Merge defaults for any missing keys + my $d = _default_config(); + for my $k (keys %$d) { + $p->{$k} = $d->{$k} unless exists $p->{$k}; + } + return $p; + } + } + return _default_config(); +} + +sub set_config { + my ($this, $cfg_hashref) = @_; + die "Config must be a hashref" unless (defined $cfg_hashref && ref($cfg_hashref) eq 'HASH'); + + # Always store canonical JSON + my $txt = encode_json($cfg_hashref); + + my $dom = "global."; + my $type = "_config"; + + # Upsert behavior: if record exists, set, else add. + my $existing; + eval { $existing = $this->type($dom); 1 }; + if ($existing) { + $this->set($dom, $txt, $type); + } else { + $this->add($dom, $txt, $type); + } +} + +sub default_master { + my ($this) = @_; + my $cfg = $this->get_config(); + return $cfg->{default_master}; } 1; diff --git a/Makefile.PL b/Makefile.PL index 7e648b5..95e6aae 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -22,6 +22,8 @@ WriteMakefile( 'bin/is-managed', 'bin/validate-master', 'bin/sync-unified-master', + 'bin/dump-config', + 'bin/update-config', ], 'AUTHOR' => 'Jorj Bauer ', ); diff --git a/bin/add-custom b/bin/add-custom index ee861c7..14c3a42 100755 --- a/bin/add-custom +++ b/bin/add-custom @@ -2,64 +2,68 @@ use strict; use warnings; - -use Getopt::Long qw/GetOptions/; -use JSON::PP (); -use Regexp::Common qw/net/; use Martnet::DDNS; +use Regexp::Common qw/net/; +use Getopt::Long qw/GetOptions/; +use JSON::PP qw/encode_json true false/; + +sub usage { + die "Usage: add-custom [--edit] [--enable-dnssec|--disable-dnssec] --master " + + ("") + + "[--ddns-keyname ...] \n"; +} my $master; my $edit = 0; -my @ddns_keys; -my $enable_dnssec = 0; +my $enable_dnssec = 0; my $disable_dnssec = 0; +my @ddns_keynames; +my $webserver_ip; GetOptions( - 'master|m=s' => \$master, - 'edit!' => \$edit, - 'ddns-keyname=s' => \@ddns_keys, - 'enable-dnssec!' => \$enable_dnssec, - 'disable-dnssec!' => \$disable_dnssec, -) or die "Usage: add-custom --master [--edit] [--ddns-keyname ]... [--enable-dnssec|--disable-dnssec] \n"; + 'master|m=s' => \$master, + 'edit' => \$edit, + 'enable-dnssec' => \$enable_dnssec, + 'disable-dnssec' => \$disable_dnssec, + 'ddns-keyname=s@' => \@ddns_keynames, +) or usage(); -my $zone = shift || die "No zone provided\n"; +usage() unless defined $master; -die "Zone must end in a dot\n" - unless ($zone =~ /^[a-zA-Z0-9\.\-\_]+\.$/); +die "Cannot specify both --enable-dnssec and --disable-dnssec\n" + if ($enable_dnssec && $disable_dnssec); -die "--master is required\n" - unless defined $master && $master ne ''; +my $host = shift || die "No zonename provided\n"; + +my $ddns = Martnet::DDNS->new(); + +die "Zonename must end in a dot\n" + unless ($host =~ /^[a-zA-Z0-9\.\-\_]+\.$/); my $regex = $RE{net}{IPv4} . '|' . $RE{net}{IPv6}; die "Master must be an IPv4 or IPv6 address\n" unless ($master =~ /^$regex$/); -die "Specify only one of --enable-dnssec or --disable-dnssec\n" - if ($enable_dnssec && $disable_dnssec); - -# Build canonical JSON payload; operators do not hand-author JSON. -my %payload = ( - master => $master, -); - -if (@ddns_keys) { - $payload{ddns_keys} = [ @ddns_keys ]; -} - +my $payload = { master => $master }; if ($enable_dnssec) { - $payload{dnssec} = JSON::PP::true; + $payload->{dnssec} = true; } elsif ($disable_dnssec) { - $payload{dnssec} = JSON::PP::false; + $payload->{dnssec} = false; } -my $json = JSON::PP->new->canonical(1)->encode(\%payload); +if (@ddns_keynames) { + # Preserve order but de-dupe + my %seen; + my @uniq = grep { !$seen{$_}++ } @ddns_keynames; + $payload->{ddns_keys} = \@uniq; +} -my $ddns = Martnet::DDNS->new(); +my $txt = encode_json($payload); if ($edit) { - $ddns->set($zone, $json, '_custom'); + $ddns->set($host, $txt, '_custom'); } else { - $ddns->add($zone, $json, '_custom'); + $ddns->add($host, $txt, '_custom'); } exit 0; diff --git a/bin/add-slave b/bin/add-slave index 294d027..66fc2c9 100755 --- a/bin/add-slave +++ b/bin/add-slave @@ -2,64 +2,68 @@ use strict; use warnings; - -use Getopt::Long qw/GetOptions/; -use JSON::PP (); -use Regexp::Common qw/net/; use Martnet::DDNS; +use Regexp::Common qw/net/; +use Getopt::Long qw/GetOptions/; +use JSON::PP qw/encode_json true false/; + +sub usage { + die "Usage: add-slave [--edit] [--enable-dnssec|--disable-dnssec] --master " + + ("") + + "[--ddns-keyname ...] \n"; +} my $master; my $edit = 0; -my @ddns_keys; -my $enable_dnssec = 0; +my $enable_dnssec = 0; my $disable_dnssec = 0; +my @ddns_keynames; +my $webserver_ip; GetOptions( - 'master|m=s' => \$master, - 'edit!' => \$edit, - 'ddns-keyname=s' => \@ddns_keys, - 'enable-dnssec!' => \$enable_dnssec, - 'disable-dnssec!' => \$disable_dnssec, -) or die "Usage: add-slave --master [--edit] [--ddns-keyname ]... [--enable-dnssec|--disable-dnssec] \n"; + 'master|m=s' => \$master, + 'edit' => \$edit, + 'enable-dnssec' => \$enable_dnssec, + 'disable-dnssec' => \$disable_dnssec, + 'ddns-keyname=s@' => \@ddns_keynames, +) or usage(); -my $zone = shift || die "No zone provided\n"; +usage() unless defined $master; -die "Zone must end in a dot\n" - unless ($zone =~ /^[a-zA-Z0-9\.\-\_]+\.$/); +die "Cannot specify both --enable-dnssec and --disable-dnssec\n" + if ($enable_dnssec && $disable_dnssec); -die "--master is required\n" - unless defined $master && $master ne ''; +my $host = shift || die "No zonename provided\n"; + +my $ddns = Martnet::DDNS->new(); + +die "Zonename must end in a dot\n" + unless ($host =~ /^[a-zA-Z0-9\.\-\_]+\.$/); my $regex = $RE{net}{IPv4} . '|' . $RE{net}{IPv6}; die "Master must be an IPv4 or IPv6 address\n" unless ($master =~ /^$regex$/); -die "Specify only one of --enable-dnssec or --disable-dnssec\n" - if ($enable_dnssec && $disable_dnssec); - -# Build canonical JSON payload; operators do not hand-author JSON. -my %payload = ( - master => $master, -); - -if (@ddns_keys) { - $payload{ddns_keys} = [ @ddns_keys ]; -} - +my $payload = { master => $master }; if ($enable_dnssec) { - $payload{dnssec} = JSON::PP::true; + $payload->{dnssec} = true; } elsif ($disable_dnssec) { - $payload{dnssec} = JSON::PP::false; + $payload->{dnssec} = false; } -my $json = JSON::PP->new->canonical(1)->encode(\%payload); +if (@ddns_keynames) { + # Preserve order but de-dupe + my %seen; + my @uniq = grep { !$seen{$_}++ } @ddns_keynames; + $payload->{ddns_keys} = \@uniq; +} -my $ddns = Martnet::DDNS->new(); +my $txt = encode_json($payload); if ($edit) { - $ddns->set($zone, $json, '_pureslave'); + $ddns->set($host, $txt, '_pureslave'); } else { - $ddns->add($zone, $json, '_pureslave'); + $ddns->add($host, $txt, '_pureslave'); } exit 0; diff --git a/bin/add-vhost b/bin/add-vhost index 0b62d19..71bf922 100755 --- a/bin/add-vhost +++ b/bin/add-vhost @@ -2,64 +2,78 @@ use strict; use warnings; - -use Getopt::Long qw/GetOptions/; -use JSON::PP (); -use Regexp::Common qw/net/; use Martnet::DDNS; +use Regexp::Common qw/net/; +use Getopt::Long qw/GetOptions/; +use JSON::PP qw/encode_json true false/; + +sub usage { + die "Usage: add-vhost [--edit] [--enable-dnssec|--disable-dnssec] --master " + + ("[--webserver-ip ] ") + + "[--ddns-keyname ...] \n"; +} my $master; my $edit = 0; -my @ddns_keys; -my $enable_dnssec = 0; +my $enable_dnssec = 0; my $disable_dnssec = 0; +my @ddns_keynames; +my $webserver_ip; GetOptions( - 'master|m=s' => \$master, - 'edit!' => \$edit, - 'ddns-keyname=s' => \@ddns_keys, - 'enable-dnssec!' => \$enable_dnssec, - 'disable-dnssec!' => \$disable_dnssec, -) or die "Usage: add-vhost --master [--edit] [--ddns-keyname ]... [--enable-dnssec|--disable-dnssec] \n"; + 'master|m=s' => \$master, + 'edit' => \$edit, + 'enable-dnssec' => \$enable_dnssec, + 'disable-dnssec' => \$disable_dnssec, + 'ddns-keyname=s@' => \@ddns_keynames, + 'webserver-ip=s' => \$webserver_ip, +) or usage(); -my $zone = shift || die "No zone provided\n"; +usage() unless defined $master; -die "Zone must end in a dot\n" - unless ($zone =~ /^[a-zA-Z0-9\.\-\_]+\.$/); +die "Cannot specify both --enable-dnssec and --disable-dnssec\n" + if ($enable_dnssec && $disable_dnssec); -die "--master is required\n" - unless defined $master && $master ne ''; +my $host = shift || die "No zonename provided\n"; + +my $ddns = Martnet::DDNS->new(); + +die "Zonename must end in a dot\n" + unless ($host =~ /^[a-zA-Z0-9\.\-\_]+\.$/); my $regex = $RE{net}{IPv4} . '|' . $RE{net}{IPv6}; die "Master must be an IPv4 or IPv6 address\n" unless ($master =~ /^$regex$/); -die "Specify only one of --enable-dnssec or --disable-dnssec\n" - if ($enable_dnssec && $disable_dnssec); - -# Build canonical JSON payload; operators do not hand-author JSON. -my %payload = ( - master => $master, -); - -if (@ddns_keys) { - $payload{ddns_keys} = [ @ddns_keys ]; +if (defined $webserver_ip) { + die "Webserver IP must be an IPv4 or IPv6 address\n" + unless ($webserver_ip =~ /^$regex$/); } +my $payload = { master => $master }; if ($enable_dnssec) { - $payload{dnssec} = JSON::PP::true; + $payload->{dnssec} = true; } elsif ($disable_dnssec) { - $payload{dnssec} = JSON::PP::false; + $payload->{dnssec} = false; } -my $json = JSON::PP->new->canonical(1)->encode(\%payload); +if (@ddns_keynames) { + # Preserve order but de-dupe + my %seen; + my @uniq = grep { !$seen{$_}++ } @ddns_keynames; + $payload->{ddns_keys} = \@uniq; +} -my $ddns = Martnet::DDNS->new(); +if (defined $webserver_ip) { + $payload->{webserver_ip} = $webserver_ip; +} + +my $txt = encode_json($payload); if ($edit) { - $ddns->set($zone, $json, '_vhosts'); + $ddns->set($host, $txt, '_vhosts'); } else { - $ddns->add($zone, $json, '_vhosts'); + $ddns->add($host, $txt, '_vhosts'); } exit 0; diff --git a/bin/sync-unified-master b/bin/sync-unified-master index 3da8a13..3cd58a2 100755 --- a/bin/sync-unified-master +++ b/bin/sync-unified-master @@ -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 <{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;