#!/usr/bin/perl use strict; use warnings; use File::Temp qw/tempfile/; use Getopt::Long qw/GetOptions/; use POSIX qw/strftime/; use Martnet::DDNS; # Hidden-master unified include generator. # # - Generates /etc/bind/unified.zones.9 # - Creates missing zonefiles for any emitted zones # - _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) # # 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() (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, '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(); my @zones; for my $r (@all) { next unless defined $r->{zone} && $r->{zone} ne ''; # Skip the control plane itself next if $r->{zone} eq 'private.invalid'; # Skip explicitly slave-only records next if defined($r->{type}) && $r->{type} eq '_pureslave'; # Skip config records (not BIND zones) next if defined($r->{type}) && $r->{type} eq '_config'; next unless defined $r->{master} && $r->{master} ne ''; my %masters = map { $_ => 1 } grep { $_ ne '' } split(/;/, $r->{master}); next unless $masters{$self_master}; push @zones, $r; } @zones = sort { ($a->{zone} cmp $b->{zone}) || (($a->{type} // '') cmp ($b->{type} // '')) } @zones; sub zonefile_for { my ($zone, $type) = @_; my $dir = '/var/lib/bind/custom'; # default if (defined $type) { if ($type eq '_vhosts') { $dir = '/var/lib/bind/vhost'; } elsif ($type eq '_custom') { $dir = '/var/lib/bind/custom'; } else { $dir = '/var/lib/bind/custom'; } } return "$dir/db.$zone"; } sub ensure_dir { my ($file) = @_; if ($file =~ m{^(.+)/[^/]+$}) { my $dir = $1; if (!-d $dir) { mkdir $dir or die "Cannot mkdir $dir: $!\n"; } } } 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}; my $type = $r->{type} // ''; my $zf = zonefile_for($zone, $type); if ($force_zf || !-f $zf) { print "Creating zone file for $zone at $zf\n" if $verbose; 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); } close $fh; system('/usr/bin/chown', 'bind:bind', $zf); chmod 0644, $zf; $zf_changes++; } } # 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"; $content .= "# Generated by sync-unified-master from control-plane data.\n"; $content .= "#\n\n"; for my $r (@zones) { my $zone = $r->{zone}; my $type = $r->{type} // ''; my $zf = zonefile_for($zone, $type); my @keys = uniq_keys($default_ddns_keyname, zone_extra_ddns_keys($r)); my $allow_transfer = 'allow-transfer { trusted;'; for my $k (@keys) { $allow_transfer .= " key \"$k\";"; } $allow_transfer .= ' };'; my $update_policy = 'update-policy {'; for my $k (@keys) { $update_policy .= " grant $k zonesub ANY;"; } $update_policy .= ' };'; my $journal = "journal \"$zf.jnl\";"; $content .= "zone \"$zone\" { type master; file \"$zf\"; $allow_transfer $update_policy $journal };\n"; } # Decide whether unified include changed my $out_changed = 1; if (!$force_out && -e $out) { open my $fh, '<', $out or die "Cannot read $out: $!\n"; local $/; my $existing = <$fh>; close $fh; if (defined $existing && $existing eq $content) { $out_changed = 0; } } if ($out_changed) { my ($tmpfh, $tmpname) = tempfile('unified.zones.9.XXXXXX', DIR => '/tmp', UNLINK => 0); print $tmpfh $content; close $tmpfh; my $mode = 0644; if (-e $out) { my @st = stat($out); $mode = $st[2] & 07777 if @st; } chmod $mode, $tmpname; system('/usr/bin/chown', 'bind:bind', $tmpname); rename($tmpname, $out) or die "Failed to install $out: $!\n"; print "Wrote $out (" . scalar(@zones) . " zones).\n" if $verbose; } else { print "No changes to $out.\n" if $verbose; } # 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"; } else { system($reload_cmd, 'reload') == 0 or die "rndc reload failed: $?\n"; } print "Reloaded named.\n" if $verbose; } exit 0;