#!/usr/bin/env perl # License: 3-Clause BSD. Author: Matthew Connelly. # This is a (formerly Bash, now Perl) script for managing in-addr.arpa and ip6.arpa zones. # If you have any questions or issues, open an issue at https://bitbucket.org/MaffC/script-collection/issues use strict; use warnings; package DNS::Reverse::Manager; use vars '$VERSION'; $VERSION = '1.0.0'; #Version number use Data::Validate::IP qw(is_public_ipv4 is_public_ipv6); #for validating v4/v6 addresses use Getopt::Long qw(:config posix_default bundling pass_through); #for intelligently handling cli arguments use Net::DNS; #for doing forward and reverse lookups use Net::IP; #for converting IPs to their reverse zones #conf my $def_rdns = 'hosted-by.mycompany.com'; #Recomend default is "hosted-by.your-website.tld". my $def_dns = '8.8.8.8'; #Recommended default is 8.8.8.8 or 4.2.2.1. my $zone_dir = '/var/named/'; #for cPanel, use /var/named/. my $zone_ext = ".db"; #Default for most environments is ".db". my $net_type = "cpanel"; #This was originally written to support cPanel-based DNS environments, and primarily impacts how rdns-manager "syncs". my $nsd_type = "bind9"; #I might in the future support more than just bind9. #variables for arguments my $verify = ''; my $force = ''; my $reset = ''; my $nosync = ''; my $fsync = ''; my $delptr = ''; my $newzone = ''; my $prefixlen = 64; #other vars my $made_modifications = ''; #functions sub nicedie { print; print "\n"; exit 1; } sub validate_domain { use Data::Validate::Domain qw(is_domain); my $domain = shift; return 1 if is_domain $domain; return 0; } sub validate_ip { my $ip = shift; return 1 if is_public_ipv4 $ip or is_public_ipv6 $ip; return 0; } sub get_arpa { my $ip = shift; if(is_public_ipv4 $ip) { $ip =~ m/^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$/; return ($4, "$3.$2.$1.in-addr.arpa"); } my $len = ($prefixlen/2); Net::IP->new($ip)->reverse_ip =~ /^(.*)\.(.{$len}ip6\.arpa)\.$/; return ($1,$2); } #TODO make these work for DNS roundrobins. I doubt anyone would be stupid enough to have more than one PTR of the same name # and i'm not sure if it's even legal, but hey. sub does_fqdn_match { my ($fqdn,$ip) = @_; my $r = Net::DNS::Resolver->new(recurse => 1,tcp_timepit => 5,udp_timeout => 5,nameservers => [$def_dns,]); my $p = $r->search($fqdn, 'A'); $p = $r->search($fqdn, 'AAAA') unless is_public_ipv4 $ip; return 0 unless defined $p; my @res = $p->answer; #due to IPv6 shortening, we need to use Net::IP here return 1 unless scalar @res < 1 or Net::IP->new($res[0]->address)->ip ne Net::IP->new($ip)->ip; return 0; } sub confirm_rdns { my ($fqdn,$ip) = @_; my ($rec,$zone) = get_arpa $ip; my $rrec = $rec.".".$zone; my $r = Net::DNS::Resolver->new(recurse => 1,tcp_timeout => 5,udp_timeout => 5,nameservers => [$def_dns,]); my $p = $r->search($rrec, 'PTR'); return 0 unless defined $p; my @res = $p->answer; return 1 unless scalar @res < 1 or $res[0]->ptrdname."." ne $fqdn; return 0; } sub does_zone_exist { my $ip = shift; my ($rec,$zone) = get_arpa $ip; return -2 if !-e "$zone_dir/$zone$zone_ext"; return -1 if -z "$zone_dir/$zone$zone_ext"; return 0 if !-w "$zone_dir/$zone$zone_ext"; return 1; } sub get_zone_array { use Net::DNS::ZoneFile; #returns 1 on record exists, 0 on record doesn't exist, -1 on zone exists but isn't writeable, -2 on file exists but isn't a zone, -3 on file doesn't exist my $ip = shift; my ($rec,$zone) = get_arpa $ip; return unless does_zone_exist $ip; my $zf = new Net::DNS::ZoneFile("$zone_dir/$zone$zone_ext", $zone); my @z = $zf->read; return @z; } sub does_record_exist { my $ip = shift; my ($rec,$zone) = get_arpa $ip; my @z = get_zone_array $ip; return 0 unless @z; #I imagine this might be grossly inefficient on large zones (such as fully-populated IPv6 zones). foreach(@z) { return 1 if $_->name eq "$rec.$zone"; } return 0; } sub generate_soa_serial { use POSIX qw(strftime); my $cur_serial = shift; my $yyyymmdd = strftime "%Y%m%d", localtime; return $cur_serial+1 if $cur_serial =~ /^$yyyymmdd[0-9]{2}$/; return $yyyymmdd."00"; } sub write_zone { use File::Copy qw(copy); use Net::DNS::ZoneParse qw(writezone); my $zone = shift; my @z = @_; foreach(@z) {$_->serial(generate_soa_serial $_->serial) if $_->type eq "SOA";} #update SOA copy "$zone_dir$zone$zone_ext", "$zone_dir$zone$zone_ext.bak" or print "Warning: Couldn't create a backup of the zone $zone.\n"; open ZONE, ">$zone_dir$zone$zone_ext" or nicedie "Failed to open zonefile for $zone for writing!"; print ZONE writezone @z; close ZONE or nicedie "Seemingly failed to close $zone$zone_ext, cowardly quitting here."; } sub del_ptr { #I'm so sorry my $rec = shift; sub is_match { my ($rr,$rec) = @_; return 1 unless $rr->name eq $rec; return 0; } $made_modifications = 1; write_zone $rec,grep {&is_match(($_,$rec))} @_; } sub add_ptr { my ($ip,$fqdn) = @_; my ($rec,$zone) = get_arpa $ip; my @z = get_zone_array $ip; my $new_rr = Net::DNS::RR->new("$rec.$zone. 3600 IN PTR $fqdn"); push @z,$new_rr; $made_modifications = 1; write_zone $zone,@z; } sub get_ptr { my $ip = shift; return unless does_record_exist $ip; my ($rec,$zone) = get_arpa $ip; my @z = get_zone_array $ip; #More inefficient to repeat the same operation twice even. foreach(@z) { return $_->ptrdname if $_->name eq "$rec.$zone"; } return; } sub set_ptr { my ($ip,$fqdn) = @_; return add_ptr $ip,$fqdn unless does_record_exist $ip; my ($record,$zone) = get_arpa $ip; my @z = get_zone_array $ip; foreach(@z) { $_->ptrdname($fqdn) if $_->name eq "$record.$zone"; } $made_modifications = 1; write_zone $zone,@z; return 1; } sub sync_cpanel { my $zone = shift; my $syncscript = "/scripts/dnscluster synczone"; `$syncscript $zone`; return $?; } sub do_sync { my $ip = shift; my ($rec,$zone) = get_arpa $ip; my $res = ''; print "Syncing zone $zone... "; nicedie "Couldn't sync $zone: Don't have a known sync method for network type $net_type." unless $net_type eq "cpanel"; $res = sync_cpanel $zone if $net_type eq "cpanel"; print (($res) ? "Synchronised\n" : "Failed\n"); } #main #do argument parsing. all unknown arguments get left in @ARGV so I can `shift`. GetOptions 'reset-hostname=s' => \$def_rdns, 'dns-server=s' => \$def_dns, 'v|verify-rdns' => \$verify, 'f|force' => \$force, 'r|reset' => \$reset, 'p|populate' => \$newzone, 'd|no-sync' => \$nosync, 's|force-sync' => \$fsync, 'R|remove-ptr' => \$delptr; #get IP and domain, validate. my $ip = shift or nicedie "No IP given!"; $prefixlen = $1 if $ip =~ s/\/([0-9]+)//; #split off prefixlen (if given) into variable for later use nicedie "Invalid IP address '$ip'!" unless validate_ip $ip; my $domain = shift; nicedie "Invalid FQDN '$domain'!" if defined $domain and !validate_domain $domain; $domain =~ s/([a-zA-Z])$/$1./ if defined $domain; #Append final period if it doesn't exist #Main program flow #Argument intelligence. Omitting this probably won't impact program flow much but it's important that the user know they're stupid. nicedie "You seem to have specified both --no-sync and --force-sync. Please make your mind up." if $nosync and $fsync; nicedie "You seem to have specified some combination of --reset, --remove-ptr and --force. Please make your mind up." if ($reset and $delptr) or (($reset or $delptr) and $force); nicedie "You seem to have specified arguments that don't make sense together. Please make your mind up." if ($newzone and ($delptr or $reset or $force)) or ($verify and !defined $domain) or (defined $domain and ($newzone or $delptr or $reset)); #Simple check that the zone exists. This was a for/when statement, but this script needs perl 5.8.8 compat, so for/given and when are out. my ($trec,$tz) = get_arpa $ip; nicedie "Authoritative zone for IP $ip doesn't exist! Please create zone $tz or ensure you specified the correct subnet mask if this is an IPv6 address!" if does_zone_exist($ip) == -2; nicedie "Zonefile $tz (supposedly authoritative for $ip) doesn't appear to be a valid BIND zone. Please check the zonefile and try again." if does_zone_exist($ip) == -1; nicedie "Authoritative zone for IP $ip exists but we can't write to it. Please check the permissions on the zonefile for $tz." if !does_zone_exist($ip); if(!defined $domain and $reset) { set_ptr $ip,$def_rdns or nicedie "Failed to set rDNS for $ip to '$def_rdns'!"; print "rDNS set.\n"; } elsif(!defined $domain and $delptr) { del_ptr $ip or nicedie "Failed to delete PTR record for $ip!"; print "PTR record for IP $ip deleted.\n";exit; } elsif(!defined $domain and $newzone) { nicedie "Sorry, but the zone population functionality isn't yet written."; } elsif(!defined $domain) { print "No rDNS record for IP $ip exists.\n" and exit unless does_record_exist $ip; print "rDNS for IP $ip: ".get_ptr $ip;print "\n";exit; } if(defined $domain) { nicedie "Forward DNS for $domain doesn't match $ip!" unless $force or does_fqdn_match $domain, $ip; set_ptr $ip,$domain or nicedie "Failed to set rDNS for $ip to '$domain'!"; print "rDNS set.\n"; } do_sync $ip if (($made_modifications and !$nosync) or $fsync); my $domain = $def_rdns unless defined $domain; print ((confirm_rdns $ip, $domain) ? "rDNS for IP $ip was successfully set to $domain." : "rDNS for IP $ip not yet resolving to $domain (check later with: host $ip)") if $verify;