#!/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}" </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 "$@"