dnsdrone/dnsdrone.sh
2025-03-05 11:15:22 +01:00

504 lines
16 KiB
Bash
Executable file

#!/usr/bin/env bash
set -u # Exit on undefined variable
set -o pipefail # Exit on pipe failures
DEBUG=0
DRY_RUN=0
CURRENT_VERSION="v1.1"
FORGEJO_API_URL=${FORGEJO_API_URL:-"https://forge.dmz.skyfritt.net"}
FORGEJO_ORG=${FORGEJO_ORG:-"Skyfritt"}
FORGEJO_REPO=${FORGEJO_REPO:-"dnsdrone"}
AUTO_UPDATE=${AUTO_UPDATE:-0}
CHECK_UPDATE=${CHECK_UPDATE:-0}
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}"
API_VERSION=${API_VERSION:-2.253}
IPCALC_CMD=${IPCALC_CMD:-false}
RECORD_NAME=${RECORD_NAME:-false}
error() {
echo "[ERROR] $1" >&2
exit 1
}
debug() {
[[ $DEBUG -eq 1 ]] && echo "[DEBUG] $1"
}
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
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 linkName realName envFile=""
linkName="$(basename "$0")"
realName="$(basename "$(realpath "$0")")"
local -r skyfrittEnvPath="/opt/skyfritt-tools-env"
local -a searchPaths=(
"$(pwd)/.env"
"$(dirname "$(realpath "$0")")/.env"
"$(dirname "$(realpath "$0")")/.env.${realName}"
"$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")/.env.${linkName}"
"${skyfrittEnvPath}/.env.${linkName}"
)
debug "Checking for .env file in multiple locations"
for path in "${searchPaths[@]}"; do
if [[ -f "$path" ]]; then
envFile="$path"
debug "Found .env file: ${envFile}"
break
fi
done
[[ -z "$envFile" ]] && error "No .env file found in search paths"
[[ ! -r "$envFile" ]] && error "Found .env file at ${envFile} but it's not readable"
# shellcheck source=/dev/null
if ! source "$envFile"; then
error "Failed to source .env file: ${envFile}"
fi
# Validate required environment variables
local -a required_vars=("SERVER" "ZONE_NAME")
local missing_vars=()
for var in "${required_vars[@]}"; do
if [[ -z "${!var:-}" ]]; then
missing_vars+=("$var")
fi
done
# Check for missing required variables
if [[ ${#missing_vars[@]} -gt 0 ]]; then
error "Missing required variables in .env file: ${missing_vars[*]}"
fi
# 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 in .env file"
fi
# Validate values if present
[[ -n "${RECORD_TTL:-}" ]] && [[ ! "${RECORD_TTL}" =~ ^[0-9]+$ ]] && \
error "RECORD_TTL must be a positive integer"
# Additional validation for specific record types
if [[ "${RECORD_TXT_PREFIX:-false}" == "true" ]]; then
[[ -z "${ISP_PREFIX_LENGHT:-}" ]] && \
error "ISP_PREFIX_LENGHT must be set when RECORD_TXT_PREFIX is true"
[[ ! "${ISP_PREFIX_LENGHT}" =~ ^[0-9]+$ ]] && \
error "ISP_PREFIX_LENGHT must be a positive integer"
fi
debug "Environment loaded successfully"
}
# 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"
)
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
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
if [[ -z "$ipv4" && -z "$ipv6" ]]; then
error "Failed to get any IP addresses from providers"
fi
echo "$ipv4:$ipv6"
}
get_dns_records() {
local record_type=$1
local result
result=$(dig +short "$record_type" "$FQDN" "@${SERVER}" 2>/dev/null || true)
echo "$result"
}
get_ptr_record() {
local ip=$1
local result
result=$(dig +short -x "$ip" "@${SERVER}" 2>/dev/null || true)
echo "$result"
}
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
[[ -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
# 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=$?
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"
if [[ $curl_exit_code -ne 0 ]]; then
error "Curl command failed with exit code $curl_exit_code: $data"
fi
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"
}
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
}
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
}
version_gt() {
test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"
}
check_for_updates() {
local latest_version
local api_endpoint="${FORGEJO_API_URL}/api/v1/repos/${FORGEJO_ORG}/${FORGEJO_REPO}/releases/latest"
debug "Checking for updates from: $api_endpoint"
latest_version=$(curl -s "$api_endpoint" | jq -r '.tag_name' 2>/dev/null)
if [[ -z "$latest_version" ]]; then
error "Failed to fetch latest version information"
fi
debug "Current version: $CURRENT_VERSION"
debug "Latest version: $latest_version"
if version_gt "$latest_version" "$CURRENT_VERSION"; then
echo "Update available: $CURRENT_VERSION$latest_version"
return 0
else
echo "You are running the latest version ($CURRENT_VERSION)"
return 1
fi
}
perform_update() {
local latest_version
local api_endpoint="${FORGEJO_API_URL}/api/v1/repos/${FORGEJO_ORG}/${FORGEJO_REPO}/releases/latest"
local raw_download_url
local current_script_path
local temp_file
latest_version=$(curl -s "$api_endpoint" | jq -r '.tag_name' 2>/dev/null)
if [[ -z "$latest_version" ]]; then
error "Failed to get latest version information. Is the repo public?"
fi
raw_download_url="${FORGEJO_API_URL}/${FORGEJO_ORG}/${FORGEJO_REPO}/raw/tag/${latest_version}/dnsdrone.sh"
debug "Downloading from: $raw_download_url"
temp_file=$(mktemp)
current_script_path=$(realpath "$0")
echo "Downloading update..."
if ! curl -s -L "$raw_download_url" -o "$temp_file"; then
rm -f "$temp_file"
error "Failed to download update"
fi
if ! bash -n "$temp_file"; then
rm -f "$temp_file"
error "Downloaded file appears to be invalid"
fi
cp "$current_script_path" "${current_script_path}.backup"
if ! mv "$temp_file" "$current_script_path"; then
cp "${current_script_path}.backup" "$current_script_path"
rm -f "$temp_file"
error "Failed to install update"
fi
chmod +x "$current_script_path"
echo "Successfully updated to version $latest_version"
echo "Previous version backed up to ${current_script_path}.backup"
exit 0
}
main() {
while getopts "dnac" opt; do
case $opt in
d) DEBUG=1 ;;
n) DRY_RUN=1 ;;
a) AUTO_UPDATE=1 ;;
c) CHECK_UPDATE=1 ;;
*) error "Invalid option: -$OPTARG" ;;
esac
done
if [[ $CHECK_UPDATE -eq 1 ]]; then
check_for_updates
exit $?
fi
if [[ $AUTO_UPDATE -eq 1 ]]; then
if check_for_updates; then
perform_update
fi
fi
trap 'error "Script interrupted"' INT TERM
check_dependencies
load_env
if [[ $RECORD_NAME = false ]]; then
local FQDN="${ZONE_NAME}."
else
local FQDN="${RECORD_NAME}.${ZONE_NAME}."
fi
IFS=':' read -r current_ipv4 current_ipv6 <<< "$(get_current_ips)" || true
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
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
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
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 "$@"