Commit a76f8e3c9158

Vincent Demeester <vincent@sbr.pm>
2026-05-07 10:35:09
feat: added demeester.fr DNS zone management
Nix-managed DNS zone for demeester.fr with all existing records (iCloud MX, SPF, DKIM, A records) plus new pds.demeester.fr entry. Updated Gandi DNS script to support multiple domains and added zone to local BIND config.
1 parent 38f0578
Changed files (3)
systems/common/services/dns/demeester.fr.nix
@@ -0,0 +1,64 @@
+# DNS zone for demeester.fr
+# Managed via Gandi LiveDNS API (tools/update-gandi-dns.sh)
+{ dns, globals, ... }:
+with dns.lib.combinators;
+{
+  SOA = {
+    nameServer = "ns1.gandi.net.";
+    adminEmail = "hostmaster.gandi.net";
+    serial = 1;
+    refresh = 10800;
+    retry = 3600;
+    expire = 604800;
+    minimum = 10800;
+  };
+
+  NS = [
+    "ns1.gandi.net."
+  ];
+
+  # Root domain points to carthage (public endpoint)
+  A = [ "46.224.100.116" ];
+
+  # iCloud Mail
+  MX = [
+    {
+      preference = 10;
+      exchange = "mx01.mail.icloud.com.";
+    }
+    {
+      preference = 10;
+      exchange = "mx02.mail.icloud.com.";
+    }
+  ];
+
+  TXT = [
+    "apple-domain=vML1eiTPb5VpQ5rc"
+    "v=spf1 include:icloud.com ~all"
+  ];
+
+  subdomains = {
+    # Wildcard for public endpoint (carthage)
+    "*".A = [ "46.224.100.116" ];
+
+    # Website
+    www.A = [ "46.224.100.116" ];
+    vincent.A = [ "46.224.100.116" ];
+
+    # Shortcuts
+    carthage.A = [ "46.224.100.116" ];
+    p.A = [ "46.224.100.116" ];
+
+    # Wildcard under www
+    "*.www".A = [ "46.224.100.116" ];
+
+    # iCloud DKIM
+    _domainkey.subdomains.sig1.CNAME = [ "sig1.dkim.demeester.fr.at.icloudmailadmin.com." ];
+
+    # ATProto PDS
+    pds.A = [ "46.224.100.116" ];
+
+    # ATProto handle verification (placeholder - update DID after account creation)
+    # "_atproto.vincent".TXT = [ "did=did:plc:PLACEHOLDER" ];
+  };
+}
systems/common/services/bind.nix
@@ -50,6 +50,12 @@ in
         master = true;
         file = mkZoneFile "192.168.1.in-addr.arpa" ./dns/192.168.1.nix;
       }
+      # demeester.fr zone
+      {
+        name = "demeester.fr";
+        master = true;
+        file = mkZoneFile "demeester.fr" ./dns/demeester.fr.nix;
+      }
       # vpn zone
       {
         name = "vpn";
tools/update-gandi-dns.sh
@@ -1,6 +1,7 @@
 #!/usr/bin/env bash
 # Update Gandi DNS records from NixOS DNS configuration
-# Usage: ./scripts/update-gandi-dns.sh [--dry-run]
+# Usage: ./tools/update-gandi-dns.sh [--dry-run] [domain...]
+# If no domains specified, updates all managed domains.
 
 set -euo pipefail
 
@@ -13,21 +14,40 @@ RED='\033[0;31m'
 CYAN='\033[0;36m'
 RESET='\033[0m'
 
-DOMAIN="sbr.pm"
-API_URL="https://api.gandi.net/v5/livedns/domains/$DOMAIN/records"
 DRY_RUN=false
 
+# Domain -> zone file mapping
+declare -A DOMAIN_ZONE_FILES
+DOMAIN_ZONE_FILES["sbr.pm"]="./systems/common/services/dns/sbr.pm-gandi.nix"
+DOMAIN_ZONE_FILES["demeester.fr"]="./systems/common/services/dns/demeester.fr.nix"
+
+ALL_DOMAINS=("sbr.pm" "demeester.fr")
+SELECTED_DOMAINS=()
+
 # Parse arguments
 for arg in "$@"; do
     case $arg in
         --dry-run)
             DRY_RUN=true
