diff --git a/DDNS.pm b/DDNS.pm index f2f435e..4f57a82 100644 --- a/DDNS.pm +++ b/DDNS.pm @@ -235,6 +235,27 @@ sub type { 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) = @_; _validateOrDie($dom); @@ -270,7 +291,7 @@ sub cleanup { # 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-master-vhosts"); + system("/usr/local/bin/sync-unified-master"); } diff --git a/bin/add-custom b/bin/add-custom index 8745f52..ee861c7 100755 --- a/bin/add-custom +++ b/bin/add-custom @@ -2,47 +2,64 @@ use strict; use warnings; -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 [--enable-dnssec|--disable-dnssec] --master \n"; -} +use Getopt::Long qw/GetOptions/; +use JSON::PP (); +use Regexp::Common qw/net/; +use Martnet::DDNS; my $master; -my $enable_dnssec = 0; +my $edit = 0; +my @ddns_keys; +my $enable_dnssec = 0; my $disable_dnssec = 0; GetOptions( - 'master|m=s' => \$master, - 'enable-dnssec' => \$enable_dnssec, - 'disable-dnssec' => \$disable_dnssec, -) or usage(); + '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"; -usage() unless defined $master; +my $zone = shift || die "No zone provided\n"; -die "Cannot specify both --enable-dnssec and --disable-dnssec\n" - if ($enable_dnssec && $disable_dnssec); +die "Zone must end in a dot\n" + unless ($zone =~ /^[a-zA-Z0-9\.\-\_]+\.$/); -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\.\-\_]+\.$/); +die "--master is required\n" + unless defined $master && $master ne ''; my $regex = $RE{net}{IPv4} . '|' . $RE{net}{IPv6}; die "Master must be an IPv4 or IPv6 address\n" unless ($master =~ /^$regex$/); -my $payload = { master => $master }; -if ($enable_dnssec) { - $payload->{dnssec} = true; -} elsif ($disable_dnssec) { - $payload->{dnssec} = false; +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 ]; } -$ddns->add($host, encode_json($payload), '_custom'); +if ($enable_dnssec) { + $payload{dnssec} = JSON::PP::true; +} elsif ($disable_dnssec) { + $payload{dnssec} = JSON::PP::false; +} +my $json = JSON::PP->new->canonical(1)->encode(\%payload); + +my $ddns = Martnet::DDNS->new(); + +if ($edit) { + $ddns->set($zone, $json, '_custom'); +} else { + $ddns->add($zone, $json, '_custom'); +} + +exit 0; diff --git a/bin/add-slave b/bin/add-slave index b5c8b1b..294d027 100755 --- a/bin/add-slave +++ b/bin/add-slave @@ -2,47 +2,64 @@ use strict; use warnings; -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 [--enable-dnssec|--disable-dnssec] --master \n"; -} +use Getopt::Long qw/GetOptions/; +use JSON::PP (); +use Regexp::Common qw/net/; +use Martnet::DDNS; my $master; -my $enable_dnssec = 0; +my $edit = 0; +my @ddns_keys; +my $enable_dnssec = 0; my $disable_dnssec = 0; GetOptions( - 'master|m=s' => \$master, - 'enable-dnssec' => \$enable_dnssec, - 'disable-dnssec' => \$disable_dnssec, -) or usage(); + '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"; -usage() unless defined $master; +my $zone = shift || die "No zone provided\n"; -die "Cannot specify both --enable-dnssec and --disable-dnssec\n" - if ($enable_dnssec && $disable_dnssec); +die "Zone must end in a dot\n" + unless ($zone =~ /^[a-zA-Z0-9\.\-\_]+\.$/); -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\.\-\_]+\.$/); +die "--master is required\n" + unless defined $master && $master ne ''; my $regex = $RE{net}{IPv4} . '|' . $RE{net}{IPv6}; die "Master must be an IPv4 or IPv6 address\n" unless ($master =~ /^$regex$/); -my $payload = { master => $master }; -if ($enable_dnssec) { - $payload->{dnssec} = true; -} elsif ($disable_dnssec) { - $payload->{dnssec} = false; +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 ]; } -$ddns->add($host, encode_json($payload), '_pureslave'); +if ($enable_dnssec) { + $payload{dnssec} = JSON::PP::true; +} elsif ($disable_dnssec) { + $payload{dnssec} = JSON::PP::false; +} +my $json = JSON::PP->new->canonical(1)->encode(\%payload); + +my $ddns = Martnet::DDNS->new(); + +if ($edit) { + $ddns->set($zone, $json, '_pureslave'); +} else { + $ddns->add($zone, $json, '_pureslave'); +} + +exit 0; diff --git a/bin/add-vhost b/bin/add-vhost index 95418a0..0b62d19 100755 --- a/bin/add-vhost +++ b/bin/add-vhost @@ -2,47 +2,64 @@ use strict; use warnings; -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 [--enable-dnssec|--disable-dnssec] --master \n"; -} +use Getopt::Long qw/GetOptions/; +use JSON::PP (); +use Regexp::Common qw/net/; +use Martnet::DDNS; my $master; -my $enable_dnssec = 0; +my $edit = 0; +my @ddns_keys; +my $enable_dnssec = 0; my $disable_dnssec = 0; GetOptions( - 'master|m=s' => \$master, - 'enable-dnssec' => \$enable_dnssec, - 'disable-dnssec' => \$disable_dnssec, -) or usage(); + '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"; -usage() unless defined $master; +my $zone = shift || die "No zone provided\n"; -die "Cannot specify both --enable-dnssec and --disable-dnssec\n" - if ($enable_dnssec && $disable_dnssec); +die "Zone must end in a dot\n" + unless ($zone =~ /^[a-zA-Z0-9\.\-\_]+\.$/); -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\.\-\_]+\.$/); +die "--master is required\n" + unless defined $master && $master ne ''; my $regex = $RE{net}{IPv4} . '|' . $RE{net}{IPv6}; die "Master must be an IPv4 or IPv6 address\n" unless ($master =~ /^$regex$/); -my $payload = { master => $master }; -if ($enable_dnssec) { - $payload->{dnssec} = true; -} elsif ($disable_dnssec) { - $payload->{dnssec} = false; +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 ]; } -$ddns->add($host, encode_json($payload), '_vhosts'); +if ($enable_dnssec) { + $payload{dnssec} = JSON::PP::true; +} elsif ($disable_dnssec) { + $payload{dnssec} = JSON::PP::false; +} +my $json = JSON::PP->new->canonical(1)->encode(\%payload); + +my $ddns = Martnet::DDNS->new(); + +if ($edit) { + $ddns->set($zone, $json, '_vhosts'); +} else { + $ddns->add($zone, $json, '_vhosts'); +} + +exit 0; diff --git a/bin/sync-unified-master b/bin/sync-unified-master index 79fc23a..1b8636d 100755 --- a/bin/sync-unified-master +++ b/bin/sync-unified-master @@ -10,7 +10,7 @@ use Martnet::DDNS; # Hidden-master unified include generator. # -# - Generates /etc/bind/unified.zones.9 +# - 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 # @@ -19,13 +19,15 @@ use Martnet::DDNS; # - 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 = '/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) +my $default_ddns_key = 'master-ddns-tsig'; + GetOptions( 'out=s' => \$out, 'self=s' => \$self_master, @@ -160,11 +162,38 @@ for my $r (@zones) { 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 - ); + +# 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