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