diff --git a/bin/pdns-audit.sh b/bin/pdns-audit.sh new file mode 100755 index 0000000..8233c83 --- /dev/null +++ b/bin/pdns-audit.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# +# powerdns-tools +# https://git.stack-source.com/msb/powerdns-tools +# MIT License Copyright (c) 2022 Matthew Saunders Brown +# +# Basic PowerDNS audit tool. Gets list of zones from pdns database +# and does DNS lookups on their nameservers to see if the zone uses +# your nameservers or not (as listed in the authoritative() array below). +# This tool does not check for mismatched NS records, for example what +# is listed with the domain registrar is not what's configured in DNS. +# This tool should be run as root or another user that has access to +# the pdns database without having to specify connection info. For +# example a fully configured .my.cnf user file can provide this. +# For domains that are found to be using other nameservers than yours +# the A & MX records are listed, the idea being you can check against +# that to determine if the domains are using your hosting services. +# No action is taken, this just provides a report that can be reviewed. + +# set array of our nameservers +authoritative=(ns1.example.com. ns2.example.com. ns3.example.com.) + +# create array of domains from pdns database: +domains=(`mysql -s -e "SELECT LOWER(name) FROM pdns.domains"`) + +# cycle through each domain +for domain in "${domains[@]}"; do + # get nameservers for domain + nameservers=(`/usr/bin/dig $domain ns +short`) + # check number of nameservers returned + if [[ ${#nameservers[@]} = 0 ]]; then + # domain returns zero nameservers (either unregistered, or registered but no NS entries configured in DNS) + echo ZERO: $domain + elif [[ ${#nameservers[@]} -gt 0 ]]; then + usesours=FALSE + for nameserver in "${nameservers[@]}"; do + if [[ " ${authoritative[*]} " =~ " ${nameserver} " ]]; then + usesours=TRUE + fi + done + if [[ $usesours = FALSE ]]; then + # domain uses other nameservers than ours + unset arecord + unset mxrecord + arecord=`/usr/bin/dig $domain +short` + mxrecord=`/usr/bin/dig $domain mx +short` + echo OTHER: $domain - $arecord - $mxrecord + else + # domain uses our nameservers + echo VERIFIED: $domain + fi + else + # error getting nameservers + echo ERROR: $domain + fi +done diff --git a/bin/pdns-record-del.sh b/bin/pdns-record-del.sh new file mode 100755 index 0000000..6fc358d --- /dev/null +++ b/bin/pdns-record-del.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# +# pdns-tools +# https://git.stack-source.com/msb/pdns-tools +# MIT License Copyright (c) 2022 Matthew Saunders Brown + +# load include file +source $(dirname $0)/pdns.sh + +help() +{ + thisfilename=$(basename -- "$0") + echo "$thisfilename" + echo "Delete Resource Record set in zone." + echo "" + echo "usage: $thisfilename -z -n -t [-h]" + echo "" + echo " -h Print this help." + echo " -z Zone (domain name) to modify." + echo " -n Hostname for the record(s) to delete." + echo " -t Type of record(s) to del (A, CNAME, TXT, etc.)." + echo + echo " Record Sets are matched against zone, name & type. This tool deletes all records" + echo " that match the specified name & type for the given zone." + echo " Can be a FQDN, or just the subdomain part (zone will be appended) or @ which will be replaced by the zone." +} + +pdns:getoptions "$@" + +# check for zone, make sure it ends with a . +if [[ -z $zone ]]; then + echo "zone is required" + exit +elif [[ $zone != *\. ]]; then + zone="$zone." +fi + +# check for name, make sure it ends with a . +if [[ -z $name ]]; then + echo "name is required" + exit +elif [[ $name = "@" ]]; then + name=$zone +elif [[ $name != *\. ]]; then + name="$name." +fi + +# make sure name is equal to or part of zone +if [[ $name != $zone ]] && [[ $name != *\.$zone ]]; then + name="$name$zone" +fi + +# check for type +if [[ -z $type ]]; then + echo "type is required" + exit +fi + +# first query to see if zone already exists +zone_status=$(/usr/bin/curl --silent --output /tmp/$zone --write-out "%{http_code}" -H "X-API-Key: $api_key" "$api_base_url/zones/$zone") + +if [[ $zone_status = 200 ]]; then + # verified zone exists, delete record + + data="{\"rrsets\":[{\"name\":\"$name\",\"type\":\"$type\",\"changetype\":\"DELETE\",\"records\":[]}]}" + + # delete record(s) + zone_status=$(/usr/bin/curl --silent --request PATCH --output "/tmp/$zone" --write-out "%{http_code}" --header "X-API-Key: $api_key" --data "$data" "$api_base_url/zones/$zone") + + if [[ $zone_status = 204 ]]; then + echo "Success. Record(s) for $zone deleted." + else + echo "Error. http response deleting record(s) for $zone was: $zone_status" + fi + +elif [[ $zone_status = 404 ]]; then + echo "Zone $zone does not exist, can't delete record." +else + echo "Unexpected http response checking for Zone $zone: $zone_status" +fi + +rm /tmp/$zone diff --git a/bin/pdns-record-rep.sh b/bin/pdns-record-rep.sh new file mode 100755 index 0000000..dd6dfd9 --- /dev/null +++ b/bin/pdns-record-rep.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# +# pdns-tools +# https://git.stack-source.com/msb/pdns-tools +# MIT License Copyright (c) 2022 Matthew Saunders Brown + +# load include file +source $(dirname $0)/pdns.sh + +help() +{ + thisfilename=$(basename -- "$0") + echo "$thisfilename" + echo "Replace Resource Record set in zone." + echo "" + echo "usage: $thisfilename -z -n -t -c [-l ] [-s ] [-h]" + echo "" + echo " -h Print this help." + echo " -z Zone (domain name) to modify records." + echo " -n Hostname for this record." + echo " -t Type of record to modify (A, CNAME, TXT, etc.)." + echo " -c Resource record content (data / values)." + echo " -l TTL, optional, defaults to $zone_defaults_ttl." + echo " -s <0|1> Status, optional. O (default) for active or 1 for disabled." + echo + echo " If record(s) do not exist they are created, if they already exist they are replaced." + echo " Record Sets are matched against zone, name & type. This tool updates/replaces all records" + echo " that match the specified name & type. is the data for the record and can be multiple" + echo " records with each record separated by a pipe (|). In the case where you have multiple records" + echo " that match the specified name & type you must specify *all* of the records. If for example" + echo " you want to add a third NS record to a domain that already has a two NS records you must" + echo " specify all 3 NS records in the , otherwise the 2 existing records will be deleted" + echo " and the single new record will be added from the ". + echo " When adding MX or SRV records specify the Priority as part of the record. e.g. -c \"10 mail.example.com\"" + echo " Only MX & SRV records use Priority, leave Priority off all other records." + echo " Can be a FQDN, or just the subdomain part (zone will be appended) or @ which will be replaced by the zone." +} + +pdns:getoptions "$@" + +# check for zone, make sure it ends with a . +if [[ -z $zone ]]; then + echo "zone is required" + exit +elif [[ $zone != *\. ]]; then + zone="$zone." +fi + +# check for name, make sure it ends with a . +if [[ -z $name ]]; then + echo "name is required" + exit +elif [[ $name = "@" ]]; then + name=$zone +elif [[ $name != *\. ]]; then + name="$name." +fi + +# make sure name is equal to or part of zone +if [[ $name != $zone ]] && [[ $name != *\.$zone ]]; then + name="$name$zone" +fi + +# check for type +if [[ -z $type ]]; then + echo "type is required" + exit +fi + +# check for content +if [[ -z $content ]]; then + echo "content is required" + exit +fi + +# check for ttl +if [[ -z $ttl ]]; then + ttl=$zone_defaults_ttl +fi + +# first query to see if zone already exists +zone_status=$(/usr/bin/curl --silent --output /tmp/$zone --write-out "%{http_code}" -H "X-API-Key: $api_key" "$api_base_url/zones/$zone") + +if [[ $zone_status = 200 ]]; then + # verified zone exists, add record(s) + + data="{\"rrsets\":[{\"name\":\"$name\",\"type\":\"$type\",\"ttl\":$ttl,\"changetype\":\"REPLACE\",\"records\":[" + + # turn content in to array of records + orig_ifs="$IFS" + IFS='|' + read -r -a resourcerecords <<< "$content" + IFS="$orig_ifs" + + # get number of records in set + resourcerecords_records_count=${#resourcerecords[@]} + records_count=0 + + for resourcerecord in "${resourcerecords[@]}"; do + + records_count=$((records_count+1)) + + # make sure hostnames end in a . + declare -a types_that_require_dot=("CNAME MX NS PTR SRV") + if [[ " ${types_that_require_dot[*]} " =~ " ${type} " ]]; then + if [[ $resourcerecord != *\. ]]; then + resourcerecord="$resourcerecord." + fi + fi + + # quote TXT records + if [[ $type = "TXT" ]]; then + resourcerecord="\\\"$resourcerecord\\\"" + fi + + # set disabled status + if [[ $status = 1 ]]; then + disabled=true + else + disabled=false + fi + + data="$data{\"content\":\"$resourcerecord\",\"disabled\":$disabled}" + + if [[ $records_count < $resourcerecords_records_count ]]; then + data="$data," + fi + + done + + data="$data]}]}" + + # add record(s) + zone_status=$(/usr/bin/curl --silent --request PATCH --output "/tmp/$zone" --write-out "%{http_code}" --header "X-API-Key: $api_key" --data "$data" "$api_base_url/zones/$zone") + + if [[ $zone_status = 204 ]]; then + echo "Success. Record(s) for $zone created/updated." + else + echo "Error. http response updating record(s) for $zone was: $zone_status" + fi + +elif [[ $zone_status = 404 ]]; then + + echo "Zone $zone does not exist, can't update records." + +else + echo "Unexpected http response checking for Zone $zone: $zone_status" +fi + +rm /tmp/$zone diff --git a/bin/pdns-zone-add.sh b/bin/pdns-zone-add.sh new file mode 100755 index 0000000..828d4ec --- /dev/null +++ b/bin/pdns-zone-add.sh @@ -0,0 +1,160 @@ +#!/bin/bash +# +# pdns-tools +# https://git.stack-source.com/msb/pdns-tools +# MIT License Copyright (c) 2022 Matthew Saunders Brown + +# load include file +source $(dirname $0)/pdns.sh + +help() +{ + thisfilename=$(basename -- "$0") + echo "$thisfilename" + echo "Add new zone to DNS" + echo "" + echo "usage: $thisfilename -z [-m ] [-t ] [-a ] [-b] [-h]" + echo "" + echo " -h Print this help." + echo " -z Zone (domain name) to add." + echo " -m IP address of master, if this zone is of type SLAVE." + echo " -t Defaults to NATIVE. Can also be MASTER or SLAVE. If type is SLAVE be sure to set master IP." + echo " -a The account that controls this zones. For tying in to Control Panel or other custom admins." + echo " -b Bare - create zone without any Resource Records. Normally a default set of RR are created." +} + +pdns:getoptions "$@" + +# check for zone +if [[ -z $zone ]]; then + echo "zone is required" + exit +fi + +# first query to see if zone already exists +zone_status=$(/usr/bin/curl --silent --output /tmp/$zone.output --write-out "%{http_code}" -H "X-API-Key: $api_key" "$api_base_url/zones/$zone") + +if [[ $zone_status = 200 ]]; then + echo Zone $zone already exists. +elif [[ $zone_status = 404 ]]; then + + # zone does not exist, create new zone now + + # generate serial + serial=$(date +%Y%m%d00) + + # set type (kind). NATIVE (default), MASTER or SLAVE + if [[ -n $type ]]; then + kind=$type + else + kind="NATIVE" + fi + + # zone + data='{' + + if [[ -n $account ]]; then + data="$data\"account\":\"$account\"," + fi + data="$data\"name\":\"$zone.\"," + data="$data\"kind\":\"$kind\"," + if [[ -n $master ]]; then + data="$data\"masters\":[\"$master\"]," + fi + data="$data\"serial\":\"$serial\"," + data="$data\"nameservers\":[]," + data="$data\"rrsets\":[" + + # create SOA + data="$data{" + data="$data\"name\":\"${zone}.\"," + data="$data\"type\":\"SOA\"," + data="$data\"ttl\":${zone_defaults_ttl}," + data="$data\"records\":[" + data="$data{" + data="$data\"content\":\"$zone_default_ns. $zone_defaults_mbox. $serial $zone_defaults_refresh $zone_defaults_retry $zone_defaults_expire $zone_defaults_minimum\"", + data="$data\"disabled\":false" + data="$data}" + data="$data]" + + + if [[ -n $bare ]]; then + # do not add default records + data="$data}" + else + # add default records + data="$data}," + + # get number of default records to add + default_records_count=${#default_records[@]} + records_count=0 + + # add default records + for record in "${default_records[@]}"; do + + records_count=$((records_count+1)) + + # replace @ with zone + record=$(echo ${record} | sed -e "s/@/$zone/g") + + # turn record row info in to array + orig_ifs="$IFS" + IFS='|' + read -r -a recordArray <<< "$record" + IFS="$orig_ifs" + + # extract record info from array + rr_name=${recordArray[0]} + rr_type=${recordArray[1]} + rr_content=${recordArray[2]} + + # munge data as needed + if vmail::validate_domain $rr_content; then + rr_content="$rr_content." + fi + if [[ $rr_type = TXT ]]; then + rr_content="\\\"$rr_content\\\"" + fi + if [[ $rr_type = MX ]]; then + rr_content="$rr_content." + fi + + # add record + data="$data{" + data="$data\"name\":\"${rr_name}.\"," + data="$data\"type\":\"${rr_type}\"," + data="$data\"ttl\":${zone_defaults_ttl}," + data="$data\"records\":[" + data="$data{" + data="$data\"content\":\"${rr_content}\"", + data="$data\"disabled\":false" + data="$data}" + data="$data]" + + if [[ $records_count = $default_records_count ]]; then + data="$data}" + else + data="$data}," + fi + + done + + fi + + # close out data + data="$data]}" + + # add zone + zone_status=$(/usr/bin/curl --silent --request POST --output "/tmp/$zone.output" --write-out "%{http_code}" --header "X-API-Key: $api_key" --data "$data" "$api_base_url/zones") + + if [[ $zone_status = 201 ]]; then + echo Success. Zone $zone created. + else + echo Error. http response adding zone $zone was: $zone_status + fi + +else + echo Unexpected http response checking for Zone $zone: $zone_status +fi + +rm /tmp/$zone.output diff --git a/bin/pdns-zone-del.sh b/bin/pdns-zone-del.sh new file mode 100755 index 0000000..f6ef122 --- /dev/null +++ b/bin/pdns-zone-del.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# +# pdns-tools +# https://git.stack-source.com/msb/pdns-tools +# MIT License Copyright (c) 2022 Matthew Saunders Brown + +# load include file +source $(dirname $0)/pdns.sh + +help() +{ + thisfilename=$(basename -- "$0") + echo "$thisfilename" + echo "Delete a zone and all of it's records." + echo "" + echo "usage: $thisfilename -z [-x] [-h]" + echo "" + echo " -h Print this help." + echo " -z Zone (domain name) to delete." + echo " -x Execute (force) - don't prompt for confirmation." +} + +pdns:getoptions "$@" + +# check for zone +if [[ -z $zone ]]; then + echo "zone is required" + exit +fi + +if [[ -n $execute ]] || pdns::yesno "Delete $zone now?"; then + echo + zone_status=$(/usr/bin/curl --silent --output /tmp/$zone --write-out "%{http_code}" --request DELETE --header "X-API-Key: $api_key" $api_base_url/zones/$zone) + rm /tmp/$zone + + if [[ $zone_status = 204 ]]; then + echo Zone $zone deleted. + elif [[ $zone_status = 404 ]]; then + echo Zone $zone does not exist. + else + echo Error. http response deleting zone $zone was: $zone_status + fi +fi diff --git a/bin/pdns-zone-export.sh b/bin/pdns-zone-export.sh new file mode 100755 index 0000000..f382914 --- /dev/null +++ b/bin/pdns-zone-export.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# +# pdns-tools +# https://git.stack-source.com/msb/pdns-tools +# MIT License Copyright (c) 2022 Matthew Saunders Brown + +# load include file +source $(dirname $0)/pdns.sh + +help() +{ + thisfilename=$(basename -- "$0") + echo "$thisfilename" + echo "Export full DNS zone" + echo "" + echo "usage: $thisfilename -z [-h]" + echo "" + echo " -h Print this help." + echo " -z Zone to get." +} + +pdns:getoptions "$@" + +# check for zone +if [[ -z $zone ]]; then + echo "zone is required" + exit +fi + +TMPDIR=$(mktemp -d -p /tmp) + +# export zone and check http status +zone_status=$(/usr/bin/curl --silent --output "$TMPDIR/$zone" --write-out "%{http_code}" -H "X-API-Key: $api_key" $api_base_url/zones/$zone/export) + +if [[ $zone_status = 200 ]]; then + # return zone level records + sed -e 's/\t/|/g' $TMPDIR/$zone|column -t -s \| |grep ^$zone. + # return subdomain records + sed -e 's/\t/|/g' $TMPDIR/$zone|column -t -s \| |grep -v ^$zone. +elif [[ $zone_status = 404 ]]; then + echo 404 Not Found, $zone does not exist +else + echo Unexecpted http response checking for existence of zone $zone: $zone_status +fi + +rm $TMPDIR/$zone +rmdir $TMPDIR diff --git a/bin/pdns.sh b/bin/pdns.sh new file mode 100755 index 0000000..b59fea4 --- /dev/null +++ b/bin/pdns.sh @@ -0,0 +1,176 @@ +#!/bin/bash +# +# powerdns-tools +# https://git.stack-source.com/msb/powerdns-tools +# MIT License Copyright (c) 2022 Matthew Saunders Brown +# +# powerdns-tools include file, used by other powerdns-tools bash scripts + +# Must be root, attempt sudo if need be. root is not actually required for the API commands, but we want to restrict access. +if [ "${EUID}" -ne 0 ]; then + exec sudo -u root --shell /bin/bash $0 $@ +fi + +# Load local config +# Two variables, api_key & dns_domain, are only set in the local config +# Any of the other constants below can be overriden in the local config +if [[ -f /usr/local/etc/pdns.conf ]]; then + source /usr/local/etc/pdns.conf +fi + +# constants + +# API URL. Consider putting this behind a proxy with https on the front. +[[ -z $api_base_url ]] && api_base_url=http://127.0.0.1:8081/api/v1/servers/localhost + +# Default IP address to use for new zone records +# Defaults to IP of server script is run from +[[ -z $default_ip ]] && default_ip=$(hostname --ip-address) + +# Array of allowed Resource Record Types +[[ -z $rr_types ]] && declare -a rr_types=(A AAAA CNAME MX NS PTR SRV TXT) + +# Minimum/maximum values for SOA and RR records +[[ -z $min_ttl ]] && min_ttl=300 +[[ -z $max_ttl ]] && max_ttl=2419200 +[[ -z $min_refresh ]] && min_refresh=300 +[[ -z $min_retry ]] && min_retry=300 +[[ -z $min_expire ]] && min_expire=86400 + +# Default values for new zones. +[[ -z $zone_default_ns ]] && zone_default_ns="ns1.$dns_domain" +[[ -z $zone_defaults_mbox ]] && zone_defaults_mbox="hostmaster.$dns_domain" +[[ -z $zone_defaults_ttl ]] && zone_defaults_ttl='3600' +[[ -z $zone_defaults_refresh ]] && zone_defaults_refresh='86400' +[[ -z $zone_defaults_retry ]] && zone_defaults_retry='7200' +[[ -z $zone_defaults_expire ]] && zone_defaults_expire='1209600' +[[ -z $zone_defaults_minimum ]] && zone_defaults_minimum='3600' +readonly zone_defaults_pri='0' +# zone_defaults_pri must be 0, do not change + +# The following array specifies default records for new zone records. +# These get inserted automatically whenever a zone is created. +# The format of each record is (name|type|content). TTL is taken from defaults above. +# @ will be replace with the zone (domain name) +# TXT records will get quoted (don't add quotes around the content field here) +# Only MX & SRV records use priority, and it should be specified as part of the content/data field. + +[[ -z $default_records ]] && declare -a default_records=("@|A|$default_ip" + "@|MX|10 mail.@" + "@|NS|ns1.$dns_domain" + "@|NS|ns2.$dns_domain" + "@|NS|ns3.$dns_domain" + "@|TXT|v=spf1 a mx -all" + "mail.@|A|$default_ip" + "www.@|CNAME|@") + +# functions + +# crude but good enough domain name format validation +function vmail::validate_domain () { + local my_domain=$1 + if [[ $my_domain =~ ^(([a-zA-Z0-9](-?[a-zA-Z0-9])*)\.)+[a-zA-Z]{2,}\.?$ ]] ; then + return 0 + else + return 1 + fi +} + +# yesno prompt +# +# Examples: +# loop until y or n: if vmail::yesno "Continue?"; then +# default y: if vmail::yesno "Continue?" Y; then +# default n: if vmail::yesno "Continue?" N; then +function pdns::yesno() { + + local prompt default reply + + if [ "${2:-}" = "Y" ]; then + prompt="Y/n" + default=Y + elif [ "${2:-}" = "N" ]; then + prompt="y/N" + default=N + else + prompt="y/n" + default= + fi + + while true; do + + read -p "$1 [$prompt] " -n 1 -r reply + + # Default? + if [ -z "$reply" ]; then + reply=$default + fi + + # Check if the reply is valid + case "$reply" in + Y*|y*) return 0 ;; + N*|n*) return 1 ;; + esac + + done + +} + +function pdns:getoptions () { + local OPTIND + while getopts "hbz:m:t:a:n:c:l:s:x" opt ; do + case "${opt}" in + h ) # display help and exit + help + exit + ;; + b ) # bare - create empty zone, do not any default records + bare=true + ;; + z ) # zone + zone=${OPTARG,,} + # pdns stores zone name without trailing dot, remove if found + if [[ ${zone: -1} = '.' ]]; then + zone=${zone::-1} + fi + if ! vmail::validate_domain $zone; then + echo "ERROR: $zone is not a valid domain name." + exit + fi + ;; + m ) # master - IP of master if this is a slave zone + master=${OPTARG} + ;; + t ) # type + type=${OPTARG^^} + ;; + a ) # account + account=${OPTARG} + ;; + n ) # name - hostname for this record + name=${OPTARG,,} + ;; + c ) # content - record data + content=${OPTARG} + ;; + l ) # ttl - Time To Live + ttl=${OPTARG} + ;; + s ) # status - disabled status, 0 for active 1 for inactive, default 0 + status=${OPTARG} + ;; + x ) # eXecute - don't prompt for confirmation + execute=true + ;; + \? ) + echo "Invalid option: $OPTARG" + exit 1 + ;; + : ) + echo "Invalid option: $OPTARG requires an argument" + exit 1 + ;; + esac + done + shift $((OPTIND-1)) +} diff --git a/etc/pdns.conf b/etc/pdns.conf new file mode 100644 index 0000000..72dad5c --- /dev/null +++ b/etc/pdns.conf @@ -0,0 +1,3 @@ +# API key +api_key=changeme +dns_domain=example.com diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..1295930 --- /dev/null +++ b/install.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +chmod 755 bin/* +cp bin/* /usr/local/bin/ + +if [[ ! -f /usr/local/etc/pdns.conf ]]; then + chmod 640 etc/pdns.conf + cp etc/pdns.conf /usr/local/etc/pdns.conf + echo "Install complete, but sure to update /usr/local/etc/pdns.conf with your settings." +fi