From bf49ccf53cc9d02416d8854d1b25e7a8a7d1afc8 Mon Sep 17 00:00:00 2001 From: Mohamed Youssef Date: Fri, 17 Apr 2026 18:56:21 +0200 Subject: [PATCH] first commit --- README.md | 238 +++++++++++++++++++++++++++++++++++++ update_domain.sh | 299 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 537 insertions(+) create mode 100644 README.md create mode 100755 update_domain.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..df0595c --- /dev/null +++ b/README.md @@ -0,0 +1,238 @@ +# Cloudflare DNS Updater + +A Bash script that automatically updates Cloudflare DNS A records with your current public IP address. This script demonstrates various advanced Bash programming features while providing a practical tool for dynamic DNS management. + +## Features + +- **Automatic IP Detection**: Retrieves current public IP using external services +- **Multi-Domain Support**: Manages DNS records for multiple domains simultaneously +- **Smart Updates**: Only updates records when IP changes to avoid unnecessary API calls +- **Orphaned Record Cleanup**: Automatically removes unmanaged DNS records +- **Protected Records**: Prevents deletion of critical DNS records +- **Dry Run Mode**: Test changes without actually modifying DNS records +- **Comprehensive Logging**: Detailed output showing all operations performed + +## Bash Features Demonstrated + +This script showcases the following Bash programming features: + +### 1. **Shebang and Script Structure** +```bash +#!/bin/bash +``` +Proper script initialization with bash interpreter specification. + +### 2. **Variable Declarations and Constants** +```bash +TOKEN="" +AUTO_DELETE_ORPHANS=true +DRY_RUN=false +``` +Use of string constants and boolean flags for configuration. + +### 3. **Associative Arrays** +```bash +declare -A d0 d1 # Declare associative arrays for domain configuration +d0=( + ["domain"]="" + ["zone_id"]="" + ["subdomains"]="@," + ["protected_records"]="" +) +``` +Key-value data structures for complex domain configurations. + +### 4. **Indexed Arrays** +```bash +declare -a domains # Declare indexed array to hold domain references +domains=( d0 d1 ) +``` +Ordered collections for managing multiple domains. + +### 5. **Array References (Namerefs)** +```bash +declare -n domain="$item" # Create reference to associative array +echo "${domain[domain]}" # Access array elements through reference +``` +Dynamic array referencing for flexible data access. + +### 6. **Functions** +```bash +get_current_ip() { + curl -s https://ifconfig.me/ip +} + +create_dns_record() { + local zone_id="$1" + local data="$2" + # Function implementation +} +``` +Modular code organization with local variables and parameters. + +### 7. **Advanced Conditional Statements** +```bash +if [[ "$DRY_RUN" == true ]]; then + echo "⚠️ DRY RUN MODE ENABLED" +fi + +if [[ -n "${existing_a_records[$full_name]}" ]]; then + # Record exists logic +fi +``` +String comparison, variable existence checks, and complex conditions. + +### 8. **Loop Constructs** +```bash +for item in "${domains[@]}"; do + # Process each domain +done + +for subdomain in "${managed_subdomains[@]}"; do + # Process each subdomain +done +``` +Iteration over arrays with proper quoting. + +### 9. **String Manipulation and Parsing** +```bash +IFS=',' read -ra protected_records <<< "${domain[protected_records]}" +IFS=',' read -ra managed_subdomains <<< "${domain[subdomains]}" +``` +Field splitting and array population from comma-separated strings. + +### 10. **Command and Process Substitution** +```bash +current_ip=$(get_current_ip) # Command substitution + +done < <(echo "$response" | jq -c '.result[] | select(.type == "A")') # Process substitution +``` +Capturing command output and feeding output to loops. + +### 11. **JSON Processing with jq** +```bash +if echo "$response" | jq -e '.success == false' > /dev/null 2>&1; then + echo "Error: Failed to get DNS records" +fi + +create_data=$(jq -n \ + --arg name "$full_name" \ + --arg ip "$current_ip" \ + '{ + "type": "A", + "name": $name, + "content": $ip, + "ttl": 1, + "proxied": true + }') +``` +Parsing JSON responses and constructing JSON payloads. + +### 12. **Error Handling and Exit Codes** +```bash +if [ -z "$current_ip" ]; then + echo "Error: Could not retrieve current IP" + exit 1 +fi +``` +Input validation and controlled script termination. + +### 13. **Arithmetic Operations** +```bash +((records_created++)) +((records_updated++)) +((records_skipped++)) +((records_deleted++)) +``` +Counter variables for operation statistics. + +### 14. **API Integration with curl** +```bash +curl -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \ + -X POST \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $TOKEN" \ + -d "$data" +``` +REST API calls with proper headers and data payloads. + +### 15. **Dynamic Variable Management** +```bash +unset existing_a_records # Clean up associative arrays +unset domain # Clean up nameref +``` +Memory management and variable cleanup. + +## Usage + +1. **Configure Domains**: Edit the domain arrays at the top of the script +2. **Set API Token**: Update the `TOKEN` variable with your Cloudflare API token +3. **Dry Run First**: Test with `DRY_RUN=true` to see what would change +4. **Run Updates**: Set `DRY_RUN=false` to apply actual changes + +```bash +# Dry run (safe testing) +./update_domain.sh + +# Apply changes +# Edit script: DRY_RUN=false +./update_domain.sh +``` + +## Configuration + +### Domain Configuration Structure +Each domain is configured as an associative array with: +- `domain`: The root domain name +- `zone_id`: Cloudflare zone identifier +- `subdomains`: Comma-separated list of subdomains to manage +- `protected_records`: Records that should never be deleted + +### Script Parameters +- `TOKEN`: Cloudflare API bearer token +- `AUTO_DELETE_ORPHANS`: Whether to remove unmanaged records +- `DRY_RUN`: Test mode without making actual changes + +## Requirements + +- Bash 4.0+ +- curl +- jq +- Valid Cloudflare API token with DNS edit permissions + +## Security Notes + +- Store API tokens securely (consider environment variables) +- Use dry run mode when testing +- Review protected records configuration carefully +- Monitor script output for any API errors + +## Output Example + +``` +⚠️ DRY RUN MODE ENABLED - No actual changes will be made +========================================= +Current Public IP: 203.0.113.1 +========================================= +Processing domain: my-dev.pro +Zone ID: 5c9a19af6aeababb78b294844290a7d2 +Managed subdomains: @ git +---------------------------------------- +Existing A records: + - my-dev.pro → 203.0.113.1 (ID: abc123) + - git.my-dev.pro → 203.0.113.1 (ID: def456) +---------------------------------------- +Checking: my-dev.pro + ✓ SKIPPED: IP unchanged (203.0.113.1 = 203.0.113.1) +Checking: git.my-dev.pro + ✓ SKIPPED: IP unchanged (203.0.113.1 = 203.0.113.1) +========================================= +SUMMARY for my-dev.pro: + ✓ Created: 0 + ✓ Updated: 0 + ✓ Skipped (IP unchanged): 2 + ✓ Deleted: 0 +========================================= +⚠️ DRY RUN COMPLETED - No actual changes were made +Set DRY_RUN=false to apply changes +``` \ No newline at end of file diff --git a/update_domain.sh b/update_domain.sh new file mode 100755 index 0000000..8b1c53d --- /dev/null +++ b/update_domain.sh @@ -0,0 +1,299 @@ +#!bin/bash + +#=================# +# params +#=================# +TOKEN="cfut_wgrWeIbAuyqekbEd1EqCQFSR6NNmE0gzJdReSZoce3352365" +AUTO_DELETE_ORPHANS=true # Set to true to auto-delete orphaned records +DRY_RUN=false # Set to false to actually create records + + +################# +# Domain list +################# +declare -a domains # declare main domain associative array to include all domains in one array +declare -A d0 d1 # declare new array for each domain + +d0=( + ["domain"]="" + ["zone_id"]="" + ["subdomains"]="@,git" + ["protected_records"]="" +) + +domains=( d0 d1 ) + +#*****************# +# DNS FUNCTIONS +#*****************# +get_current_ip() { + curl -s https://ifconfig.me/ip +} + +list_dns_records() { + local zone_id="$1" + curl -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \ + -H "Authorization: Bearer $TOKEN" +} + +create_dns_record() { + local zone_id="$1" + local data="$2" + + if [[ "$DRY_RUN" == true ]]; then + echo " [DRY RUN] Would create record with: $data" + echo '{"success":true,"dry_run":true}' + return 0 + fi + + curl -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \ + -X POST \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $TOKEN" \ + -d "$data" +} + +delete_dns_record() { + local zone_id="$1" + local dns_record_id="$2" + + if [[ "$DRY_RUN" == true ]]; then + echo " [DRY RUN] Would delete record ID: $dns_record_id" + echo '{"success":true,"dry_run":true}' + return 0 + fi + + curl -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$dns_record_id" \ + -X DELETE \ + -H "Authorization: Bearer $TOKEN" +} + +update_dns_record() { + local zone_id="$1" + local dns_record_id="$2" + local body="$3" + + if [[ "$DRY_RUN" == true ]]; then + echo " [DRY RUN] Would update record ID: $dns_record_id with: $body" + echo '{"success":true,"dry_run":true}' + return 0 + fi + + curl -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$dns_record_id" \ + -X PATCH \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $TOKEN" \ + -d "$body" +} + +#*****************# +# HELPER FUNCTIONS +#*****************# +compare_ips() { + local ip1="$1" + local ip2="$2" + + if [[ "$ip1" == "$ip2" ]]; then + return 0 # IPs are the same + else + return 1 # IPs are different + fi +} + +#**************# +# MAIN OPERATIONS +#**************# + +# Display mode +if [[ "$DRY_RUN" == true ]]; then + echo "⚠️ DRY RUN MODE ENABLED - No actual changes will be made" + echo "=========================================" +fi + +# Get current public IP +current_ip=$(get_current_ip) + +if [ -z "$current_ip" ]; then + echo "Error: Could not retrieve current IP" + exit 1 +fi + +echo "Current Public IP: $current_ip" +echo "=========================================" + +# Process each domain +for item in "${domains[@]}"; do + unset domain + declare -n domain="$item" + + echo "Processing domain: ${domain[domain]}" + echo "Zone ID: ${domain[zone_id]}" + + # Parse protected records + IFS=',' read -ra protected_records <<< "${domain[protected_records]}" + + # Parse managed subdomains + IFS=',' read -ra managed_subdomains <<< "${domain[subdomains]}" + echo "Managed subdomains: ${managed_subdomains[@]}" + + # Get existing DNS records + response=$(list_dns_records "${domain[zone_id]}") + + # Check for API errors + if echo "$response" | jq -e '.success == false' > /dev/null 2>&1; then + echo "Error: Failed to get DNS records" + echo "$response" | jq '.errors' + continue + fi + + echo "----------------------------------------" + + # Create associative array for existing A records + declare -A existing_a_records + + # Build map of existing A records + echo "Existing A records:" + while IFS= read -r record; do + if [[ -n "$record" ]]; then + record_id=$(echo "$record" | jq -r '.id') + record_name=$(echo "$record" | jq -r '.name') + record_content=$(echo "$record" | jq -r '.content') + + existing_a_records["$record_name"]="$record_id|$record_content" + echo " - $record_name → $record_content (ID: $record_id)" + fi + done < <(echo "$response" | jq -c '.result[] | select(.type == "A")') + + echo "----------------------------------------" + + # Statistics counters + records_created=0 + records_updated=0 + records_skipped=0 + records_deleted=0 + + # Process each managed subdomain (create/update) + for subdomain in "${managed_subdomains[@]}"; do + # Build full domain name + if [[ "$subdomain" == "@" ]] || [[ -z "$subdomain" ]]; then + full_name="${domain[domain]}" + else + full_name="${subdomain}.${domain[domain]}" + fi + + echo "Checking: $full_name" + + # Check if record exists + if [[ -n "${existing_a_records[$full_name]}" ]]; then + IFS='|' read -r record_id existing_ip <<< "${existing_a_records[$full_name]}" + + # Compare IPs + if compare_ips "$existing_ip" "$current_ip"; then + echo " ✓ SKIPPED: IP unchanged ($existing_ip = $current_ip)" + ((records_skipped++)) + else + echo " ⚠ NEEDS UPDATE: IP changed ($existing_ip → $current_ip)" + + # Update existing record with new IP + update_body=$(jq -n --arg ip "$current_ip" '{"content": $ip}') + update_response=$(update_dns_record "${domain[zone_id]}" "$record_id" "$update_body") + + if echo "$update_response" | jq -e '.success == true' > /dev/null 2>&1; then + echo " ✓ SUCCESS: Updated $full_name to $current_ip" + ((records_updated++)) + else + echo " ✗ FAILED: Could not update $full_name" + echo "$update_response" | jq '.errors' + fi + fi + + # Mark as processed (remove from map for deletion check) + unset existing_a_records["$full_name"] + else + echo " ➕ CREATING: Record does not exist" + + # Create new record + create_data=$(jq -n \ + --arg name "$full_name" \ + --arg ip "$current_ip" \ + '{ + "type": "A", + "name": $name, + "content": $ip, + "ttl": 1, + "proxied": true + }') + + create_response=$(create_dns_record "${domain[zone_id]}" "$create_data") + + if echo "$create_response" | jq -e '.success == true' > /dev/null 2>&1; then + echo " ✓ SUCCESS: Created $full_name -> $current_ip" + ((records_created++)) + else + echo " ✗ FAILED: Could not create $full_name" + echo "$create_response" | jq '.errors' + fi + fi + echo "" + done + + # Delete orphaned records if auto-delete is enabled + if [[ "$AUTO_DELETE_ORPHANS" == true ]] && [[ ${#existing_a_records[@]} -gt 0 ]]; then + echo "Checking for orphaned records to delete..." + + for record_name in "${!existing_a_records[@]}"; do + # Check if record is protected + is_protected=false + for protected in "${protected_records[@]}"; do + if [[ "$record_name" == "$protected" ]]; then + is_protected=true + break + fi + done + + if [[ "$is_protected" == true ]]; then + echo " ⚠ PROTECTED: $record_name (skipped from deletion)" + continue + fi + + IFS='|' read -r record_id existing_ip <<< "${existing_a_records[$record_name]}" + echo " 🗑 ORPHANED: $record_name -> $existing_ip" + + delete_response=$(delete_dns_record "${domain[zone_id]}" "$record_id") + + if echo "$delete_response" | jq -e '.success == true' > /dev/null 2>&1; then + echo " ✓ DELETED: $record_name" + ((records_deleted++)) + else + echo " ✗ FAILED: Could not delete $record_name" + echo "$delete_response" | jq '.errors' + fi + done + echo "" + elif [[ ${#existing_a_records[@]} -gt 0 ]]; then + echo "Orphaned records found (auto-delete disabled):" + for record_name in "${!existing_a_records[@]}"; do + IFS='|' read -r record_id existing_ip <<< "${existing_a_records[$record_name]}" + echo " - $record_name -> $existing_ip (ID: $record_id)" + done + echo "" + fi + + # Summary + echo "=========================================" + echo "SUMMARY for ${domain[domain]}:" + echo " ✓ Created: $records_created" + echo " ✓ Updated: $records_updated" + echo " ✓ Skipped (IP unchanged): $records_skipped" + echo " ✓ Deleted: $records_deleted" + echo "=========================================" + echo "" + + # Clean up associative array + unset existing_a_records +done + +if [[ "$DRY_RUN" == true ]]; then + echo "⚠️ DRY RUN COMPLETED - No actual changes were made" + echo "Set DRY_RUN=false to apply changes" +fi \ No newline at end of file