504 lines
16 KiB
Bash
Executable file
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 "$@"
|