main
  1#!/usr/bin/env bash
  2# Update Gandi DNS records from NixOS DNS configuration
  3# Usage: ./tools/update-gandi-dns.sh [--dry-run] [domain...]
  4# If no domains specified, updates all managed domains.
  5
  6set -euo pipefail
  7
  8# Colors
  9BOLD='\033[1m'
 10GREEN='\033[0;32m'
 11BLUE='\033[0;34m'
 12YELLOW='\033[0;33m'
 13RED='\033[0;31m'
 14CYAN='\033[0;36m'
 15RESET='\033[0m'
 16
 17DRY_RUN=false
 18
 19# Domain -> zone file mapping
 20declare -A DOMAIN_ZONE_FILES
 21DOMAIN_ZONE_FILES["sbr.pm"]="./systems/common/services/dns/sbr.pm-gandi.nix"
 22DOMAIN_ZONE_FILES["demeester.fr"]="./systems/common/services/dns/demeester.fr.nix"
 23
 24ALL_DOMAINS=("sbr.pm" "demeester.fr")
 25SELECTED_DOMAINS=()
 26
 27# Parse arguments
 28for arg in "$@"; do
 29    case $arg in
 30        --dry-run)
 31            DRY_RUN=true
 32            ;;
 33        *)
 34            if [[ -n "${DOMAIN_ZONE_FILES[$arg]+_}" ]]; then
 35                SELECTED_DOMAINS+=("$arg")
 36            else
 37                echo -e "${RED}Error: Unknown domain '$arg'${RESET}"
 38                echo -e "${YELLOW}Available domains: ${ALL_DOMAINS[*]}${RESET}"
 39                exit 1
 40            fi
 41            ;;
 42    esac
 43done
 44
 45# Default to all domains if none specified
 46if [[ ${#SELECTED_DOMAINS[@]} -eq 0 ]]; then
 47    SELECTED_DOMAINS=("${ALL_DOMAINS[@]}")
 48fi
 49
 50# Check for API key
 51if [[ -z "${GANDIV5_PERSONAL_TOKEN:-}" ]]; then
 52    echo -e "${RED}Error: GANDIV5_PERSONAL_TOKEN environment variable not set${RESET}"
 53    echo -e "${YELLOW}Please set it with: export GANDIV5_PERSONAL_TOKEN=your-api-key${RESET}"
 54    echo -e "${CYAN}Or source it from the agenix secret on rhea:${RESET}"
 55    echo -e "${CYAN}  source /run/agenix/gandi.env${RESET}"
 56    exit 1
 57fi
 58
 59# Process a single domain
 60update_domain() {
 61    local DOMAIN="$1"
 62    local ZONE_FILE="$2"
 63    local API_URL="https://api.gandi.net/v5/livedns/domains/$DOMAIN/records"
 64
 65    echo -e "${BOLD}${BLUE}━━━ Updating Gandi DNS records for $DOMAIN ━━━${RESET}"
 66    if [[ "$DRY_RUN" == "true" ]]; then
 67        echo -e "${YELLOW}DRY RUN MODE - No changes will be made${RESET}"
 68    fi
 69    echo
 70
 71    # Get the DNS zone file content from Nix
 72    echo -e "${CYAN}Extracting DNS records from Nix configuration...${RESET}"
 73    ZONE_CONTENT=$(nix eval --impure --raw --expr '
 74      let
 75        pkgs = import <nixpkgs> {};
 76        dns = (builtins.getFlake (toString ./.)).inputs.dns;
 77        globals = (import ./globals.nix) {};
 78        zone = import '"$ZONE_FILE"' { inherit dns globals; };
 79      in
 80      dns.lib.toString "'"$DOMAIN"'" zone
 81    ' 2>&1 | grep -v "^warning:" | grep -v "^Using saved setting" | grep -v "^building ")
 82
 83    if [[ -z "$ZONE_CONTENT" ]]; then
 84        echo -e "${RED}Error: Could not generate zone file for $DOMAIN${RESET}"
 85        return 1
 86    fi
 87
 88    echo -e "${GREEN}DNS records extracted${RESET}"
 89    echo
 90
 91    # Create a temporary file for the zone
 92    TEMP_ZONE=$(mktemp)
 93    echo "$ZONE_CONTENT" > "$TEMP_ZONE"
 94
 95    # Extract A records
 96    RECORDS=$(grep -E "^\S+\s+(([0-9]+\s+)?IN\s+)?A\s+" "$TEMP_ZONE" | \
 97              grep -v "SOA" | \
 98              grep -v "^;" || true)
 99
100    # Extract CNAME records
101    CNAME_RECORDS=$(grep -E "^\S+\s+(([0-9]+\s+)?IN\s+)?CNAME\s+" "$TEMP_ZONE" | \
102                    grep -v "^;" || true)
103
104    # Extract TXT records
105    TXT_RECORDS=$(grep -E "^\S+\s+(([0-9]+\s+)?IN\s+)?TXT\s+" "$TEMP_ZONE" | \
106                  grep -v "^;" || true)
107
108    # Extract MX records
109    MX_RECORDS=$(grep -E "^\S+\s+(([0-9]+\s+)?IN\s+)?MX\s+" "$TEMP_ZONE" | \
110                 grep -v "^;" || true)
111
112    rm -f "$TEMP_ZONE"
113
114    TOTAL=$(echo "$RECORDS" | grep -c . || echo 0)
115    [[ -n "$CNAME_RECORDS" ]] && TOTAL=$((TOTAL + $(echo "$CNAME_RECORDS" | grep -c . || echo 0)))
116    echo -e "${GREEN}Found $TOTAL A/CNAME records to process${RESET}"
117    echo
118
119    # Get current DNS records from Gandi
120    echo -e "${CYAN}Fetching current DNS records from Gandi...${RESET}"
121    CURRENT_RECORDS=$(curl -s \
122      -H "Authorization: Bearer $GANDIV5_PERSONAL_TOKEN" \
123      "$API_URL" || echo "[]")
124
125    echo -e "${GREEN}Current records fetched${RESET}"
126    echo
127
128    # Process each record
129    local UPDATED=0
130    local SKIPPED=0
131    local FAILED=0
132    local DELETED=0
133
134    # Track all desired A record names from Nix config
135    declare -A DESIRED_NAMES
136
137    # Process A records
138    if [[ -n "$RECORDS" ]]; then
139        while IFS= read -r line; do
140            FULL_NAME=$(echo "$line" | awk '{print $1}')
141
142            SECOND_FIELD=$(echo "$line" | awk '{print $2}')
143            if [[ "$SECOND_FIELD" =~ ^[0-9]+$ ]]; then
144                TTL="$SECOND_FIELD"
145                VALUE=$(echo "$line" | awk '{print $5}')
146            else
147                TTL=10800
148                VALUE=$(echo "$line" | awk '{print $4}')
149            fi
150
151            if [[ "$FULL_NAME" == "$DOMAIN." ]]; then
152                NAME="@"
153            else
154                NAME="${FULL_NAME%.$DOMAIN.}"
155            fi
156
157            DESIRED_NAMES["$NAME"]=1
158
159            echo -e "${BLUE}Processing: ${RESET}$NAME.$DOMAIN A $VALUE (TTL: $TTL)"
160
161            if [[ "$DRY_RUN" == "true" ]]; then
162                echo -e "  ${CYAN}[DRY RUN] Would update/create record${RESET}"
163                UPDATED=$((UPDATED + 1))
164            else
165                CURRENT_VALUE=$(echo "$CURRENT_RECORDS" | jq -r \
166                  --arg name "$NAME" \
167                  --arg type "A" \
168                  '.[] | select(.rrset_name == $name and .rrset_type == $type) | .rrset_values[0]' \
169                  2>/dev/null || echo "")
170
171                if [[ "$CURRENT_VALUE" == "$VALUE" ]]; then
172                    echo -e "  ${GREEN}✓ Record unchanged, skipping${RESET}"
173                    SKIPPED=$((SKIPPED + 1))
174                else
175                    if [[ -z "$CURRENT_VALUE" ]]; then
176                        echo -e "  ${YELLOW}Creating new record...${RESET}"
177                    else
178                        echo -e "  ${YELLOW}Updating record (was: $CURRENT_VALUE)...${RESET}"
179                    fi
180
181                    RESPONSE=$(curl -s -w "\n%{http_code}" \
182                      -X PUT \
183                      -H "Authorization: Bearer $GANDIV5_PERSONAL_TOKEN" \
184                      -H "Content-Type: application/json" \
185                      -d "{\"rrset_values\": [\"$VALUE\"], \"rrset_ttl\": $TTL}" \
186                      "$API_URL/$NAME/A")
187
188                    HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
189                    BODY=$(echo "$RESPONSE" | sed '$d')
190
191                    if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then
192                        echo -e "  ${GREEN}✓ Record updated successfully${RESET}"
193                        UPDATED=$((UPDATED + 1))
194                    else
195                        echo -e "  ${RED}✗ Failed to update record (HTTP $HTTP_CODE)${RESET}"
196                        echo -e "  ${RED}Response: $BODY${RESET}"
197                        FAILED=$((FAILED + 1))
198                    fi
199                fi
200            fi
201
202            echo
203        done <<< "$RECORDS"
204    fi
205
206    # Process CNAME records
207    if [[ -n "$CNAME_RECORDS" ]]; then
208        while IFS= read -r line; do
209            FULL_NAME=$(echo "$line" | awk '{print $1}')
210
211            SECOND_FIELD=$(echo "$line" | awk '{print $2}')
212            if [[ "$SECOND_FIELD" =~ ^[0-9]+$ ]]; then
213                TTL="$SECOND_FIELD"
214                VALUE=$(echo "$line" | awk '{print $5}')
215            else
216                TTL=10800
217                VALUE=$(echo "$line" | awk '{print $4}')
218            fi
219
220            NAME="${FULL_NAME%.$DOMAIN.}"
221
222            echo -e "${BLUE}Processing: ${RESET}$NAME.$DOMAIN CNAME $VALUE (TTL: $TTL)"
223
224            if [[ "$DRY_RUN" == "true" ]]; then
225                echo -e "  ${CYAN}[DRY RUN] Would update/create CNAME record${RESET}"
226                UPDATED=$((UPDATED + 1))
227            else
228                CURRENT_VALUE=$(echo "$CURRENT_RECORDS" | jq -r \
229                  --arg name "$NAME" \
230                  --arg type "CNAME" \
231                  '.[] | select(.rrset_name == $name and .rrset_type == $type) | .rrset_values[0]' \
232                  2>/dev/null || echo "")
233
234                if [[ "$CURRENT_VALUE" == "$VALUE" ]]; then
235                    echo -e "  ${GREEN}✓ Record unchanged, skipping${RESET}"
236                    SKIPPED=$((SKIPPED + 1))
237                else
238                    if [[ -z "$CURRENT_VALUE" ]]; then
239                        echo -e "  ${YELLOW}Creating new CNAME record...${RESET}"
240                    else
241                        echo -e "  ${YELLOW}Updating CNAME record (was: $CURRENT_VALUE)...${RESET}"
242                    fi
243
244                    RESPONSE=$(curl -s -w "\n%{http_code}" \
245                      -X PUT \
246                      -H "Authorization: Bearer $GANDIV5_PERSONAL_TOKEN" \
247                      -H "Content-Type: application/json" \
248                      -d "{\"rrset_values\": [\"$VALUE\"], \"rrset_ttl\": $TTL}" \
249                      "$API_URL/$NAME/CNAME")
250
251                    HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
252                    BODY=$(echo "$RESPONSE" | sed '$d')
253
254                    if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then
255                        echo -e "  ${GREEN}✓ CNAME record updated successfully${RESET}"
256                        UPDATED=$((UPDATED + 1))
257                    else
258                        echo -e "  ${RED}✗ Failed to update CNAME record (HTTP $HTTP_CODE)${RESET}"
259                        echo -e "  ${RED}Response: $BODY${RESET}"
260                        FAILED=$((FAILED + 1))
261                    fi
262                fi
263            fi
264
265            echo
266        done <<< "$CNAME_RECORDS"
267    fi
268
269    # Process TXT records
270    # Group TXT records by name since a single name can have multiple values
271    if [[ -n "$TXT_RECORDS" ]]; then
272        declare -A TXT_BY_NAME
273        while IFS= read -r line; do
274            FULL_NAME=$(echo "$line" | awk '{print $1}')
275
276            if [[ "$FULL_NAME" == "$DOMAIN." ]]; then
277                NAME="@"
278            else
279                NAME="${FULL_NAME%.$DOMAIN.}"
280            fi
281
282            # Extract TXT value (everything after TXT, keeping quotes)
283            VALUE=$(echo "$line" | sed 's/.*TXT[[:space:]]*//' | sed 's/^"//;s/"$//')
284
285            if [[ -n "${TXT_BY_NAME[$NAME]+_}" ]]; then
286                TXT_BY_NAME[$NAME]="${TXT_BY_NAME[$NAME]}|||$VALUE"
287            else
288                TXT_BY_NAME[$NAME]="$VALUE"
289            fi
290        done <<< "$TXT_RECORDS"
291
292        for NAME in "${!TXT_BY_NAME[@]}"; do
293            IFS='|||' read -ra VALUES <<< "${TXT_BY_NAME[$NAME]}"
294            # Filter empty values from split
295            CLEAN_VALUES=()
296            for v in "${VALUES[@]}"; do
297                [[ -n "$v" ]] && CLEAN_VALUES+=("$v")
298            done
299
300            echo -e "${BLUE}Processing: ${RESET}$NAME.$DOMAIN TXT (${#CLEAN_VALUES[@]} values)"
301
302            if [[ "$DRY_RUN" == "true" ]]; then
303                echo -e "  ${CYAN}[DRY RUN] Would update/create TXT record${RESET}"
304                for v in "${CLEAN_VALUES[@]}"; do
305                    echo -e "    ${CYAN}$v${RESET}"
306                done
307                UPDATED=$((UPDATED + 1))
308            else
309                # Build JSON array of values
310                JSON_VALUES=$(printf '%s\n' "${CLEAN_VALUES[@]}" | jq -R . | jq -s .)
311
312                # Get current values
313                CURRENT_TXT=$(echo "$CURRENT_RECORDS" | jq -r \
314                  --arg name "$NAME" \
315                  '.[] | select(.rrset_name == $name and .rrset_type == "TXT") | .rrset_values | sort | join(",")' \
316                  2>/dev/null || echo "")
317
318                NEW_TXT=$(echo "$JSON_VALUES" | jq -r 'sort | join(",")' 2>/dev/null || echo "")
319
320                if [[ "$CURRENT_TXT" == "$NEW_TXT" ]]; then
321                    echo -e "  ${GREEN}✓ Record unchanged, skipping${RESET}"
322                    SKIPPED=$((SKIPPED + 1))
323                else
324                    if [[ -z "$CURRENT_TXT" ]]; then
325                        echo -e "  ${YELLOW}Creating new TXT record...${RESET}"
326                    else
327                        echo -e "  ${YELLOW}Updating TXT record...${RESET}"
328                    fi
329
330                    RESPONSE=$(curl -s -w "\n%{http_code}" \
331                      -X PUT \
332                      -H "Authorization: Bearer $GANDIV5_PERSONAL_TOKEN" \
333                      -H "Content-Type: application/json" \
334                      -d "{\"rrset_values\": $JSON_VALUES, \"rrset_ttl\": 10800}" \
335                      "$API_URL/$NAME/TXT")
336
337                    HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
338                    BODY=$(echo "$RESPONSE" | sed '$d')
339
340                    if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then
341                        echo -e "  ${GREEN}✓ TXT record updated successfully${RESET}"
342                        UPDATED=$((UPDATED + 1))
343                    else
344                        echo -e "  ${RED}✗ Failed to update TXT record (HTTP $HTTP_CODE)${RESET}"
345                        echo -e "  ${RED}Response: $BODY${RESET}"
346                        FAILED=$((FAILED + 1))
347                    fi
348                fi
349            fi
350
351            echo
352        done
353        unset TXT_BY_NAME
354    fi
355
356    # Remove stale A records from Gandi that are no longer in Nix config
357    echo -e "${BOLD}${BLUE}Checking for stale A records to remove...${RESET}"
358    echo
359
360    GANDI_A_NAMES=$(echo "$CURRENT_RECORDS" | jq -r '.[] | select(.rrset_type == "A") | .rrset_name' 2>/dev/null || true)
361
362    while IFS= read -r gandi_name; do
363        [[ -z "$gandi_name" ]] && continue
364
365        if [[ -z "${DESIRED_NAMES[$gandi_name]+_}" ]]; then
366            GANDI_VALUE=$(echo "$CURRENT_RECORDS" | jq -r \
367              --arg name "$gandi_name" \
368              '.[] | select(.rrset_name == $name and .rrset_type == "A") | .rrset_values | join(", ")' \
369              2>/dev/null || echo "unknown")
370
371            echo -e "${RED}Stale record: ${RESET}$gandi_name.$DOMAIN A $GANDI_VALUE"
372
373            if [[ "$DRY_RUN" == "true" ]]; then
374                echo -e "  ${CYAN}[DRY RUN] Would delete record${RESET}"
375                DELETED=$((DELETED + 1))
376            else
377                RESPONSE=$(curl -s -w "\n%{http_code}" \
378                  -X DELETE \
379                  -H "Authorization: Bearer $GANDIV5_PERSONAL_TOKEN" \
380                  "$API_URL/$gandi_name/A")
381
382                HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
383                BODY=$(echo "$RESPONSE" | sed '$d')
384
385                if [[ "$HTTP_CODE" == "204" ]] || [[ "$HTTP_CODE" == "200" ]]; then
386                    echo -e "  ${GREEN}✓ Record deleted successfully${RESET}"
387                    DELETED=$((DELETED + 1))
388                else
389                    echo -e "  ${RED}✗ Failed to delete record (HTTP $HTTP_CODE)${RESET}"
390                    echo -e "  ${RED}Response: $BODY${RESET}"
391                    FAILED=$((FAILED + 1))
392                fi
393            fi
394
395            echo
396        fi
397    done <<< "$GANDI_A_NAMES"
398
399    echo -e "${BOLD}${GREEN}DNS update complete for $DOMAIN!${RESET}"
400    echo
401    echo -e "${CYAN}Summary:${RESET}"
402    echo -e "  Updated: $UPDATED"
403    echo -e "  Skipped (unchanged): $SKIPPED"
404    echo -e "  Deleted: $DELETED"
405    if [[ $FAILED -gt 0 ]]; then
406        echo -e "  ${RED}Failed: $FAILED${RESET}"
407    fi
408    echo
409}
410
411# Main loop
412for domain in "${SELECTED_DOMAINS[@]}"; do
413    update_domain "$domain" "${DOMAIN_ZONE_FILES[$domain]}"
414done