218 lines
6.3 KiB
Perl
Executable File
218 lines
6.3 KiB
Perl
Executable File
#!/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 (minimal skeleton)
|
|
# - Deterministic output; only rewrites include file if content changes
|
|
#
|
|
# 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).
|
|
|
|
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)
|
|
|
|
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";
|
|
|
|
my $ddns = Martnet::DDNS->new();
|
|
$self_master = $ddns->default_master() unless defined $self_master;
|
|
|
|
# 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 '';
|
|
|
|
# Core control plane zone should remain static, not autogenerated.
|
|
next if $r->{zone} eq 'private.invalid';
|
|
|
|
# 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.
|
|
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
|
|
|
|
if (defined $type) {
|
|
if ($type eq '_vhosts') {
|
|
$dir = '/var/lib/bind/vhost';
|
|
} elsif ($type eq '_custom') {
|
|
$dir = '/var/lib/bind/custom';
|
|
} else {
|
|
# Unknown types: default to custom; keep predictable.
|
|
$dir = '/var/lib/bind/custom';
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
# Ensure missing zonefiles exist (or replace if forced).
|
|
# This is intentionally conservative: it creates only if absent unless --force-zonefiles.
|
|
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 minimal zone file for $zone at $zf\n";
|
|
|
|
# Ensure directory exists (best-effort)
|
|
if ($zf =~ m{^(.+)/db\..+$}) {
|
|
my $dir = $1;
|
|
if (!-d $dir) {
|
|
mkdir $dir or die "Cannot mkdir $dir: $!\n";
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
$zf_changes++;
|
|
}
|
|
}
|
|
|
|
# Render unified include content
|
|
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);
|
|
|
|
# Stable, single-line formatting
|
|
$content .= sprintf(
|
|
"zone \"%s\" { type master; file \"%s\"; };\n",
|
|
$zone, $zf
|
|
);
|
|
}
|
|
|
|
# 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;
|
|
|
|
# Preserve mode if possible
|
|
my $mode = 0644;
|
|
if (-e $out) {
|
|
my @st = stat($out);
|
|
$mode = $st[2] & 07777 if @st;
|
|
}
|
|
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";
|
|
} else {
|
|
print "No changes to $out.\n";
|
|
}
|
|
|
|
# Reload only if something changed (zonefile creation or include change)
|
|
if ($zf_changes || $out_changed) {
|
|
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";
|
|
}
|
|
|
|
exit 0;
|