-            shift
+            ;;
+        *)
+            if [[ -n "${DOMAIN_ZONE_FILES[$arg]+_}" ]]; then
+                SELECTED_DOMAINS+=("$arg")
+            else
+                echo -e "${RED}Error: Unknown domain '$arg'${RESET}"
+                echo -e "${YELLOW}Available domains: ${ALL_DOMAINS[*]}${RESET}"
+                exit 1
+            fi
             ;;
     esac
 done
 
-# Check for API key (using lego's environment variable name)
+# Default to all domains if none specified
+if [[ ${#SELECTED_DOMAINS[@]} -eq 0 ]]; then
+    SELECTED_DOMAINS=("${ALL_DOMAINS[@]}")
+fi
+
+# Check for API key
 if [[ -z "${GANDIV5_PERSONAL_TOKEN:-}" ]]; then
     echo -e "${RED}Error: GANDIV5_PERSONAL_TOKEN environment variable not set${RESET}"
     echo -e "${YELLOW}Please set it with: export GANDIV5_PERSONAL_TOKEN=your-api-key${RESET}"
@@ -36,204 +56,272 @@ if [[ -z "${GANDIV5_PERSONAL_TOKEN:-}" ]]; then
     exit 1
 fi
 
-echo -e "${BOLD}${BLUE}Updating Gandi DNS records for $DOMAIN...${RESET}"
-if [[ "$DRY_RUN" == "true" ]]; then
-    echo -e "${YELLOW}DRY RUN MODE - No changes will be made${RESET}"
-fi
-echo
-
-# Get the DNS zone file content from Nix (using Gandi-specific config with VPN IPs)
-echo -e "${CYAN}Extracting DNS records from Nix configuration (Gandi/VPN version)...${RESET}"
-ZONE_FILE=$(nix eval --impure --raw --expr '
-  let
-    pkgs = import <nixpkgs> {};
-    dns = (builtins.getFlake (toString ./.)).inputs.dns;
-    globals = (import ./globals.nix) {};
-    zone = import ./systems/common/services/dns/sbr.pm-gandi.nix { inherit dns globals; };
-  in
-  dns.lib.toString "sbr.pm" zone
-' 2>&1 | grep -v "^warning:" | grep -v "^Using saved setting" | grep -v "^building ")
-
-if [[ -z "$ZONE_FILE" ]]; then
-    echo -e "${RED}Error: Could not generate zone file${RESET}"
-    exit 1
-fi
-
-echo -e "${GREEN}DNS records extracted${RESET}"
-echo
-
-# Create a temporary file for the zone
-TEMP_ZONE=$(mktemp)
-echo "$ZONE_FILE" > "$TEMP_ZONE"
-
-# Extract A records (excluding SOA, NS, and comments)
-# Format examples:
-#   name.domain. IN A ip
-#   name.domain. TTL IN A ip
-#   *.name.domain. IN A ip
-RECORDS=$(grep -E "^\S+\s+(([0-9]+\s+)?IN\s+)?A\s+" "$TEMP_ZONE" | \
-          grep -v "SOA" | \
-          grep -v "^;" || true)
-
-rm -f "$TEMP_ZONE"
-
-if [[ -z "$RECORDS" ]]; then
-    echo -e "${YELLOW}No A records found in zone file${RESET}"
-    exit 0
-fi
-
-echo -e "${GREEN}Found $(echo "$RECORDS" | wc -l) A records to process${RESET}"
-echo
-
-# Get current DNS records from Gandi (needed for both update and removal detection)
-echo -e "${CYAN}Fetching current DNS records from Gandi...${RESET}"
-CURRENT_RECORDS=$(curl -s \
-  -H "Authorization: Bearer $GANDIV5_PERSONAL_TOKEN" \
-  "$API_URL" || echo "[]")
-
-echo -e "${GREEN}Current records fetched${RESET}"
-echo
-
-# Process each record
-UPDATED=0
-SKIPPED=0
-FAILED=0
-DELETED=0
-
-# Track all desired A record names from Nix config
-declare -A DESIRED_NAMES
-
-while IFS= read -r line; do
-    # Parse the line to extract: name, TTL, and value
-    # Handle various formats:
-    #   name.sbr.pm. IN A value
-    #   name.sbr.pm. TTL IN A value
-    #   *.name.sbr.pm. TTL IN A value
-
-    # Extract components
-    FULL_NAME=$(echo "$line" | awk '{print $1}')
-
-    # Check if second field is a number (TTL) or "IN"
-    SECOND_FIELD=$(echo "$line" | awk '{print $2}')
-    if [[ "$SECOND_FIELD" =~ ^[0-9]+$ ]]; then
-        TTL="$SECOND_FIELD"
-        VALUE=$(echo "$line" | awk '{print $5}')
-    else
-        TTL=10800
-        VALUE=$(echo "$line" | awk '{print $4}')
-    fi
-
-    # Convert FQDN to Gandi record name
-    # - "sbr.pm." (apex) → "@"
-    # - "name.sbr.pm." → "name"
-    # - "*.sbr.pm." → "*"
-    # - "*.name.sbr.pm." → "*.name"
-    if [[ "$FULL_NAME" == "sbr.pm." ]]; then
-        NAME="@"
-    else
-        NAME="${FULL_NAME%.sbr.pm.}"
-    fi
-
-    # Track this name as desired
-    DESIRED_NAMES["$NAME"]=1
-
-    echo -e "${BLUE}Processing: ${RESET}$NAME.$DOMAIN A $VALUE (TTL: $TTL)"
+# Process a single domain
+update_domain() {
+    local DOMAIN="$1"
+    local ZONE_FILE="$2"
+    local API_URL="https://api.gandi.net/v5/livedns/domains/$DOMAIN/records"
 
+    echo -e "${BOLD}${BLUE}━━━ Updating Gandi DNS records for $DOMAIN ━━━${RESET}"
     if [[ "$DRY_RUN" == "true" ]]; then
-        echo -e "  ${CYAN}[DRY RUN] Would update/create record${RESET}"
-        UPDATED=$((UPDATED + 1))
-    else
-        # Check if record exists and has same value
-        CURRENT_VALUE=$(echo "$CURRENT_RECORDS" | jq -r \
-          --arg name "$NAME" \
-          --arg type "A" \
-          '.[] | select(.rrset_name == $name and .rrset_type == $type) | .rrset_values[0]' \
-          2>/dev/null || echo "")
+        echo -e "${YELLOW}DRY RUN MODE - No changes will be made${RESET}"
+    fi
+    echo
 
-        if [[ "$CURRENT_VALUE" == "$VALUE" ]]; then
-            echo -e "  ${GREEN}✓ Record unchanged, skipping${RESET}"
-            SKIPPED=$((SKIPPED + 1))
-        else
-            if [[ -z "$CURRENT_VALUE" ]]; then
-                echo -e "  ${YELLOW}Creating new record...${RESET}"
+    # Get the DNS zone file content from Nix
+    echo -e "${CYAN}Extracting DNS records from Nix configuration...${RESET}"
+    ZONE_CONTENT=$(nix eval --impure --raw --expr '
+      let
+        pkgs = import <nixpkgs> {};
+        dns = (builtins.getFlake (toString ./.)).inputs.dns;
+        globals = (import ./globals.nix) {};
+        zone = import '"$ZONE_FILE"' { inherit dns globals; };
+      in
+      dns.lib.toString "'"$DOMAIN"'" zone
+    ' 2>&1 | grep -v "^warning:" | grep -v "^Using saved setting" | grep -v "^building ")
+
+    if [[ -z "$ZONE_CONTENT" ]]; then
+        echo -e "${RED}Error: Could not generate zone file for $DOMAIN${RESET}"
+        return 1
+    fi
+
+    echo -e "${GREEN}DNS records extracted${RESET}"
+    echo
+
+    # Create a temporary file for the zone
+    TEMP_ZONE=$(mktemp)
+    echo "$ZONE_CONTENT" > "$TEMP_ZONE"
+
+    # Extract A records
+    RECORDS=$(grep -E "^\S+\s+(([0-9]+\s+)?IN\s+)?A\s+" "$TEMP_ZONE" | \
+              grep -v "SOA" | \
+              grep -v "^;" || true)
+
+    # Extract CNAME records
+    CNAME_RECORDS=$(grep -E "^\S+\s+(([0-9]+\s+)?IN\s+)?CNAME\s+" "$TEMP_ZONE" | \
+                    grep -v "^;" || true)
+
+    # Extract TXT records
+    TXT_RECORDS=$(grep -E "^\S+\s+(([0-9]+\s+)?IN\s+)?TXT\s+" "$TEMP_ZONE" | \
+                  grep -v "^;" || true)
+
+    # Extract MX records
+    MX_RECORDS=$(grep -E "^\S+\s+(([0-9]+\s+)?IN\s+)?MX\s+" "$TEMP_ZONE" | \
+                 grep -v "^;" || true)
+
+    rm -f "$TEMP_ZONE"
+
+    TOTAL=$(echo "$RECORDS" | grep -c . || echo 0)
+    [[ -n "$CNAME_RECORDS" ]] && TOTAL=$((TOTAL + $(echo "$CNAME_RECORDS" | grep -c . || echo 0)))
+    echo -e "${GREEN}Found $TOTAL A/CNAME records to process${RESET}"
+    echo
+
+    # Get current DNS records from Gandi
+    echo -e "${CYAN}Fetching current DNS records from Gandi...${RESET}"
+    CURRENT_RECORDS=$(curl -s \
+      -H "Authorization: Bearer $GANDIV5_PERSONAL_TOKEN" \
+      "$API_URL" || echo "[]")
+
+    echo -e "${GREEN}Current records fetched${RESET}"
+    echo
+
+    # Process each record
+    local UPDATED=0
+    local SKIPPED=0
+    local FAILED=0
+    local DELETED=0
+
+    # Track all desired A record names from Nix config
+    declare -A DESIRED_NAMES
+
+    # Process A records
+    if [[ -n "$RECORDS" ]]; then
+        while IFS= read -r line; do
+            FULL_NAME=$(echo "$line" | awk '{print $1}')
+
+            SECOND_FIELD=$(echo "$line" | awk '{print $2}')
+            if [[ "$SECOND_FIELD" =~ ^[0-9]+$ ]]; then
+                TTL="$SECOND_FIELD"
+                VALUE=$(echo "$line" | awk '{print $5}')
             else
-                echo -e "  ${YELLOW}Updating record (was: $CURRENT_VALUE)...${RESET}"
+                TTL=10800
+                VALUE=$(echo "$line" | awk '{print $4}')
             fi
 
-            # Update/create the record
-            RESPONSE=$(curl -s -w "\n%{http_code}" \
-              -X PUT \
-              -H "Authorization: Bearer $GANDIV5_PERSONAL_TOKEN" \
-              -H "Content-Type: application/json" \
-              -d "{\"rrset_values\": [\"$VALUE\"], \"rrset_ttl\": $TTL}" \
-              "$API_URL/$NAME/A")
+            if [[ "$FULL_NAME" == "$DOMAIN." ]]; then
+                NAME="@"
+            else
+                NAME="${FULL_NAME%.$DOMAIN.}"
+            fi
 
-            HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
-            BODY=$(echo "$RESPONSE" | sed '$d')
+            DESIRED_NAMES["$NAME"]=1
 
-            if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then
-                echo -e "  ${GREEN}✓ Record updated successfully${RESET}"
+            echo -e "${BLUE}Processing: ${RESET}$NAME.$DOMAIN A $VALUE (TTL: $TTL)"
+
+            if [[ "$DRY_RUN" == "true" ]]; then
+                echo -e "  ${CYAN}[DRY RUN] Would update/create record${RESET}"
                 UPDATED=$((UPDATED + 1))
             else
-                echo -e "  ${RED}✗ Failed to update record (HTTP $HTTP_CODE)${RESET}"
-                echo -e "  ${RED}Response: $BODY${RESET}"
-                FAILED=$((FAILED + 1))
+                CURRENT_VALUE=$(echo "$CURRENT_RECORDS" | jq -r \
+                  --arg name "$NAME" \
+                  --arg type "A" \
+                  '.[] | select(.rrset_name == $name and .rrset_type == $type) | .rrset_values[0]' \
+                  2>/dev/null || echo "")
+
+                if [[ "$CURRENT_VALUE" == "$VALUE" ]]; then
+                    echo -e "  ${GREEN}✓ Record unchanged, skipping${RESET}"
+                    SKIPPED=$((SKIPPED + 1))
+                else
+                    if [[ -z "$CURRENT_VALUE" ]]; then
+                        echo -e "  ${YELLOW}Creating new record...${RESET}"
+                    else
+                        echo -e "  ${YELLOW}Updating record (was: $CURRENT_VALUE)...${RESET}"
+                    fi
+
+                    RESPONSE=$(curl -s -w "\n%{http_code}" \
+                      -X PUT \
+                      -H "Authorization: Bearer $GANDIV5_PERSONAL_TOKEN" \
+                      -H "Content-Type: application/json" \
+                      -d "{\"rrset_values\": [\"$VALUE\"], \"rrset_ttl\": $TTL}" \
+                      "$API_URL/$NAME/A")
+
+                    HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
+                    BODY=$(echo "$RESPONSE" | sed '$d')
+
+                    if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then
+                        echo -e "  ${GREEN}✓ Record updated successfully${RESET}"
+                        UPDATED=$((UPDATED + 1))
+                    else
+                        echo -e "  ${RED}✗ Failed to update record (HTTP $HTTP_CODE)${RESET}"
+                        echo -e "  ${RED}Response: $BODY${RESET}"
+                        FAILED=$((FAILED + 1))
+                    fi
+                fi
             fi
-        fi
+
+            echo
+        done <<< "$RECORDS"
     fi
 
+    # Process CNAME records
+    if [[ -n "$CNAME_RECORDS" ]]; then
+        while IFS= read -r line; do
+            FULL_NAME=$(echo "$line" | awk '{print $1}')
+
+            SECOND_FIELD=$(echo "$line" | awk '{print $2}')
+            if [[ "$SECOND_FIELD" =~ ^[0-9]+$ ]]; then
+                TTL="$SECOND_FIELD"
+                VALUE=$(echo "$line" | awk '{print $5}')
+            else
+                TTL=10800
+                VALUE=$(echo "$line" | awk '{print $4}')
+            fi
+
+            NAME="${FULL_NAME%.$DOMAIN.}"
+
+            echo -e "${BLUE}Processing: ${RESET}$NAME.$DOMAIN CNAME $VALUE (TTL: $TTL)"
+
+            if [[ "$DRY_RUN" == "true" ]]; then
+                echo -e "  ${CYAN}[DRY RUN] Would update/create CNAME record${RESET}"
+                UPDATED=$((UPDATED + 1))
+            else
+                CURRENT_VALUE=$(echo "$CURRENT_RECORDS" | jq -r \
+                  --arg name "$NAME" \
+                  --arg type "CNAME" \
+                  '.[] | select(.rrset_name == $name and .rrset_type == $type) | .rrset_values[0]' \
+                  2>/dev/null || echo "")
+
+                if [[ "$CURRENT_VALUE" == "$VALUE" ]]; then
+                    echo -e "  ${GREEN}✓ Record unchanged, skipping${RESET}"
+                    SKIPPED=$((SKIPPED + 1))
+                else
+                    if [[ -z "$CURRENT_VALUE" ]]; then
+                        echo -e "  ${YELLOW}Creating new CNAME record...${RESET}"
+                    else
+                        echo -e "  ${YELLOW}Updating CNAME record (was: $CURRENT_VALUE)...${RESET}"
+                    fi
+
+                    RESPONSE=$(curl -s -w "\n%{http_code}" \
+                      -X PUT \
+                      -H "Authorization: Bearer $GANDIV5_PERSONAL_TOKEN" \
+                      -H "Content-Type: application/json" \
+                      -d "{\"rrset_values\": [\"$VALUE\"], \"rrset_ttl\": $TTL}" \
+                      "$API_URL/$NAME/CNAME")
+
+                    HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
+                    BODY=$(echo "$RESPONSE" | sed '$d')
+
+                    if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then
+                        echo -e "  ${GREEN}✓ CNAME record updated successfully${RESET}"
+                        UPDATED=$((UPDATED + 1))
+                    else
+                        echo -e "  ${RED}✗ Failed to update CNAME record (HTTP $HTTP_CODE)${RESET}"
+                        echo -e "  ${RED}Response: $BODY${RESET}"
+                        FAILED=$((FAILED + 1))
+                    fi
+                fi
+            fi
+
+            echo
+        done <<< "$CNAME_RECORDS"
+    fi
+
+    # Remove stale A records from Gandi that are no longer in Nix config
+    echo -e "${BOLD}${BLUE}Checking for stale A records to remove...${RESET}"
     echo
-done <<< "$RECORDS"
 
-# Remove stale A records from Gandi that are no longer in Nix config
-echo -e "${BOLD}${BLUE}Checking for stale A records to remove...${RESET}"
-echo
+    GANDI_A_NAMES=$(echo "$CURRENT_RECORDS" | jq -r '.[] | select(.rrset_type == "A") | .rrset_name' 2>/dev/null || true)
 
-GANDI_A_NAMES=$(echo "$CURRENT_RECORDS" | jq -r '.[] | select(.rrset_type == "A") | .rrset_name' 2>/dev/null || true)
+    while IFS= read -r gandi_name; do
+        [[ -z "$gandi_name" ]] && continue
 
-while IFS= read -r gandi_name; do
-    [[ -z "$gandi_name" ]] && continue
+        if [[ -z "${DESIRED_NAMES[$gandi_name]+_}" ]]; then
+            GANDI_VALUE=$(echo "$CURRENT_RECORDS" | jq -r \
+              --arg name "$gandi_name" \
+              '.[] | select(.rrset_name == $name and .rrset_type == "A") | .rrset_values | join(", ")' \
+              2>/dev/null || echo "unknown")
 
-    if [[ -z "${DESIRED_NAMES[$gandi_name]+_}" ]]; then
-        GANDI_VALUE=$(echo "$CURRENT_RECORDS" | jq -r \
-          --arg name "$gandi_name" \
-          '.[] | select(.rrset_name == $name and .rrset_type == "A") | .rrset_values | join(", ")' \
-          2>/dev/null || echo "unknown")
+            echo -e "${RED}Stale record: ${RESET}$gandi_name.$DOMAIN A $GANDI_VALUE"
 
-        echo -e "${RED}Stale record: ${RESET}$gandi_name.$DOMAIN A $GANDI_VALUE"
-
-        if [[ "$DRY_RUN" == "true" ]]; then
-            echo -e "  ${CYAN}[DRY RUN] Would delete record${RESET}"
-            DELETED=$((DELETED + 1))
-        else
-            RESPONSE=$(curl -s -w "\n%{http_code}" \
-              -X DELETE \
-              -H "Authorization: Bearer $GANDIV5_PERSONAL_TOKEN" \
-              "$API_URL/$gandi_name/A")
-
-            HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
-            BODY=$(echo "$RESPONSE" | sed '$d')
-
-            if [[ "$HTTP_CODE" == "204" ]] || [[ "$HTTP_CODE" == "200" ]]; then
-                echo -e "  ${GREEN}✓ Record deleted successfully${RESET}"
+            if [[ "$DRY_RUN" == "true" ]]; then
+                echo -e "  ${CYAN}[DRY RUN] Would delete record${RESET}"
                 DELETED=$((DELETED + 1))
             else
-                echo -e "  ${RED}✗ Failed to delete record (HTTP $HTTP_CODE)${RESET}"
-                echo -e "  ${RED}Response: $BODY${RESET}"
-                FAILED=$((FAILED + 1))
+                RESPONSE=$(curl -s -w "\n%{http_code}" \
+                  -X DELETE \
+                  -H "Authorization: Bearer $GANDIV5_PERSONAL_TOKEN" \
+                  "$API_URL/$gandi_name/A")
+
+                HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
+                BODY=$(echo "$RESPONSE" | sed '$d')
+
+                if [[ "$HTTP_CODE" == "204" ]] || [[ "$HTTP_CODE" == "200" ]]; then
+                    echo -e "  ${GREEN}✓ Record deleted successfully${RESET}"
+                    DELETED=$((DELETED + 1))
+                else
+                    echo -e "  ${RED}✗ Failed to delete record (HTTP $HTTP_CODE)${RESET}"
+                    echo -e "  ${RED}Response: $BODY${RESET}"
+                    FAILED=$((FAILED + 1))
+                fi
             fi
+
+            echo
         fi
+    done <<< "$GANDI_A_NAMES"
 
-        echo
+    echo -e "${BOLD}${GREEN}DNS update complete for $DOMAIN!${RESET}"
+    echo
+    echo -e "${CYAN}Summary:${RESET}"
+    echo -e "  Updated: $UPDATED"
+    echo -e "  Skipped (unchanged): $SKIPPED"
+    echo -e "  Deleted: $DELETED"
+    if [[ $FAILED -gt 0 ]]; then
+        echo -e "  ${RED}Failed: $FAILED${RESET}"
     fi
-done <<< "$GANDI_A_NAMES"
+    echo
+}
 
-echo -e "${BOLD}${GREEN}DNS update complete!${RESET}"
-echo
-echo -e "${CYAN}Summary:${RESET}"
-echo -e "  Updated: $UPDATED"
-echo -e "  Skipped (unchanged): $SKIPPED"
-echo -e "  Deleted: $DELETED"
-if [[ $FAILED -gt 0 ]]; then
-    echo -e "  ${RED}Failed: $FAILED${RESET}"
-fi
+# Main loop
+for domain in "${SELECTED_DOMAINS[@]}"; do
+    update_domain "$domain" "${DOMAIN_ZONE_FILES[$domain]}"
+done