Files
linux-scripts/route53-dns-backup.sh
chiefgeek a1a17e81a1 Sync all scripts from website downloads — 352 scripts total
Includes updated JS challenge scripts with Claude-User whitelist,
same-site referer bypass, Blackbox-Exporter allowed bot, and all
new exporters, cheat sheets, and automation scripts.
2026-05-25 03:31:08 +02:00

508 lines
19 KiB
Bash

#!/usr/bin/env bash
#########################################################################################
#### route53-dns-backup.sh — Export AWS Route 53 zones to BIND zone file format ####
#### Supports diff, S3 upload, retention, and zone restore ####
#### Requires: bash 4+, aws-cli v2, jq ####
#### ####
#### Author: Phil Connor ####
#### Contact: contact@mylinux.work ####
#### License: MIT ####
#### Version 1.00 ####
#### ####
#### Usage: ####
#### ./route53-dns-backup.sh --export ####
#### ./route53-dns-backup.sh --export --s3-bucket my-dns-backups ####
#### ####
#### See --help for all options. ####
#########################################################################################
set -euo pipefail
# ── Defaults ──────────────────────────────────────────────────────────
AWS_REGION="${AWS_REGION:-us-east-1}"
BACKUP_DIR="${R53_BACKUP_DIR:-$HOME/route53-backups}"
S3_BUCKET="${R53_S3_BUCKET:-}"
S3_PREFIX="${R53_S3_PREFIX:-route53-backups}"
RETAIN_DAYS="${R53_RETAIN_DAYS:-90}"
VERBOSE="${VERBOSE:-false}"
COLOR="${COLOR:-auto}"
# ── State ─────────────────────────────────────────────────────────────
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
RUN_MODE=""
ZONE_ID=""
RESTORE_FILE=""
DRY_RUN="false"
DIFF_MODE="false"
OUTPUT_DIR=""
TOTAL_ZONES=0
TOTAL_RECORDS=0
START_TIME=""
# ── Colors ────────────────────────────────────────────────────────────
RED="" GREEN="" YELLOW="" BLUE="" BOLD="" DIM="" RESET=""
setup_colors() {
if [[ "$COLOR" == "never" ]]; then
return
fi
if [[ "$COLOR" == "auto" && ! -t 1 ]]; then
return
fi
RED="\033[0;31m"
GREEN="\033[0;32m"
YELLOW="\033[0;33m"
BLUE="\033[0;34m"
BOLD="\033[1m"
DIM="\033[2m"
RESET="\033[0m"
}
# ── Helpers ───────────────────────────────────────────────────────────
die() { printf "%b\n" "${RED}[ERROR]${RESET} $*" >&2; exit 1; }
log_info() { printf "%b\n" "${GREEN}[INFO]${RESET} $*"; }
log_warn() { printf "%b\n" "${YELLOW}[WARN]${RESET} $*"; }
log_verbose() { [[ "$VERBOSE" == "true" ]] && printf "%b\n" "${DIM}[DEBUG]${RESET} $*" || true; }
usage() {
cat <<EOF
Usage: $SCRIPT_NAME [OPTIONS]
Export AWS Route 53 hosted zones to BIND zone file format.
Supports diff, S3 upload, local retention, and zone restore.
Modes:
--export Export hosted zones to BIND format
--restore Restore records from a BIND zone file
Options:
--zone-id ID Target a specific hosted zone (default: all)
--diff Compare with most recent previous backup
--s3-bucket BUCKET Upload backups to S3
--s3-prefix PREFIX S3 key prefix (default: route53-backups)
--file PATH BIND zone file for restore
--dry-run Preview restore without applying
--output-dir DIR Local backup directory (default: ~/route53-backups)
--retain-days N Delete local backups older than N days (default: 90)
--verbose Debug output
--no-color Disable colored output
--help Show this help
Environment Variables:
AWS_PROFILE AWS CLI profile
AWS_REGION AWS region (default: us-east-1)
R53_BACKUP_DIR Backup directory (default: ~/route53-backups)
R53_S3_BUCKET S3 bucket for uploads
R53_S3_PREFIX S3 key prefix (default: route53-backups)
R53_RETAIN_DAYS Local retention days (default: 90)
Examples:
$SCRIPT_NAME --export
$SCRIPT_NAME --export --diff --s3-bucket my-dns-backups
$SCRIPT_NAME --export --zone-id Z0123456789ABCDEFGHIJ
$SCRIPT_NAME --restore --zone-id Z0123456789ABCDEFGHIJ --file backup.zone --dry-run
EOF
exit 0
}
# ── Dependency Check ──────────────────────────────────────────────────
check_deps() {
local missing=()
for cmd in aws jq; do
if ! command -v "$cmd" &>/dev/null; then
missing+=("$cmd")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
die "Missing required commands: ${missing[*]}"
fi
log_verbose "Dependencies OK: aws, jq"
}
# ── Argument Parsing ──────────────────────────────────────────────────
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--export) RUN_MODE="export"; shift ;;
--restore) RUN_MODE="restore"; shift ;;
--zone-id) ZONE_ID="${2:?--zone-id requires a value}"; shift 2 ;;
--diff) DIFF_MODE="true"; shift ;;
--s3-bucket) S3_BUCKET="${2:?--s3-bucket requires a value}"; shift 2 ;;
--s3-prefix) S3_PREFIX="${2:?--s3-prefix requires a value}"; shift 2 ;;
--file) RESTORE_FILE="${2:?--file requires a value}"; shift 2 ;;
--dry-run) DRY_RUN="true"; shift ;;
--output-dir) OUTPUT_DIR="${2:?--output-dir requires a value}"; shift 2 ;;
--retain-days) RETAIN_DAYS="${2:?--retain-days requires a value}"; shift 2 ;;
--verbose) VERBOSE="true"; shift ;;
--no-color) COLOR="never"; shift ;;
--help) usage ;;
*) die "Unknown option: $1" ;;
esac
done
[[ -z "$RUN_MODE" ]] && die "Specify --export or --restore. See --help."
if [[ "$RUN_MODE" == "restore" ]]; then
[[ -z "$ZONE_ID" ]] && die "--restore requires --zone-id"
[[ -z "$RESTORE_FILE" ]] && die "--restore requires --file"
[[ ! -f "$RESTORE_FILE" ]] && die "File not found: $RESTORE_FILE"
fi
}
# ── AWS Identity ──────────────────────────────────────────────────────
get_account_id() {
aws sts get-caller-identity --query 'Account' --output text 2>/dev/null || echo "unknown"
}
# ── List Hosted Zones ─────────────────────────────────────────────────
list_hosted_zones() {
if [[ -n "$ZONE_ID" ]]; then
aws route53 get-hosted-zone --id "$ZONE_ID" --output json 2>/dev/null | \
jq '[{Id: .HostedZone.Id, Name: .HostedZone.Name, Private: .HostedZone.Config.PrivateZone, RecordCount: .HostedZone.ResourceRecordSetCount}]'
else
local zones="[]"
local marker=""
while true; do
local cmd=(aws route53 list-hosted-zones --output json --max-items 100)
[[ -n "$marker" ]] && cmd+=(--starting-token "$marker")
local result
result=$("${cmd[@]}" 2>/dev/null)
zones=$(echo "$zones" "$result" | jq -s '.[0] + [.[1].HostedZones[] | {Id: .Id, Name: .Name, Private: .Config.PrivateZone, RecordCount: .ResourceRecordSetCount}]')
marker=$(echo "$result" | jq -r '.NextToken // empty')
[[ -z "$marker" ]] && break
done
echo "$zones"
fi
}
# ── Export Zone to BIND Format ────────────────────────────────────────
export_zone() {
local zone_id="$1"
local zone_name="$2"
local is_private="$3"
local output_file="$4"
# Strip trailing dot for display
local display_name="${zone_name%.}"
log_verbose "Exporting zone: $display_name ($zone_id)"
# Fetch all records
local records
records=$(aws route53 list-resource-record-sets \
--hosted-zone-id "$zone_id" \
--output json 2>/dev/null)
local record_count
record_count=$(echo "$records" | jq '.ResourceRecordSets | length')
# Write BIND zone file
{
echo "; Zone file for $display_name"
echo "; Exported from AWS Route 53 on $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
echo "; Hosted Zone ID: $zone_id"
[[ "$is_private" == "true" ]] && echo "; Private hosted zone"
echo ";"
echo "\$ORIGIN ${zone_name}"
echo ""
# Process each record set
echo "$records" | jq -r '.ResourceRecordSets[] | @json' | while IFS= read -r rr; do
local rr_name rr_type rr_ttl
rr_name=$(echo "$rr" | jq -r '.Name')
rr_type=$(echo "$rr" | jq -r '.Type')
rr_ttl=$(echo "$rr" | jq -r '.TTL // 300')
# Handle ALIAS records (not supported in BIND)
local alias_target
alias_target=$(echo "$rr" | jq -r '.AliasTarget.DNSName // empty')
if [[ -n "$alias_target" ]]; then
echo "; ALIAS $rr_name $rr_type$alias_target (Route 53 alias — no BIND equivalent)"
continue
fi
# Standard resource records
echo "$rr" | jq -r '.ResourceRecords[]?.Value // empty' | while IFS= read -r value; do
[[ -z "$value" ]] && continue
printf "%-40s %s IN %-8s %s\n" "$rr_name" "$rr_ttl" "$rr_type" "$value"
done
done
} > "$output_file"
local private_label=""
[[ "$is_private" == "true" ]] && private_label=" (private)"
printf " %b %s (%s) — %d records → %s%s\n" \
"${GREEN}${RESET}" "$display_name" "${zone_id##*/}" "$record_count" \
"$(basename "$output_file")" "$private_label"
TOTAL_ZONES=$((TOTAL_ZONES + 1))
TOTAL_RECORDS=$((TOTAL_RECORDS + record_count))
# Rate limiting — Route 53 API allows 5 req/s
sleep 0.3
}
# ── Diff Against Previous Backup ──────────────────────────────────────
diff_zone() {
local zone_name="$1"
local current_file="$2"
local backup_base="$3"
local display_name="${zone_name%.}"
local zone_filename
zone_filename=$(sanitize_filename "$zone_name")
# Find the most recent previous backup
local prev_dir=""
local today_dir
today_dir=$(basename "$(dirname "$current_file")")
if [[ -d "$backup_base" ]]; then
prev_dir=$(find "$backup_base" -maxdepth 1 -mindepth 1 -type d \
-not -name "$today_dir" | sort -r | head -1)
fi
if [[ -z "$prev_dir" || ! -f "$prev_dir/$zone_filename" ]]; then
log_verbose "No previous backup found for $display_name — skipping diff"
return
fi
local prev_file="$prev_dir/$zone_filename"
# Filter out comments and blank lines for comparison
local diff_output
diff_output=$(diff \
<(grep -v '^;' "$prev_file" | grep -v '^\$' | grep -v '^$' | sort) \
<(grep -v '^;' "$current_file" | grep -v '^\$' | grep -v '^$' | sort) \
2>/dev/null || true)
if [[ -z "$diff_output" ]]; then
echo "No changes in $display_name"
else
echo ""
printf "%bChanges detected in %s:%b\n" "$YELLOW" "$display_name" "$RESET"
echo "$diff_output" | while IFS= read -r line; do
case "$line" in
">"*) printf " %b+ %s%b\n" "$GREEN" "${line:2}" "$RESET" ;;
"<"*) printf " %b- %s%b\n" "$RED" "${line:2}" "$RESET" ;;
esac
done
fi
}
# ── S3 Upload ─────────────────────────────────────────────────────────
upload_to_s3() {
local local_dir="$1"
local date_stamp
date_stamp=$(basename "$local_dir")
local s3_path="s3://${S3_BUCKET}/${S3_PREFIX}/${date_stamp}/"
log_info "Uploading to $s3_path"
aws s3 cp "$local_dir/" "$s3_path" \
--recursive \
--sse AES256 \
--quiet \
2>/dev/null
log_info "Upload complete: $s3_path"
}
# ── Local Retention ───────────────────────────────────────────────────
enforce_retention() {
local backup_base="$1"
local days="$2"
[[ ! -d "$backup_base" ]] && return
local deleted=0
while IFS= read -r dir; do
[[ -z "$dir" ]] && continue
log_verbose "Deleting old backup: $dir"
rm -rf "$dir"
deleted=$((deleted + 1))
done < <(find "$backup_base" -maxdepth 1 -mindepth 1 -type d -mtime +"$days" 2>/dev/null)
[[ $deleted -gt 0 ]] && log_info "Deleted $deleted backup(s) older than $days days"
}
# ── Restore Zone ──────────────────────────────────────────────────────
restore_zone() {
local zone_id="$1"
local file="$2"
log_info "Restoring records to zone $zone_id from $file"
[[ "$DRY_RUN" == "true" ]] && log_warn "DRY-RUN mode — no changes will be applied"
local changes='{"Changes":[]}'
local record_count=0
while IFS= read -r line; do
# Skip comments, directives, and blank lines
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
[[ "$line" =~ ^[[:space:]]*\; ]] && continue
[[ "$line" =~ ^\$ ]] && continue
# Parse BIND record: name ttl IN type value
local rr_name rr_ttl rr_type rr_value
read -r rr_name rr_ttl _ rr_type rr_value <<< "$line"
[[ -z "$rr_type" || -z "$rr_value" ]] && continue
# Skip SOA and NS records (Route 53 manages these)
[[ "$rr_type" == "SOA" || "$rr_type" == "NS" ]] && continue
local change
change=$(jq -n \
--arg action "UPSERT" \
--arg name "$rr_name" \
--arg type "$rr_type" \
--argjson ttl "$rr_ttl" \
--arg value "$rr_value" \
'{Action: $action, ResourceRecordSet: {Name: $name, Type: $type, TTL: $ttl, ResourceRecords: [{Value: $value}]}}')
changes=$(echo "$changes" | jq --argjson c "$change" '.Changes += [$c]')
record_count=$((record_count + 1))
if [[ "$DRY_RUN" == "true" ]]; then
printf " %b[DRY-RUN]%b UPSERT %s %s %s → %s\n" "$YELLOW" "$RESET" "$rr_name" "$rr_ttl" "$rr_type" "$rr_value"
fi
done < "$file"
if [[ $record_count -eq 0 ]]; then
log_warn "No restorable records found in $file"
return
fi
if [[ "$DRY_RUN" == "true" ]]; then
log_info "DRY-RUN: $record_count record(s) would be upserted"
return
fi
# Batch changes (Route 53 allows up to 1000 per request)
local batch_size=500
local total_changes
total_changes=$(echo "$changes" | jq '.Changes | length')
local batches=$(( (total_changes + batch_size - 1) / batch_size ))
for ((i = 0; i < batches; i++)); do
local offset=$((i * batch_size))
local batch
batch=$(echo "$changes" | jq "{Comment: \"Restore from backup\", Changes: .Changes[$offset:$((offset + batch_size))]}")
log_verbose "Submitting batch $((i + 1))/$batches ($batch_size records)"
aws route53 change-resource-record-sets \
--hosted-zone-id "$zone_id" \
--change-batch "$batch" \
--output text \
2>/dev/null
sleep 0.5
done
log_info "Restored $record_count record(s) to zone $zone_id"
}
# ── Filename Sanitization ─────────────────────────────────────────────
sanitize_filename() {
local name="$1"
name="${name%.}"
echo "${name}.zone"
}
# ── Main: Export ──────────────────────────────────────────────────────
run_export() {
local account_id
account_id=$(get_account_id)
local date_stamp
date_stamp=$(date -u '+%Y-%m-%d')
local target_dir="${OUTPUT_DIR:-$BACKUP_DIR}/$date_stamp"
mkdir -p "$target_dir"
echo ""
printf "%bRoute 53 DNS Backup%b\n" "$BOLD" "$RESET"
echo "Account: $account_id"
echo "Time: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
if [[ -n "$ZONE_ID" ]]; then
echo "Mode: export (zone $ZONE_ID)"
else
echo "Mode: export (all zones)"
fi
echo ""
echo "Exporting hosted zones..."
local zones
zones=$(list_hosted_zones)
local zone_count
zone_count=$(echo "$zones" | jq 'length')
if [[ "$zone_count" -eq 0 ]]; then
log_warn "No hosted zones found"
return
fi
for ((i = 0; i < zone_count; i++)); do
local z_id z_name z_private
z_id=$(echo "$zones" | jq -r ".[$i].Id" | sed 's|/hostedzone/||')
z_name=$(echo "$zones" | jq -r ".[$i].Name")
z_private=$(echo "$zones" | jq -r ".[$i].Private")
local zone_file
zone_file="$target_dir/$(sanitize_filename "$z_name")"
export_zone "$z_id" "$z_name" "$z_private" "$zone_file"
# Diff if requested
if [[ "$DIFF_MODE" == "true" ]]; then
diff_zone "$z_name" "$zone_file" "${OUTPUT_DIR:-$BACKUP_DIR}"
fi
done
echo ""
printf "Backup saved to: %b%s%b\n" "$BLUE" "$target_dir" "$RESET"
echo " $TOTAL_ZONES zones, $TOTAL_RECORDS records total"
# S3 upload
if [[ -n "$S3_BUCKET" ]]; then
echo ""
upload_to_s3 "$target_dir"
fi
# Local retention
enforce_retention "${OUTPUT_DIR:-$BACKUP_DIR}" "$RETAIN_DAYS"
}
# ── Main: Restore ─────────────────────────────────────────────────────
run_restore() {
restore_zone "$ZONE_ID" "$RESTORE_FILE"
}
# ── Entry Point ───────────────────────────────────────────────────────
main() {
START_TIME=$(date +%s)
setup_colors
parse_args "$@"
check_deps
case "$RUN_MODE" in
export) run_export ;;
restore) run_restore ;;
*) die "Unknown mode: $RUN_MODE" ;;
esac
local elapsed=$(( $(date +%s) - START_TIME ))
echo ""
log_info "Completed in ${elapsed}s"
}
main "$@"