2025-11-24 13:17:00 -05:00
#!/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:"
2026-04-14 21:42:38 -04:00
# 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)
2025-11-24 13:17:00 -05:00
# 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
2026-04-14 21:42:38 -04:00
ping_response = $( curl " ${ CURL_RETRY_ARGS [@] } " --max-time 10 -X POST " $PORKBUN_API_URL /ping " \
2025-11-24 13:17:00 -05:00
-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
2026-04-14 21:42:38 -04:00
ip = $( curl " ${ CURL_RETRY_ARGS [@] } " --max-time 5 https://api.ipify.org 2>/dev/null || echo "" )
2025-11-24 13:17:00 -05:00
if [ -n " $ip " ] ; then
printf '%s [INFO] IP source: api.ipify.org\n' " $( date) " >& 2
fi
fi
if [ -z " $ip " ] ; then
2026-04-14 21:42:38 -04:00
ip = $( curl " ${ CURL_RETRY_ARGS [@] } " --max-time 5 https://icanhazip.com 2>/dev/null || echo "" )
2025-11-24 13:17:00 -05:00
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
2026-04-14 21:42:38 -04:00
if ! records = $( curl " ${ CURL_RETRY_ARGS [@] } " --max-time 10 -X POST " $PORKBUN_API_URL /dns/retrieve/ $domain " \
2025-11-24 13:17:00 -05:00
-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
2026-04-14 21:42:38 -04:00
if curl " ${ CURL_RETRY_ARGS [@] } " --max-time 10 -X POST " $PORKBUN_API_URL /dns/delete/ $domain / $record_id " \
2025-11-24 13:17:00 -05:00
-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
2026-04-14 21:42:38 -04:00
if ! records = $( curl " ${ CURL_RETRY_ARGS [@] } " --max-time 10 -X POST " $PORKBUN_API_URL /dns/retrieve/ $domain " \
2025-11-24 13:17:00 -05:00
-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
2026-04-14 21:42:38 -04:00
if ! create_response = $( curl " ${ CURL_RETRY_ARGS [@] } " --max-time 10 -X POST " $PORKBUN_API_URL /dns/create/ $domain " \
2025-11-24 13:17:00 -05:00
-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
2026-04-14 21:42:38 -04:00
if records = $( curl " ${ CURL_RETRY_ARGS [@] } " --max-time 10 -X POST " $PORKBUN_API_URL /dns/retrieve/ $domain " \
2025-11-24 13:17:00 -05:00
-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