#!/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 </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 "$@"