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

247
DDNS.pm
View File

@@ -4,32 +4,35 @@ use strict;
use warnings; use warnings;
use File::Temp qw/tempfile/; use File::Temp qw/tempfile/;
use Memoize; use Memoize;
use JSON::PP qw/decode_json/; use JSON::PP qw/decode_json encode_json/;
memoize('_gethosts'); memoize('_gethosts');
our $VERSION = '0.5'; our $VERSION = '0.6';
# our $misterdns = '198.251.79.234'; # Control plane update keyfile (zone-management-key)
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'
our $keyfile = '/etc/bind/zone-management.key'; our $keyfile = '/etc/bind/zone-management.key';
sub new { sub new {
my $me = shift; my $me = shift;
my %opts = @_; 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' => '<master-ip>'.
my $server = $opts{server} // 'localhost';
return bless { return bless {
'keyfile' => $keyfile, 'keyfile' => $keyfile,
%opts 'server' => $server,
%opts
}, $me; }, $me;
} }
sub _validateTypeOrDie { sub _validateTypeOrDie {
my ($t) = @_; my ($t) = @_;
die "Invalid type" die "Invalid type"
unless ($t =~ /^_(vhosts|pureslave|custom)$/); unless ($t =~ /^_(vhosts|pureslave|custom|config)$/);
} }
sub _fqdn { sub _fqdn {
@@ -37,7 +40,6 @@ sub _fqdn {
$type ||= '_vhosts'; $type ||= '_vhosts';
_validateTypeOrDie($type); _validateTypeOrDie($type);
return $dom . "$type.private.invalid."; return $dom . "$type.private.invalid.";
} }
@@ -45,38 +47,33 @@ sub _validateOrDie {
my ($dom) = @_; my ($dom) = @_;
die "No domain provided" die "No domain provided"
unless $dom; unless $dom;
die "Invalid domain name (must end in a dot)" die "Invalid domain name (must end in a dot)"
unless ($dom =~ /^[a-zA-Z0-9\.\-\_]+\.$/); unless ($dom =~ /^[a-zA-Z0-9\.\-\_]+\.$/);
} }
sub _lookupOrDie { sub _lookupOrDie {
my ($dom, $type) = @_; my ($this, $dom, $type) = @_;
_validateOrDie($dom); _validateOrDie($dom);
my $fqdn = _fqdn($dom, $type); my $fqdn = _fqdn($dom, $type);
my $answer; my $answer;
my $all = ''; my $all = '';
my $fh; my $fh;
open($fh, "dig +short -t txt \@${misterdns} $fqdn |") open($fh, "dig +short -t txt \@$this->{server} $fqdn |")
|| die "Can't open dig: $!"; || die "Can't open dig: $!";
while (<$fh>) { while (<$fh>) {
$all .= $_; $all .= $_;
if ($_ =~ /^\"(.+)\"$/) { if ($_ =~ /^\"(.+)\"$/) {
$answer = $1; $answer = $1;
} }
} }
if ($answer) { 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 { sub __docmd {
@@ -84,17 +81,17 @@ sub __docmd {
my ($tmpfh, $filename) = tempfile(); my ($tmpfh, $filename) = tempfile();
close $tmpfh; close $tmpfh;
my $fh; my $fh;
open($fh, "|nsupdate -k $this->{keyfile} > $filename") 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"; print $fh "server localhost\nzone private.invalid.\n$cmd\nshow\nsend\n";
close $fh; close $fh;
open($fh, $filename) || die "Can't re-open tmpfile $filename: $!"; open($fh, $filename) || die "Can't re-open tmpfile $filename: $!";
while (<$fh>) { while (<$fh>) {
print; print;
} }
close $fh; close $fh;
@@ -119,25 +116,21 @@ sub _txt_from_line {
return $txt; return $txt;
} }
sub _parse_txt_payload { sub _parse_txt_payload {
my ($raw) = @_; my ($raw) = @_;
return (undef, undef) unless defined $raw; return ('', undef) unless defined $raw;
my $v = $raw; my $v = $raw;
$v =~ s/^\s+|\s+$//g; $v =~ s/^\s+|\s+$//g;
# Legacy format: plain string (single master or ';' separated list)
my $payload; my $payload;
if ($v =~ /^[\[{]/) { if ($v =~ /^[\[{]/) {
my $obj; my $obj;
eval { $obj = decode_json($v); 1 } or do { eval { $obj = decode_json($v); 1 } or $obj = undef;
# If JSON decoding fails, treat as legacy string.
$obj = undef;
};
if (defined $obj) { if (defined $obj) {
$payload = $obj if ref($obj) eq 'HASH'; $payload = $obj if ref($obj) eq 'HASH';
# Normalize masters string from supported JSON shapes. # Normalize masters string from supported JSON shapes.
if (ref($obj) eq 'HASH') { if (ref($obj) eq 'HASH') {
if (defined $obj->{masters}) { 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 = '' unless defined $v;
$v =~ s/\s+//g; $v =~ s/\s+//g;
$v =~ s/;+$//; $v =~ s/;+$//;
@@ -173,51 +165,37 @@ sub _quote_txt_rdata {
return "\"$s\""; return "\"$s\"";
} }
{
my ($s) = @_;
$s = '' unless defined $s;
$s =~ s/\\/\\\\/g;
$s =~ s/\"/\\\"/g;
return "\"$s\"";
}
sub _gethosts { sub _gethosts {
my ($this, $type) = @_; my ($this, $type) = @_;
unless (!defined($type)) { unless (!defined($type) || $type eq '') {
_validateTypeOrDie($type); _validateTypeOrDie($type);
} }
my $fh; my $fh;
open($fh, "dig -t AXFR \@${misterdns} private.invalid. |") open($fh, "dig -t AXFR \@$this->{server} private.invalid. |")
|| die "Can't open dig: $!"; || die "Can't open dig: $!";
my @vh; my @vh;
while (<$fh>) { while (<$fh>) {
if ($type) { if ($type) {
if (/^(\S+)\.$type\.private\.invalid\.\s+\d+\s+IN\s+TXT\s+/) { if (/^(\S+)\.$type\.private\.invalid\.\s+\d+\s+IN\s+TXT\s+/) {
my $z = $1; my $z = $1;
my ($m, $payload) = _parse_txt_payload(_txt_from_line($_)); my ($m, $payload) = _parse_txt_payload(_txt_from_line($_));
push (@vh, { zone => $z, type => $type, master => $m, payload => $payload }); push(@vh, { zone => $z, type => $type, master => $m, payload => $payload });
} }
} else { } else {
# Querying everything if (/^(\S+)\.(\S+)\.private\.invalid\.\s+\d+\s+IN\s+TXT\s+/) {
if (/^(\S+)\.(\S+)\.private\.invalid\.\s+\d+\s+IN\s+TXT\s+/) { my ($z, $t) = ($1, $2);
my ($z, $t) = ($1, $2); my ($m, $payload) = _parse_txt_payload(_txt_from_line($_));
my ($m, $payload) = _parse_txt_payload(_txt_from_line($_)); push(@vh, { zone => $z, type => $t, master => $m, payload => $payload });
push (@vh, { zone => $z, type => $t, master => $m, payload => $payload }); }
} }
}
} }
return @vh; 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 { sub type {
my ($this, $dom) = @_; my ($this, $dom) = @_;
@@ -225,55 +203,42 @@ sub type {
my @vh = $this->get(); my @vh = $this->get();
foreach my $i (@vh) { foreach my $i (@vh) {
if (lc($i->{zone})."." eq lc($dom)) { if (lc($i->{zone}) . "." eq lc($dom)) {
return $i->{type} return $i->{type};
; }
}
} }
# Didn't find it.
return undef; 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 { sub add {
my ($this, $dom, $master, $type) = @_; my ($this, $dom, $txt_payload, $type) = @_;
_validateOrDie($dom); _validateOrDie($dom);
my $fqdn = _fqdn($dom, $type); my $fqdn = _fqdn($dom, $type);
if (my $existingtype = $this->type($dom)) { 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($txt_payload));
$this->__docmd("update add $fqdn 60 TXT " . _quote_txt_rdata($master)); $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(); $this->cleanup();
} }
sub del { sub del {
my ($this, $dom, $type) = @_; my ($this, $dom, $type) = @_;
_lookupOrDie($dom, $type); _lookupOrDie($this, $dom, $type);
my $fqdn = _fqdn($dom, $type); my $fqdn = _fqdn($dom, $type);
$this->__docmd("update delete $fqdn TXT"); $this->__docmd("update delete $fqdn TXT");
@@ -282,24 +247,20 @@ sub del {
sub get { sub get {
my ($this, $type) = @_; my ($this, $type) = @_;
return $this->_gethosts($type); return $this->_gethosts($type);
} }
sub cleanup { sub cleanup {
my ($this) = @_; my ($this) = @_;
# Merge the .jnl file in with the domain file
system("rndc sync -clean private.invalid"); system("rndc sync -clean private.invalid");
# Rebuild and reload the master vhosts files
system("/usr/local/bin/sync-unified-master"); system("/usr/local/bin/sync-unified-master");
} }
sub is_dnssec { sub is_dnssec {
my ($this, $dom) = @_; my ($this, $dom) = @_;
_validateOrDie($dom); _validateOrDie($dom);
$dom =~ s/^(.+)\.$/$1/; # remove trailing dot $dom =~ s/^(.+)\.$/$1/;
my @vh = $this->get(); my @vh = $this->get();
foreach my $i (@vh) { foreach my $i (@vh) {
@@ -311,11 +272,67 @@ sub is_dnssec {
return 0; return 0;
} }
sub default_master { sub _default_config {
my ($this, $type) = @_; 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 sub get_config {
return $defaultmaster; 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; 1;

View File

@@ -22,6 +22,8 @@ WriteMakefile(
'bin/is-managed', 'bin/is-managed',
'bin/validate-master', 'bin/validate-master',
'bin/sync-unified-master', 'bin/sync-unified-master',
'bin/dump-config',
'bin/update-config',
], ],
'AUTHOR' => 'Jorj Bauer <jorj@jorj.org>', 'AUTHOR' => 'Jorj Bauer <jorj@jorj.org>',
); );

View File

@@ -2,64 +2,68 @@
use strict; use strict;
use warnings; use warnings;
use Getopt::Long qw/GetOptions/;
use JSON::PP ();
use Regexp::Common qw/net/;
use Martnet::DDNS; 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 <IPv4|IPv6> " +
("") +
"[--ddns-keyname <keyname> ...] <zone.>\n";
}
my $master; my $master;
my $edit = 0; my $edit = 0;
my @ddns_keys; my $enable_dnssec = 0;
my $enable_dnssec = 0;
my $disable_dnssec = 0; my $disable_dnssec = 0;
my @ddns_keynames;
my $webserver_ip;
GetOptions( GetOptions(
'master|m=s' => \$master, 'master|m=s' => \$master,
'edit!' => \$edit, 'edit' => \$edit,
'ddns-keyname=s' => \@ddns_keys, 'enable-dnssec' => \$enable_dnssec,
'enable-dnssec!' => \$enable_dnssec, 'disable-dnssec' => \$disable_dnssec,
'disable-dnssec!' => \$disable_dnssec, 'ddns-keyname=s@' => \@ddns_keynames,
) or die "Usage: add-custom --master <ip> [--edit] [--ddns-keyname <key>]... [--enable-dnssec|--disable-dnssec] <zone.>\n"; ) or usage();
my $zone = shift || die "No zone provided\n"; usage() unless defined $master;
die "Zone must end in a dot\n" die "Cannot specify both --enable-dnssec and --disable-dnssec\n"
unless ($zone =~ /^[a-zA-Z0-9\.\-\_]+\.$/); if ($enable_dnssec && $disable_dnssec);
die "--master is required\n" my $host = shift || die "No zonename provided\n";
unless defined $master && $master ne '';
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}; my $regex = $RE{net}{IPv4} . '|' . $RE{net}{IPv6};
die "Master must be an IPv4 or IPv6 address\n" die "Master must be an IPv4 or IPv6 address\n"
unless ($master =~ /^$regex$/); unless ($master =~ /^$regex$/);
die "Specify only one of --enable-dnssec or --disable-dnssec\n" my $payload = { master => $master };
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 ($enable_dnssec) { if ($enable_dnssec) {
$payload{dnssec} = JSON::PP::true; $payload->{dnssec} = true;
} elsif ($disable_dnssec) { } 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) { if ($edit) {
$ddns->set($zone, $json, '_custom'); $ddns->set($host, $txt, '_custom');
} else { } else {
$ddns->add($zone, $json, '_custom'); $ddns->add($host, $txt, '_custom');
} }
exit 0; exit 0;

View File

@@ -2,64 +2,68 @@
use strict; use strict;
use warnings; use warnings;
use Getopt::Long qw/GetOptions/;
use JSON::PP ();
use Regexp::Common qw/net/;
use Martnet::DDNS; 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 <IPv4|IPv6> " +
("") +
"[--ddns-keyname <keyname> ...] <zone.>\n";
}
my $master; my $master;
my $edit = 0; my $edit = 0;
my @ddns_keys; my $enable_dnssec = 0;
my $enable_dnssec = 0;
my $disable_dnssec = 0; my $disable_dnssec = 0;
my @ddns_keynames;
my $webserver_ip;
GetOptions( GetOptions(
'master|m=s' => \$master, 'master|m=s' => \$master,
'edit!' => \$edit, 'edit' => \$edit,
'ddns-keyname=s' => \@ddns_keys, 'enable-dnssec' => \$enable_dnssec,
'enable-dnssec!' => \$enable_dnssec, 'disable-dnssec' => \$disable_dnssec,
'disable-dnssec!' => \$disable_dnssec, 'ddns-keyname=s@' => \@ddns_keynames,
) or die "Usage: add-slave --master <ip> [--edit] [--ddns-keyname <key>]... [--enable-dnssec|--disable-dnssec] <zone.>\n"; ) or usage();
my $zone = shift || die "No zone provided\n"; usage() unless defined $master;
die "Zone must end in a dot\n" die "Cannot specify both --enable-dnssec and --disable-dnssec\n"
unless ($zone =~ /^[a-zA-Z0-9\.\-\_]+\.$/); if ($enable_dnssec && $disable_dnssec);
die "--master is required\n" my $host = shift || die "No zonename provided\n";
unless defined $master && $master ne '';
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}; my $regex = $RE{net}{IPv4} . '|' . $RE{net}{IPv6};
die "Master must be an IPv4 or IPv6 address\n" die "Master must be an IPv4 or IPv6 address\n"
unless ($master =~ /^$regex$/); unless ($master =~ /^$regex$/);
die "Specify only one of --enable-dnssec or --disable-dnssec\n" my $payload = { master => $master };
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 ($enable_dnssec) { if ($enable_dnssec) {
$payload{dnssec} = JSON::PP::true; $payload->{dnssec} = true;
} elsif ($disable_dnssec) { } 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) { if ($edit) {
$ddns->set($zone, $json, '_pureslave'); $ddns->set($host, $txt, '_pureslave');
} else { } else {
$ddns->add($zone, $json, '_pureslave'); $ddns->add($host, $txt, '_pureslave');
} }
exit 0; exit 0;

View File

@@ -2,64 +2,78 @@
use strict; use strict;
use warnings; use warnings;
use Getopt::Long qw/GetOptions/;
use JSON::PP ();
use Regexp::Common qw/net/;
use Martnet::DDNS; 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 <IPv4|IPv6> " +
("[--webserver-ip <IPv4|IPv6>] ") +
"[--ddns-keyname <keyname> ...] <zone.>\n";
}
my $master; my $master;
my $edit = 0; my $edit = 0;
my @ddns_keys; my $enable_dnssec = 0;
my $enable_dnssec = 0;
my $disable_dnssec = 0; my $disable_dnssec = 0;
my @ddns_keynames;
my $webserver_ip;
GetOptions( GetOptions(
'master|m=s' => \$master, 'master|m=s' => \$master,
'edit!' => \$edit, 'edit' => \$edit,
'ddns-keyname=s' => \@ddns_keys, 'enable-dnssec' => \$enable_dnssec,
'enable-dnssec!' => \$enable_dnssec, 'disable-dnssec' => \$disable_dnssec,
'disable-dnssec!' => \$disable_dnssec, 'ddns-keyname=s@' => \@ddns_keynames,
) or die "Usage: add-vhost --master <ip> [--edit] [--ddns-keyname <key>]... [--enable-dnssec|--disable-dnssec] <zone.>\n"; '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" die "Cannot specify both --enable-dnssec and --disable-dnssec\n"
unless ($zone =~ /^[a-zA-Z0-9\.\-\_]+\.$/); if ($enable_dnssec && $disable_dnssec);
die "--master is required\n" my $host = shift || die "No zonename provided\n";
unless defined $master && $master ne '';
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}; my $regex = $RE{net}{IPv4} . '|' . $RE{net}{IPv6};
die "Master must be an IPv4 or IPv6 address\n" die "Master must be an IPv4 or IPv6 address\n"
unless ($master =~ /^$regex$/); unless ($master =~ /^$regex$/);
die "Specify only one of --enable-dnssec or --disable-dnssec\n" if (defined $webserver_ip) {
if ($enable_dnssec && $disable_dnssec); die "Webserver IP must be an IPv4 or IPv6 address\n"
unless ($webserver_ip =~ /^$regex$/);
# 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) { if ($enable_dnssec) {
$payload{dnssec} = JSON::PP::true; $payload->{dnssec} = true;
} elsif ($disable_dnssec) { } 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) { if ($edit) {
$ddns->set($zone, $json, '_vhosts'); $ddns->set($host, $txt, '_vhosts');
} else { } else {
$ddns->add($zone, $json, '_vhosts'); $ddns->add($host, $txt, '_vhosts');
} }
exit 0; exit 0;

View File

@@ -11,35 +11,44 @@ use Martnet::DDNS;
# Hidden-master unified include generator. # Hidden-master unified include generator.
# #
# - Generates /etc/bind/unified.zones.9 # - Generates /etc/bind/unified.zones.9
# - Creates missing zonefiles for any emitted zones (minimal skeleton) # - Creates missing zonefiles for any emitted zones
# - Deterministic output; only rewrites include file if content changes # - _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: # Control plane (private.invalid) remains static and is NOT generated here.
# - 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 $out = '/etc/bind/unified.zones.9';
my $self_master = undef; # if unset, uses $ddns->default_master() my $self_master = undef; # if unset, uses $ddns->default_master() (from _config)
my $force_zf = 0; # overwrite existing zonefiles too my $force_zf = 0; # overwrite existing zonefiles too
my $force_out = 0; # rewrite unified include even if identical my $force_out = 0; # rewrite unified include even if identical
my $reload_cmd = '/usr/sbin/rndc'; my $reload_cmd = '/usr/sbin/rndc';
my $reconfig = 1; # use rndc reconfig (safer for include file updates) my $reconfig = 0; # default to reload (safer; reconfig requires includes to be perfect)
my $reconcile_all = 0;
my $default_ddns_key = 'master-ddns-tsig'; my $verbose = 0;
GetOptions( GetOptions(
'out=s' => \$out, 'out=s' => \$out,
'self=s' => \$self_master, 'self=s' => \$self_master,
'force-zonefiles!' => \$force_zf, 'force-zonefiles!' => \$force_zf,
'force!' => \$force_out, 'force!' => \$force_out,
'reload-cmd=s' => \$reload_cmd, 'reload-cmd=s' => \$reload_cmd,
'reconfig!' => \$reconfig, 'reconfig!' => \$reconfig,
) or die "Usage: $0 [--out PATH] [--self IP] [--force-zonefiles] [--force] [--reload-cmd /path/to/rndc] [--reconfig]\n"; '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 $ddns = Martnet::DDNS->new();
my $cfg = $ddns->get_config();
$self_master = $ddns->default_master() unless defined $self_master; $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. # Pull all control-plane records and select the zones this hidden master should serve as MASTER.
my @all = $ddns->get(); my @all = $ddns->get();
@@ -47,33 +56,29 @@ my @zones;
for my $r (@all) { for my $r (@all) {
next unless defined $r->{zone} && $r->{zone} ne ''; 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'; 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'; 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 ''; 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}); my %masters = map { $_ => 1 } grep { $_ ne '' } split(/;/, $r->{master});
# Only emit zones where THIS host is listed as master.
next unless $masters{$self_master}; next unless $masters{$self_master};
push @zones, $r; push @zones, $r;
} }
# Deterministic ordering
@zones = sort { @zones = sort {
($a->{zone} cmp $b->{zone}) ($a->{zone} cmp $b->{zone})
|| ||
(($a->{type} // '') cmp ($b->{type} // '')) (($a->{type} // '') cmp ($b->{type} // ''))
} @zones; } @zones;
# Map control-plane type to zonefile directory.
# Adjust if you later collapse storage locations.
sub zonefile_for { sub zonefile_for {
my ($zone, $type) = @_; my ($zone, $type) = @_;
my $dir = '/var/lib/bind/custom'; # default my $dir = '/var/lib/bind/custom'; # default
@@ -84,7 +89,6 @@ sub zonefile_for {
} elsif ($type eq '_custom') { } elsif ($type eq '_custom') {
$dir = '/var/lib/bind/custom'; $dir = '/var/lib/bind/custom';
} else { } else {
# Unknown types: default to custom; keep predictable.
$dir = '/var/lib/bind/custom'; $dir = '/var/lib/bind/custom';
} }
} }
@@ -92,34 +96,131 @@ sub zonefile_for {
return "$dir/db.$zone"; return "$dir/db.$zone";
} }
# Minimal zonefile skeleton: valid SOA + NS, no service assumptions. sub ensure_dir {
sub write_minimal_zonefile { my ($file) = @_;
my ($fh, $zone) = @_; if ($file =~ m{^(.+)/[^/]+$}) {
my $dir = $1;
my $fqdn = $zone . '.'; if (!-d $dir) {
my $serial = strftime('%Y%m%d01', localtime()); mkdir $dir or die "Cannot mkdir $dir: $!\n";
}
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). sub write_minimal_zonefile {
# This is intentionally conservative: it creates only if absent unless --force-zonefiles. 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; my $zf_changes = 0;
for my $r (@zones) { for my $r (@zones) {
my $zone = $r->{zone}; my $zone = $r->{zone};
@@ -127,22 +228,23 @@ for my $r (@zones) {
my $zf = zonefile_for($zone, $type); my $zf = zonefile_for($zone, $type);
if ($force_zf || !-f $zf) { 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) ensure_dir($zf);
if ($zf =~ m{^(.+)/db\..+$}) { open(my $fh, '>', $zf) or die "Can't create zone file $zf: $!\n";
my $dir = $1;
if (!-d $dir) { if ($type eq '_vhosts') {
mkdir $dir or die "Cannot mkdir $dir: $!\n"; 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; 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); system('/usr/bin/chown', 'bind:bind', $zf);
chmod 0644, $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 = ""; my $content = "";
$content .= "#\n"; $content .= "#\n";
$content .= "# Autogenerated. DO NOT EDIT.\n"; $content .= "# Autogenerated. DO NOT EDIT.\n";
@@ -162,38 +333,23 @@ for my $r (@zones) {
my $type = $r->{type} // ''; my $type = $r->{type} // '';
my $zf = zonefile_for($zone, $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 $allow_transfer = 'allow-transfer { trusted;';
my @keys = ($default_ddns_key); for my $k (@keys) {
if (defined($r->{payload}) && ref($r->{payload}) eq 'HASH') { $allow_transfer .= " key \"$k\";";
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;
}
} }
} $allow_transfer .= ' };';
# 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 {';
my $update_policy = 'update-policy { ' . join(' ', map { 'grant ' . $_ . ' zonesub ANY;' } @keys) . ' };'; for my $k (@keys) {
my $journal = 'journal "' . $zf . '.jnl";'; $update_policy .= " grant $k zonesub ANY;";
my $serial_update = 'serial-update-method unixtime;'; }
$update_policy .= ' };';
my $dnssec = 0; my $journal = "journal \"$zf.jnl\";";
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( $content .= "zone \"$zone\" { type master; file \"$zf\"; $allow_transfer $update_policy $journal };\n";
"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 # Decide whether unified include changed
@@ -214,7 +370,6 @@ if ($out_changed) {
print $tmpfh $content; print $tmpfh $content;
close $tmpfh; close $tmpfh;
# Preserve mode if possible
my $mode = 0644; my $mode = 0644;
if (-e $out) { if (-e $out) {
my @st = stat($out); my @st = stat($out);
@@ -222,17 +377,15 @@ if ($out_changed) {
} }
chmod $mode, $tmpname; chmod $mode, $tmpname;
# Try to preserve bind ownership on the include
system('/usr/bin/chown', 'bind:bind', $tmpname); system('/usr/bin/chown', 'bind:bind', $tmpname);
rename($tmpname, $out) or die "Failed to install $out: $!\n"; 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 { } 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) # Reload only if something changed
if ($zf_changes || $out_changed) { if ($zf_changes || $out_changed || $reconcile_changes) {
if ($reconfig) { if ($reconfig) {
system($reload_cmd, 'reconfig') == 0 system($reload_cmd, 'reconfig') == 0
or die "rndc reconfig failed: $?\n"; or die "rndc reconfig failed: $?\n";
@@ -240,7 +393,7 @@ if ($zf_changes || $out_changed) {
system($reload_cmd, 'reload') == 0 system($reload_cmd, 'reload') == 0
or die "rndc reload failed: $?\n"; or die "rndc reload failed: $?\n";
} }
print "Reloaded named.\n"; print "Reloaded named.\n" if $verbose;
} }
exit 0; exit 0;