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