a1a17e81a1
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.
508 lines
19 KiB
Bash
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 "$@"
|