diff --git a/README.md b/README.md index 55768a1..6b1f2fc 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,15 @@ # DNSDrone -DNSDrone is a Bash script that automatically updates DNS records (A, AAAA, and PTR) in FreeIPA or standard DNS servers using nsupdate. It detects your current public IPv4 and IPv6 addresses and updates the configured DNS records when changes are detected. +DNSDrone is a Bash script that automatically updates DNS records (A, AAAA, PTR, and TXT) in FreeIPA or standard DNS servers using nsupdate. It detects your current public IPv4 and IPv6 addresses and updates the configured DNS records when changes are detected. ## Features -- Supports both IPv4 (A) and IPv6 (AAAA) records -- Optional PTR record management for both IPv4 and IPv6 -- Optional TXT record management for IPv6 prefix tracking -- Supports authentication via FreeIPA user/password or TSIG keys -- Configurable through environment variables -- Debug mode for troubleshooting -- Dry run mode for testing -- Flexible .env file location detection -- Multiple IP address providers for redundancy -- Support for updating zone apex (root domain) +- Full DNS record management (A, AAAA, PTR, and TXT records with IPv6 prefix tracking) +- Dual authentication support (FreeIPA user/password or TSIG keys) +- Flexible configuration with environment variables and multiple .env file locations +- Advanced operational modes (debug, dry-run, auto-update) +- Multiple IP provider fallback for reliable address detection +- Support for zone apex (root domain) updates ## Prerequisites @@ -96,3 +92,4 @@ RECORD_PTR4=false RECORD_PTR6=false RECORD_TXT_PREFIX=true ISP_PREFIX_LENGHT=56 +``` diff --git a/dnsdrone.sh b/dnsdrone.sh index a7da2c4..869f2c2 100755 --- a/dnsdrone.sh +++ b/dnsdrone.sh @@ -1,40 +1,35 @@ #!/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 +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} -# 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 @@ -44,7 +39,6 @@ check_dependencies() { 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" @@ -60,40 +54,69 @@ check_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 "$0")")/.env.${linkName}" - "${skyfrittEnvPath}/.env.${linkName}" -) + 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" + 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 + 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 -done - -# 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" + 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" + 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 @@ -113,7 +136,6 @@ get_current_ips() { "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) @@ -123,7 +145,6 @@ get_current_ips() { 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) @@ -133,7 +154,6 @@ get_current_ips() { 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 @@ -141,7 +161,6 @@ get_current_ips() { echo "$ipv4:$ipv6" } -# Function to get DNS records get_dns_records() { local record_type=$1 local result @@ -149,7 +168,6 @@ get_dns_records() { echo "$result" } -# Function to get PTR record get_ptr_record() { local ip=$1 local result @@ -157,8 +175,6 @@ get_ptr_record() { 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 @@ -174,7 +190,6 @@ update_freeipa_record() { return fi - # Validate inputs [[ -z "$record_type" ]] && error "Record type cannot be empty" [[ -z "$ip" ]] && error "IP address cannot be empty" @@ -219,7 +234,7 @@ update_freeipa_record() { ip="\\\"${ip}\\\"" fi - # Now make the API call with the cookie + # Make the API call with the cookie curl_output=$(curl -s -k \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ @@ -231,7 +246,6 @@ update_freeipa_record() { "https://${SERVER}/ipa/session/json" 2>&1) curl_exit_code=$? - # Cleanup cookie file rm -f "${cookie_file}" # Parse HTTP status code from last line @@ -242,12 +256,10 @@ update_freeipa_record() { 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 @@ -263,7 +275,6 @@ update_freeipa_record() { debug "DNS record update completed successfully" } -# Function to update nsupdate record update_nsupdate_record() { local record_type=$1 local ip=$2 @@ -296,7 +307,6 @@ send EOF } -# Main update function update_record() { local record_type=$1 local current_ip=$2 @@ -319,47 +329,126 @@ update_record() { fi } -# Main execution +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() { - # Process flags - while getopts "dn" opt; do + 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 - # 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" @@ -390,7 +479,6 @@ main() { 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