384 lines
16 KiB
Bash
Executable file
384 lines
16 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
#
|
|
# Porkbun Dynamic DNS Update Script
|
|
# Updates DNS A records with current public IP address
|
|
# Supports multi-machine failover configurations
|
|
#
|
|
# Author: Auto-generated
|
|
# License: MIT
|
|
|
|
# Exit on error, undefined variables, and pipe failures
|
|
set -euo pipefail
|
|
# NOTE:
|
|
# - Critical operations should use explicit error checks: `if ! cmd; then ...; fi`
|
|
# - Some calls intentionally suppress errors with `||` to allow fallbacks (e.g. IP detection)
|
|
# - Be careful adding new commands to conditionals or pipelines; unexpected `set -e` behavior
|
|
# can cause early exits if errors aren't handled explicitly.
|
|
|
|
# Trap errors and cleanup (final safety net; most errors are handled explicitly)
|
|
trap 'echo "Error on line $LINENO. Exit code: $?" >&2' ERR
|
|
|
|
# Load environment variables from .env file
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
if [ -f "$SCRIPT_DIR/.env" ]; then
|
|
set -a # Mark variables for export
|
|
# Info-level log: configuration file found
|
|
printf '%s [INFO] Using environment file: %s/.env\n' "$(date)" "$SCRIPT_DIR" >&2
|
|
source "$SCRIPT_DIR/.env"
|
|
set +a # Stop marking variables for export
|
|
else
|
|
printf '%s [ERROR] .env file not found in %s\n' "$(date)" "$SCRIPT_DIR" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Validate required environment variables without leaking secrets
|
|
if [ -z "${API_KEY:-}" ] || [ -z "${SECRET_KEY:-}" ] || [ -z "${DOMAIN:-}" ]; then
|
|
printf '%s [ERROR] API_KEY, SECRET_KEY, and DOMAIN must be set in .env file\n' "$(date)" >&2
|
|
printf '%s [ERROR] Current values:\n' "$(date)" >&2
|
|
if [ -n "${API_KEY:-}" ]; then
|
|
printf '%s [ERROR] API_KEY=<set>\n' "$(date)" >&2
|
|
else
|
|
printf '%s [ERROR] API_KEY=<not set>\n' "$(date)" >&2
|
|
fi
|
|
if [ -n "${SECRET_KEY:-}" ]; then
|
|
printf '%s [ERROR] SECRET_KEY=<set>\n' "$(date)" >&2
|
|
else
|
|
printf '%s [ERROR] SECRET_KEY=<not set>\n' "$(date)" >&2
|
|
fi
|
|
printf '%s [ERROR] DOMAIN=%s\n' "$(date)" "${DOMAIN:-<not set>}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Warn if .env permissions are too permissive
|
|
if [ -f "$SCRIPT_DIR/.env" ]; then
|
|
env_perms=$(stat -c '%a' "$SCRIPT_DIR/.env" 2>/dev/null || echo "")
|
|
if [ -n "$env_perms" ] && [ "$env_perms" -gt 600 ]; then
|
|
printf '%s [WARN] %s/.env has permissions %s; consider restricting to 600 to protect secrets\n' "$(date)" "$SCRIPT_DIR" "$env_perms" >&2
|
|
fi
|
|
fi
|
|
|
|
# MACHINE_ID is required for multi-machine setups
|
|
if [ -z "${MACHINE_ID:-}" ]; then
|
|
printf '%s [WARN] MACHINE_ID not set in .env file\n' "$(date)" >&2
|
|
printf '%s [INFO] Using hostname as MACHINE_ID: %s\n' "$(date)" "$(hostname)" >&2
|
|
MACHINE_ID="$(hostname)"
|
|
fi
|
|
|
|
# Validate MACHINE_ID doesn't contain special characters that could cause issues
|
|
if [[ "$MACHINE_ID" =~ [^a-zA-Z0-9_-] ]]; then
|
|
printf '%s [ERROR] MACHINE_ID contains invalid characters. Only alphanumeric, dash, and underscore allowed.\n' "$(date)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
PORKBUN_API_URL="https://api-ipv4.porkbun.com/api/json/v3"
|
|
NOTES_PREFIX="ddns:"
|
|
# Fail on transient HTTP error bodies so retries cannot append HTML to JSON output.
|
|
CURL_RETRY_ARGS=(--silent --show-error --fail --retry 3 --retry-delay 2)
|
|
|
|
# Optional dry-run mode. When DRY_RUN=1, destructive or mutating API calls
|
|
# (dns/create, dns/delete) are logged but not executed.
|
|
DRY_RUN=${DRY_RUN:-0}
|
|
|
|
# Check for required commands
|
|
for cmd in curl jq; do
|
|
if ! command -v "$cmd" &> /dev/null; then
|
|
printf '%s [ERROR] Required command %s not found. Please install it.\n' "$(date)" "$cmd" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
function get_public_ip() {
|
|
local ip=""
|
|
|
|
# Try Porkbun's ping endpoint first (most reliable for their API)
|
|
local auth_json
|
|
if ! auth_json=$(jq -n \
|
|
--arg apikey "$API_KEY" \
|
|
--arg secretapikey "$SECRET_KEY" \
|
|
'{apikey: $apikey, secretapikey: $secretapikey}' 2>&1); then
|
|
printf '%s [ERROR] Failed to create auth JSON: %s\n' "$(date)" "$auth_json" >&2
|
|
exit 1
|
|
fi
|
|
|
|
local ping_response
|
|
ping_response=$(curl "${CURL_RETRY_ARGS[@]}" --max-time 10 -X POST "$PORKBUN_API_URL/ping" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$auth_json" 2>&1 || echo "")
|
|
|
|
if [ -n "$ping_response" ]; then
|
|
ip=$(echo "$ping_response" | jq -r '.yourIp // empty' 2>/dev/null || echo "")
|
|
if [ -n "$ip" ]; then
|
|
printf '%s [INFO] IP source: Porkbun API (/ping)\n' "$(date)" >&2
|
|
fi
|
|
fi
|
|
|
|
# Fallback methods if Porkbun ping fails
|
|
if [ -z "$ip" ]; then
|
|
ip=$(curl "${CURL_RETRY_ARGS[@]}" --max-time 5 https://api.ipify.org 2>/dev/null || echo "")
|
|
if [ -n "$ip" ]; then
|
|
printf '%s [INFO] IP source: api.ipify.org\n' "$(date)" >&2
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$ip" ]; then
|
|
ip=$(curl "${CURL_RETRY_ARGS[@]}" --max-time 5 https://icanhazip.com 2>/dev/null || echo "")
|
|
if [ -n "$ip" ]; then
|
|
printf '%s [INFO] IP source: icanhazip.com\n' "$(date)" >&2
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$ip" ]; then
|
|
printf '%s [ERROR] Failed to retrieve public IP address from any source\n' "$(date)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Validate IP format (basic IPv4 validation)
|
|
if ! [[ "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
|
printf '%s [ERROR] Invalid IP address format: %s\n' "$(date)" "$ip" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "$ip"
|
|
}
|
|
|
|
function register_host_dns() {
|
|
local host="$1"
|
|
local domain="$2"
|
|
local public_ip="$3"
|
|
|
|
# Validate inputs
|
|
if [ -z "$domain" ]; then
|
|
printf '%s [ERROR] Domain parameter is empty\n' "$(date)" >&2
|
|
return 1
|
|
fi
|
|
|
|
if [ -z "$public_ip" ]; then
|
|
printf '%s [ERROR] Public IP parameter is empty\n' "$(date)" >&2
|
|
return 1
|
|
fi
|
|
|
|
# Build FQDN for display
|
|
local fqdn
|
|
if [ -z "$host" ]; then
|
|
fqdn="$domain"
|
|
else
|
|
fqdn="${host}.${domain}"
|
|
fi
|
|
|
|
printf '%s [INFO] Processing DNS record for: %s (IP: %s)\n' "$(date)" "$fqdn" "$public_ip"
|
|
printf '%s [INFO] Machine ID: %s\n' "$(date)" "$MACHINE_ID"
|
|
|
|
# Create auth JSON for Porkbun API
|
|
local auth_json
|
|
if ! auth_json=$(jq -n \
|
|
--arg apikey "$API_KEY" \
|
|
--arg secretapikey "$SECRET_KEY" \
|
|
'{apikey: $apikey, secretapikey: $secretapikey}' 2>&1); then
|
|
printf '%s [ERROR] Failed to create auth JSON: %s\n' "$(date)" "$auth_json" >&2
|
|
return 1
|
|
fi
|
|
|
|
# Retrieve all DNS records for the domain
|
|
local records
|
|
if ! records=$(curl "${CURL_RETRY_ARGS[@]}" --max-time 10 -X POST "$PORKBUN_API_URL/dns/retrieve/$domain" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$auth_json" 2>&1); then
|
|
printf '%s [ERROR] Failed to retrieve DNS records: %s\n' "$(date)" "$records" >&2
|
|
return 1
|
|
fi
|
|
|
|
# Check if response is valid JSON
|
|
if ! echo "$records" | jq empty 2>/dev/null; then
|
|
printf '%s [ERROR] API returned invalid JSON. Response: %s\n' "$(date)" "$records" >&2
|
|
printf '%s [ERROR] Check if API access is enabled for domain: %s\n' "$(date)" "$domain" >&2
|
|
return 1
|
|
fi
|
|
|
|
# Check if API call was successful
|
|
local status
|
|
status=$(echo "$records" | jq -r '.status // "error"')
|
|
if [ "$status" == "ERROR" ]; then
|
|
local message
|
|
message=$(echo "$records" | jq -r '.message // "Unknown error"')
|
|
printf '%s [ERROR] Failed to retrieve DNS records. Message: %s\n' "$(date)" "$message" >&2
|
|
printf '%s [ERROR] Make sure API access is enabled for domain: %s\n' "$(date)" "$domain" >&2
|
|
return 1
|
|
fi
|
|
|
|
# Show what records exist for this FQDN
|
|
printf "%s [INFO] Existing records for FQDN '%s':\n" "$(date)" "$fqdn"
|
|
echo "$records" | jq -r --arg fqdn "$fqdn" --arg prefix "$NOTES_PREFIX" '.records[] | select(.name == $fqdn) | " Type: \(.type), Name: \(.name), Content: \(.content), Notes: \(.notes // "none"), Managed: \( (.notes // "") | startswith($prefix) ), ID: \(.id)"'
|
|
|
|
# Check for duplicate records with the same machine ID (only within managed records)
|
|
local machine_records
|
|
machine_records=$(echo "$records" | jq -r --arg fqdn "$fqdn" --arg machine_id "$MACHINE_ID" --arg prefix "$NOTES_PREFIX" '.records[] | select(.name == $fqdn and .type == "A" and ((.notes // "") == ($prefix + $machine_id)))')
|
|
local record_count
|
|
record_count=$(echo "$machine_records" | jq -s 'length')
|
|
|
|
if [ "$record_count" -gt 1 ]; then
|
|
printf '%s [WARN] Found %d A records for machine %s (expected 0 or 1)\n' "$(date)" "$record_count" "$MACHINE_ID" >&2
|
|
printf '%s [WARN] This may indicate concurrent script execution or previous failures. Cleaning up...\n' "$(date)" >&2
|
|
# Will be cleaned up in the deletion step below
|
|
fi
|
|
|
|
# Check if this machine already has a record with the current IP
|
|
local existing_ip
|
|
existing_ip=$(echo "$records" | jq -r --arg fqdn "$fqdn" --arg machine_id "$MACHINE_ID" --arg prefix "$NOTES_PREFIX" '.records[] | select(.name == $fqdn and .type == "A" and ((.notes // "") == ($prefix + $machine_id))) | .content' | head -1)
|
|
|
|
if [ "$existing_ip" == "$public_ip" ] && [ "$record_count" -eq 1 ]; then
|
|
printf "%s [INFO] Record already exists with correct IP (%s). No update needed.\n" "$(date)" "$public_ip"
|
|
return 0
|
|
fi
|
|
|
|
# IP has changed, log it
|
|
if [ -n "$existing_ip" ] && [ "$existing_ip" != "$public_ip" ]; then
|
|
printf "%s [INFO] IP changed from %s to %s\n" "$(date)" "$existing_ip" "$public_ip"
|
|
fi
|
|
|
|
# If we have duplicates, we need to clean them up
|
|
if [ "$record_count" -gt 1 ]; then
|
|
printf "%s [INFO] Cleaning up %d duplicate records for machine '%s'\n" "$(date)" "$record_count" "$MACHINE_ID"
|
|
fi
|
|
|
|
# Delete A/ALIAS/CNAME records that belong to this MACHINE_ID within the managed namespace
|
|
# Managed records are identified by notes starting with NOTES_PREFIX (e.g. "ddns:<machine_id>")
|
|
printf "%s [INFO] Deleting managed records for machine '%s' (notes starting with '%s')\n" "$(date)" "$MACHINE_ID" "$NOTES_PREFIX"
|
|
|
|
local delete_count=0
|
|
while IFS= read -r record_id; do
|
|
if [ -n "$record_id" ]; then
|
|
printf "%s [INFO] Deleting record ID: %s\n" "$(date)" "$record_id"
|
|
|
|
if [ "$DRY_RUN" -eq 1 ]; then
|
|
printf "%s [INFO] DRY RUN: Skipping actual delete for record ID %s\n" "$(date)" "$record_id"
|
|
else
|
|
if curl "${CURL_RETRY_ARGS[@]}" --max-time 10 -X POST "$PORKBUN_API_URL/dns/delete/$domain/$record_id" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$auth_json" > /dev/null 2>&1; then
|
|
((delete_count++)) || true
|
|
else
|
|
printf '%s [WARN] Failed to delete record ID: %s\n' "$(date)" "$record_id" >&2
|
|
fi
|
|
fi
|
|
fi
|
|
done < <(echo "$records" | jq -r --arg fqdn "$fqdn" --arg machine_id "$MACHINE_ID" --arg prefix "$NOTES_PREFIX" '.records[] | select(.name == $fqdn and (.type == "A" or .type == "ALIAS" or .type == "CNAME") and ((.notes // "") == ($prefix + $machine_id))) | .id')
|
|
|
|
if [ $delete_count -gt 0 ]; then
|
|
printf "%s [INFO] Deleted %d record(s)\n" "$(date)" "$delete_count"
|
|
fi
|
|
|
|
# Verify no duplicates remain before creating new record
|
|
# Re-fetch records to ensure we have latest state
|
|
if ! records=$(curl "${CURL_RETRY_ARGS[@]}" --max-time 10 -X POST "$PORKBUN_API_URL/dns/retrieve/$domain" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$auth_json" 2>&1); then
|
|
printf '%s [WARN] Failed to re-verify DNS records after deletion: %s\n' "$(date)" "$records" >&2
|
|
# Continue anyway - deletion likely succeeded
|
|
else
|
|
local remaining_count
|
|
remaining_count=$(echo "$records" | jq -r --arg fqdn "$fqdn" --arg machine_id "$MACHINE_ID" --arg prefix "$NOTES_PREFIX" '[.records[] | select(.name == $fqdn and .type == "A" and ((.notes // "") == ($prefix + $machine_id))) ] | length')
|
|
if [ "$remaining_count" -gt 0 ]; then
|
|
printf '%s [ERROR] Found %d A record(s) still present for machine %s after deletion\n' "$(date)" "$remaining_count" "$MACHINE_ID" >&2
|
|
printf '%s [ERROR] This should not happen. Possible API delay or concurrent execution.\n' "$(date)" >&2
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
# Create new A record for this machine's IP with machine ID in notes
|
|
printf "%s [INFO] Creating new A record for '%s' with IP: %s\n" "$(date)" "$fqdn" "$public_ip"
|
|
|
|
# Build JSON with machine ID in notes field within managed namespace
|
|
local notes_value
|
|
notes_value="${NOTES_PREFIX}${MACHINE_ID}"
|
|
local create_json
|
|
if ! create_json=$(jq -n \
|
|
--arg apikey "$API_KEY" \
|
|
--arg secretapikey "$SECRET_KEY" \
|
|
--arg name "$host" \
|
|
--arg type "A" \
|
|
--arg content "$public_ip" \
|
|
--arg notes "$notes_value" \
|
|
'{apikey: $apikey, secretapikey: $secretapikey, name: $name, type: $type, content: $content, ttl: 300, notes: $notes}' 2>&1); then
|
|
printf '%s [ERROR] Failed to create JSON for new record: %s\n' "$(date)" "$create_json" >&2
|
|
return 1
|
|
fi
|
|
|
|
if [ "$DRY_RUN" -eq 1 ]; then
|
|
printf "%s [INFO] DRY RUN: Skipping actual create for '%s' (would create A %s -> %s with notes '%s')\n" "$(date)" "$fqdn" "$fqdn" "$public_ip" "$notes_value"
|
|
return 0
|
|
fi
|
|
|
|
local create_response
|
|
if ! create_response=$(curl "${CURL_RETRY_ARGS[@]}" --max-time 10 -X POST "$PORKBUN_API_URL/dns/create/$domain" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$create_json" 2>&1); then
|
|
printf '%s [ERROR] Failed to create DNS record: %s\n' "$(date)" "$create_response" >&2
|
|
return 1
|
|
fi
|
|
|
|
local create_status
|
|
create_status=$(echo "$create_response" | jq -r '.status // "error"')
|
|
if [ "$create_status" == "SUCCESS" ]; then
|
|
printf "%s [INFO] Successfully created A record for %s (machine: %s)\n" "$(date)" "$fqdn" "$MACHINE_ID"
|
|
|
|
# Final verification: ensure exactly one record exists
|
|
if records=$(curl "${CURL_RETRY_ARGS[@]}" --max-time 10 -X POST "$PORKBUN_API_URL/dns/retrieve/$domain" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$auth_json" 2>&1); then
|
|
local final_count
|
|
final_count=$(echo "$records" | jq -r --arg fqdn "$fqdn" --arg machine_id "$MACHINE_ID" --arg prefix "$NOTES_PREFIX" '[.records[] | select(.name == $fqdn and .type == "A" and ((.notes // "") == ($prefix + $machine_id))) ] | length')
|
|
if [ "$final_count" -eq 1 ]; then
|
|
printf "%s [INFO] Verified: Exactly 1 A record exists for machine '%s'\n" "$(date)" "$MACHINE_ID"
|
|
elif [ "$final_count" -gt 1 ]; then
|
|
printf '%s [WARN] Found %d A records for machine %s after creation (expected 1)\n' "$(date)" "$final_count" "$MACHINE_ID" >&2
|
|
printf '%s [WARN] This may indicate concurrent script execution. The extra records will be cleaned up on next run.\n' "$(date)" >&2
|
|
fi
|
|
fi
|
|
|
|
return 0
|
|
else
|
|
local error_msg
|
|
error_msg=$(echo "$create_response" | jq -r '.message // "Unknown error"')
|
|
printf '%s [ERROR] Failed to create DNS record. Message: %s\n' "$(date)" "$error_msg" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Main execution
|
|
main() {
|
|
local exit_code=0
|
|
|
|
# Get public IP
|
|
local pip
|
|
if ! pip=$(get_public_ip); then
|
|
printf '%s [ERROR] Failed to get public IP\n' "$(date)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
printf "%s [INFO] Current Public IP: %s\n" "$(date)" "$pip"
|
|
printf "%s [INFO] Machine ID: %s\n" "$(date)" "$MACHINE_ID"
|
|
printf "%s [INFO] Domain: %s\n\n" "$(date)" "$DOMAIN"
|
|
|
|
# Update DNS records for the configured domain
|
|
# Both root domain and wildcard will have this machine's A record
|
|
if ! register_host_dns "" "$DOMAIN" "$pip"; then
|
|
printf '%s [ERROR] Failed to update root domain DNS record\n' "$(date)" >&2
|
|
exit_code=1
|
|
fi
|
|
|
|
if ! register_host_dns "*" "$DOMAIN" "$pip"; then
|
|
printf '%s [ERROR] Failed to update wildcard DNS record\n' "$(date)" >&2
|
|
exit_code=1
|
|
fi
|
|
|
|
if [ $exit_code -eq 0 ]; then
|
|
printf "\n%s [INFO] DNS update completed successfully\n" "$(date)"
|
|
else
|
|
printf "\n%s [ERROR] DNS update completed with errors\n" "$(date)" >&2
|
|
fi
|
|
|
|
exit $exit_code
|
|
}
|
|
|
|
# Run main function
|
|
main
|