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