dnsdrone/dnsdrone.sh
2025-03-04 14:53:24 +01:00

426 lines
14 KiB
Bash
Executable file

#!/usr/bin/env bash
# TODO: Make hierarchy of where to check for .env.* files.
#
# Exit on any error
# set -e
set -u # Exit on undefined variable
set -o pipefail # Exit on pipe failures
# Debug flag
DEBUG=0
DRY_RUN=0
# Default values
RECORD_A=${RECORD_A:-false}
RECORD_AAAA=${RECORD_AAAA:-false}
RECORD_PTR4=${RECORD_PTR4:-false}
RECORD_PTR6=${RECORD_PTR6:-false}
ISP_PREFIX_LENGHT="${ISP_PREFIX_LENGHT:-false}"
# TXT_PREFIX_NAME="${TXT_PREFIX_NAME:-false}"
API_VERSION=${API_VERSION:-2.253}
IPCALC_CMD=${IPCALC_CMD:-false}
RECORD_NAME=${RECORD_NAME:-false}
# Error handling function
error() {
echo "[ERROR] $1" >&2
exit 1
}
# Function to handle debug output
debug() {
[[ $DEBUG -eq 1 ]] && echo "[DEBUG] $1"
}
# Function to check dependencies
check_dependencies() {
local missing=0
for cmd in curl jq dig nsupdate; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "Error: $cmd is required but not installed." >&2
missing=1
fi
done
# Check for ipcalc-ng first, fall back to ipcalc
if command -v ipcalc-ng >/dev/null 2>&1; then
debug "Using ipcalc-ng"
IPCALC_CMD="ipcalc-ng"
elif command -v ipcalc >/dev/null 2>&1; then
debug "Using ipcalc"
IPCALC_CMD="ipcalc"
else
echo "Error: either ipcalc or ipcalc-ng is required but not installed." >&2
missing=1
fi
[[ $missing -eq 1 ]] && error "Missing required dependencies"
}
load_env() {
local fullRealPath fullLinkPath realPath linkPath realName linkName callDir
linkName="$(basename $0)"
fullLinkPath="$(readlink "$0")"
fullRealPath="$(realpath "$0")"
realName="$(basename "$fullRealPath")"
realPath="$(dirname "$fullRealPath")"
linkPath="$(dirname "$fullLinkPath")"
callDir="$(pwd)"
local skyfrittEnvPath="/opt/skyfritt-tools-env"
local envFile=""
debug "Checking for .env file in multiple locations"
if [[ -f "${callDir}/.env" ]]; then # Check in call directory
envFile="${callDir}/.env"
debug "Found .env in call directory: ${envFile}"
elif [[ -f "${realPath}/.env" ]]; then # Check in real path directory
envFile="${realPath}/.env"
debug "Found .env in real path directory: ${envFile}"
elif [[ -f "${realPath}/.env.${realName}" ]]; then # Check for .env.realName in real path
envFile="${realPath}/.env.${realName}"
debug "Found .env.${realName} in real path: ${envFile}"
elif [[ -f "${linkPath}/.env.${linkName}" ]]; then # Check for .env.linkName in link path
envFile="${linkPath}/.env.${linkName}"
debug "Found .env.${linkName} in link path: ${envFile}"
elif [[ -f "${skyfrittEnvPath}/.env.${linkName}" ]]; then # Check in skyfritt env path
envFile="${skyfrittEnvPath}/.env.${linkName}"
debug "Found .env.${linkName} in skyfritt path: ${envFile}"
fi
[[ -z "$envFile" ]] && error ".env file not found in any location"
# shellcheck source=/dev/null
source "$envFile" || error "Failed to source .env file"
# Validate required environment variables
[[ -z "${SERVER:-}" ]] && error "SERVER not set in .env"
[[ -z "${ZONE_NAME:-}" ]] && error "ZONE_NAME not set in .env"
# [[ -z "${RECORD_NAME:-}" ]] && error "RECORD_NAME not set in .env"
# Validate authentication method
if [[ -z "${USER:-}" || -z "${PASSWORD:-}" ]] && [[ -z "${TSIG_KEYNAME:-}" || -z "${TSIG_KEY:-}" ]]; then
error "Either USER/PASSWORD or TSIG_KEYNAME/TSIG_KEY must be set"
fi
}
# Function to get current IPs
get_current_ips() {
local ipv4="" ipv6=""
local ipv4_providers=(
"ifconfig.me"
"api.ipify.org"
"icanhazip.com"
"ipecho.net/plain"
"checkip.amazonaws.com"
)
local ipv6_providers=(
"ifconfig.me"
"api6.ipify.org"
"icanhazip.com"
"v6.ident.me"
)
# Try IPv4 providers
for provider in "${ipv4_providers[@]}"; do
local temp_ipv4
temp_ipv4=$(curl -s4 --connect-timeout 5 "$provider" 2>/dev/null)
if [[ -n "$temp_ipv4" ]]; then
ipv4=$temp_ipv4
break
fi
done
# Try IPv6 providers
for provider in "${ipv6_providers[@]}"; do
local temp_ipv6
temp_ipv6=$(curl -s6 --connect-timeout 5 "$provider" 2>/dev/null)
if [[ -n "$temp_ipv6" ]]; then
ipv6=$temp_ipv6
break
fi
done
# Only error if both stacks are unavailable
if [[ -z "$ipv4" && -z "$ipv6" ]]; then
error "Failed to get any IP addresses from providers"
fi
echo "$ipv4:$ipv6"
}
# Function to get DNS records
get_dns_records() {
local record_type=$1
local result
result=$(dig +short "$record_type" "$FQDN" "@${SERVER}" 2>/dev/null || true)
echo "$result"
}
# Function to get PTR record
get_ptr_record() {
local ip=$1
local result
result=$(dig +short -x "$ip" "@${SERVER}" 2>/dev/null || true)
echo "$result"
}
# Function to update FreeIPA DNS record
# IMPORTANT! The records have to exists, since we are using the api call dnsrecord_mod.
update_freeipa_record() {
local record_type=$1
local ip=$2
debug "Updating FreeIPA $record_type record to $ip"
debug "Using server: ${SERVER}"
debug "Zone: ${ZONE_NAME}"
debug "Record: ${RECORD_NAME}"
debug "API Version: ${API_VERSION}"
if [[ $DRY_RUN -eq 1 ]]; then
echo "[DRY RUN] Would update FreeIPA $record_type record for ${FQDN} to $ip"
return
fi
# Validate inputs
[[ -z "$record_type" ]] && error "Record type cannot be empty"
[[ -z "$ip" ]] && error "IP address cannot be empty"
local data
local curl_exit_code
local curl_output
local cookie_file
local record_name
cookie_file=$(mktemp)
if [[ $RECORD_NAME == false ]]; then
record_name="@" # Empty string for root record
else
record_name="$RECORD_NAME"
fi
# First authenticate and get cookie
curl_output=$(curl -s -k \
-H "Accept: text/plain" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Referer: https://${SERVER}/ipa" \
-H "X-IPA-API-Version: ${API_VERSION}" \
--data-urlencode "user=${USER}" \
--data-urlencode "password=${PASSWORD}" \
-c "${cookie_file}" \
--write-out "\n%{http_code}" \
"https://${SERVER}/ipa/session/login_password" 2>&1)
curl_exit_code=$?
# Parse HTTP status code from login
local login_code
login_code=$(echo "$curl_output" | tail -n1)
# Check login success
if [[ $curl_exit_code -ne 0 || $login_code -ne 200 ]]; then
rm -f "${cookie_file}"
error "Login failed with status $login_code: $(echo "$curl_output" | sed '$d')"
fi
# If record type is TXT, wrap the IP in escaped quotes
if [[ "${record_type}" == "TXT" ]]; then
ip="\\\"${ip}\\\""
fi
# Now make the API call with the cookie
curl_output=$(curl -s -k \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Referer: https://${SERVER}/ipa" \
-H "X-IPA-API-Version: ${API_VERSION}" \
-b "${cookie_file}" \
--write-out "\n%{http_code}" \
--data "{\"method\":\"dnsrecord_mod\",\"params\":[[\"${ZONE_NAME}\",\"${record_name}\"],{\"${record_type,,}record\":\"${ip}\", \"dnsttl\":${RECORD_TTL:-60}}]}" \
"https://${SERVER}/ipa/session/json" 2>&1)
curl_exit_code=$?
# Cleanup cookie file
rm -f "${cookie_file}"
# Parse HTTP status code from last line
local http_code
http_code=$(echo "$curl_output" | tail -n1)
data=$(echo "$curl_output" | sed '$d')
debug "Curl exit code: $curl_exit_code"
debug "HTTP status code: $http_code"
# Check curl execution
if [[ $curl_exit_code -ne 0 ]]; then
error "Curl command failed with exit code $curl_exit_code: $data"
fi
# Check HTTP status
if [[ $http_code -ne 200 ]]; then
error "HTTP request failed with status $http_code: $data"
fi
# Check for error in JSON response
if echo "$data" | jq -e '.error' >/dev/null 2>&1; then
local error_msg
error_msg=$(echo "$data" | jq -r '.error.message // "Unknown error"')
error "FreeIPA API returned error: $error_msg"
fi
debug "FreeIPA response: $data"
debug "DNS record update completed successfully"
}
# Function to update nsupdate record
update_nsupdate_record() {
local record_type=$1
local ip=$2
local ttl=${RECORD_TTL:-60} # Default TTL of 60 seconds if not set in .env
debug "Updating nsupdate $record_type record to $ip with TTL $ttl"
if [[ $DRY_RUN -eq 1 ]]; then
echo "[DRY RUN] Would update nsupdate $record_type record for ${FQDN} to $ip with TTL $ttl"
return
fi
# Create nsupdate commands based on record type
nsupdate -y "${TSIG_KEY_TYPE}:${TSIG_KEYNAME}:${TSIG_KEY}" <<EOF || error "nsupdate failed"
server ${SERVER}
zone ${ZONE_NAME}.
$(if [[ "$record_type" == "A" && "${RECORD_A:-false}" == "true" ]]; then
echo "update delete ${FQDN} A"
echo "update add ${FQDN} ${ttl} A ${ip}"
fi)
$(if [[ "$record_type" == "AAAA" && "${RECORD_AAAA:-false}" == "true" ]]; then
echo "update delete ${FQDN} AAAA"
echo "update add ${FQDN} ${ttl} AAAA ${ip}"
fi)
$(if [[ "$record_type" == "TXT" && "${RECORD_TXT_PREFIX:-false}" == "true" ]]; then
echo "update delete ${FQDN} TXT"
echo "update add ${FQDN} ${ttl} TXT ${ip}"
fi)
send
EOF
}
# Main update function
update_record() {
local record_type=$1
local current_ip=$2
local dns_ip
dns_ip=$(get_dns_records "$record_type")
debug "Checking $record_type record - Current DNS: '$dns_ip', New record: '$current_ip'"
if [[ "$dns_ip" != "$current_ip" ]]; then
debug "Updating $record_type record from '$dns_ip' to '$current_ip'"
if [[ -n "${USER:-}" && -n "${PASSWORD:-}" ]]; then
update_freeipa_record "$record_type" "$current_ip"
elif [[ -n "${TSIG_KEYNAME:-}" && -n "${TSIG_KEY:-}" ]]; then
update_nsupdate_record "$record_type" "$current_ip"
else
error "No valid authentication method available"
fi
else
debug "No update needed for $record_type record"
fi
}
# Main execution
main() {
# Process flags
while getopts "dn" opt; do
case $opt in
d) DEBUG=1 ;;
n) DRY_RUN=1 ;;
*) error "Invalid option: -$OPTARG" ;;
esac
done
trap 'error "Script interrupted"' INT TERM
check_dependencies
load_env
# Construct the full domain name
if [[ $RECORD_NAME = false ]]; then
local FQDN="${ZONE_NAME}."
else
local FQDN="${RECORD_NAME}.${ZONE_NAME}."
fi
# Get current IPs
IFS=':' read -r current_ipv4 current_ipv6 <<< "$(get_current_ips)" || true
# Update A record if IPv4 is available
if [[ -n "$current_ipv4" && "${RECORD_A:-false}" == "true" ]]; then
update_record "A" "$current_ipv4"
elif [[ "${RECORD_A:-false}" == "true" ]]; then
debug "Skipping A record update - no IPv4 address available"
fi
# Update AAAA record if IPv6 is available
if [[ -n "$current_ipv6" && "${RECORD_AAAA:-false}" == "true" ]]; then
update_record "AAAA" "$current_ipv6"
elif [[ "${RECORD_AAAA:-false}" == "true" ]]; then
debug "Skipping AAAA record update - no IPv6 address available"
fi
# Check and update PTR records if configured and IP is available
if [[ "${RECORD_PTR4:-false}" == "true" ]]; then
if [[ -n "$current_ipv4" ]]; then
debug "Processing PTR record for IPv4"
local expected_ptr="${RECORD_NAME}.${ZONE_NAME}."
local current_ptr4
current_ptr4=$(get_ptr_record "$current_ipv4")
debug "Current PTR4 record: $current_ptr4"
if [[ "$current_ptr4" != "$expected_ptr" ]]; then
update_record "PTR" "$expected_ptr"
fi
else
debug "Skipping PTR4 record update - no IPv4 address available"
fi
fi
if [[ "${RECORD_PTR6:-false}" == "true" ]]; then
if [[ -n "$current_ipv6" ]]; then
debug "Processing PTR record for IPv6"
local expected_ptr="${RECORD_NAME}.${ZONE_NAME}."
local current_ptr6
current_ptr6=$(get_ptr_record "$current_ipv6")
debug "Current PTR6 record: $current_ptr6"
if [[ "$current_ptr6" != "$expected_ptr" ]]; then
update_record "PTR" "$expected_ptr"
fi
else
debug "Skipping PTR6 record update - no IPv6 address available"
fi
fi
# Check for, and update TXT record with IPv6 prefix if IPv6 is available
if [[ "${RECORD_TXT_PREFIX:-false}" == true ]]; then
if [[ -n "$current_ipv6" ]]; then
local expected_network_address expected_network_address_without_prefix current_network_address current_network_address_quotes
current_network_address_quotes=$(get_dns_records "TXT")
current_network_address=${current_network_address_quotes//\"/}
expected_network_address_without_prefix="$($IPCALC_CMD "$current_ipv6"/"$ISP_PREFIX_LENGHT" --network --no-decorate)"
expected_network_address="${expected_network_address_without_prefix}/${ISP_PREFIX_LENGHT}"
debug "Current network address: ${current_network_address}"
debug "Expected network address: ${expected_network_address}"
if [[ "$current_network_address" != "$expected_network_address" ]]; then
debug "Current network address differs from the expected. Updating."
update_record "TXT" "$expected_network_address"
else
debug "Current network address is the same as expected. Doing nothing."
fi
else
debug "Skipping TXT record update - no IPv6 address available"
fi
fi
}
main "$@"