flake-update-20260505
  1#!/usr/bin/env bash
  2# Update Gandi DNS records from NixOS DNS configuration
  3# Usage: ./scripts/update-gandi-dns.sh [--dry-run]
  4
  5set -euo pipefail
  6
  7# Colors
  8BOLD='\033[1m'
  9GREEN='\033[0;32m'
 10BLUE='\033[0;34m'
 11YELLOW='\033[0;33m'
 12RED='\033[0;31m'
 13CYAN='\033[0;36m'
 14RESET='\033[0m'
 15
 16DOMAIN="sbr.pm"
 17API_URL="https://api.gandi.net/v5/livedns/domains/$DOMAIN/records"
 18DRY_RUN=false
 19
 20# Parse arguments
 21for arg in "$@"; do
 22    case $arg in
 23        --dry-run)
 24            DRY_RUN=true
 25            shift
 26            ;;
 27    esac
 28done
 29
 30# Check for API key (using lego's environment variable name)
 31if [[ -z "${GANDIV5_PERSONAL_TOKEN:-}" ]]; then
 32    echo -e "${RED}Error: GANDIV5_PERSONAL_TOKEN environment variable not set${RESET}"
 33    echo -e "${YELLOW}Please set it with: export GANDIV5_PERSONAL_TOKEN=your-api-key${RESET}"
 34    echo -e "${CYAN}Or source it from the agenix secret on rhea:${RESET}"
 35    echo -e "${CYAN}  source /run/agenix/gandi.env${RESET}"
 36    exit 1
 37fi
 38
 39echo -e "${BOLD}${BLUE}Updating Gandi DNS records for $DOMAIN...${RESET}"
 40if [[ "$DRY_RUN" == "true" ]]; then
 41    echo -e "${YELLOW}DRY RUN MODE - No changes will be made${RESET}"
 42fi
 43echo
 44
 45# Get the DNS zone file content from Nix (using Gandi-specific config with VPN IPs)
 46echo -e "${CYAN}Extracting DNS records from Nix configuration (Gandi/VPN version)...${RESET}"
 47ZONE_FILE=$(nix eval --impure --raw --expr '
 48  let
 49    pkgs = import <nixpkgs> {};
 50    dns = (builtins.getFlake (toString ./.)).inputs.dns;
 51    globals = (import ./globals.nix) {};
 52    zone = import ./systems/common/services/dns/sbr.pm-gandi.nix { inherit dns globals; };
 53  in
 54  dns.lib.toString "sbr.pm" zone
 55' 2>&1 | grep -v "^warning:" | grep -v "^Using saved setting" | grep -v "^building ")
 56
 57if [[ -z "$ZONE_FILE" ]]; then
 58    echo -e "${RED}Error: Could not generate zone file${RESET}"
 59    exit 1
 60fi
 61
 62echo -e "${GREEN}DNS records extracted${RESET}"
 63echo
 64
 65# Create a temporary file for the zone
 66TEMP_ZONE=$(mktemp)
 67echo "$ZONE_FILE" > "$TEMP_ZONE"
 68
 69# Extract A records (excluding SOA, NS, and comments)
 70# Format examples:
 71#   name.domain. IN A ip
 72#   name.domain. TTL IN A ip
 73#   *.name.domain. IN A ip
 74RECORDS=$(grep -E "^\S+\s+(([0-9]+\s+)?IN\s+)?A\s+" "$TEMP_ZONE" | \
 75          grep -v "SOA" | \
 76          grep -v "^;" || true)
 77
 78rm -f "$TEMP_ZONE"
 79
 80if [[ -z "$RECORDS" ]]; then
 81    echo -e "${YELLOW}No A records found in zone file${RESET}"
 82    exit 0
 83fi
 84
 85echo -e "${GREEN}Found $(echo "$RECORDS" | wc -l) A records to process${RESET}"
 86echo
 87
 88# Get current DNS records from Gandi (needed for both update and removal detection)
 89echo -e "${CYAN}Fetching current DNS records from Gandi...${RESET}"
 90CURRENT_RECORDS=$(curl -s \
 91  -H "Authorization: Bearer $GANDIV5_PERSONAL_TOKEN" \
 92  "$API_URL" || echo "[]")
 93
 94echo -e "${GREEN}Current records fetched${RESET}"
 95echo
 96
 97# Process each record
 98UPDATED=0
 99SKIPPED=0
100FAILED=0
101DELETED=0
102
103# Track all desired A record names from Nix config
104declare -A DESIRED_NAMES
105
106while IFS= read -r line; do
107    # Parse the line to extract: name, TTL, and value
108    # Handle various formats:
109    #   name.sbr.pm. IN A value
110    #   name.sbr.pm. TTL IN A value
111    #   *.name.sbr.pm. TTL IN A value
112
113    # Extract components
114    FULL_NAME=$(echo "$line" | awk '{print $1}')
115
116    # Check if second field is a number (TTL) or "IN"
117    SECOND_FIELD=$(echo "$line" | awk '{print $2}')
118    if [[ "$SECOND_FIELD" =~ ^[0-9]+$ ]]; then
119        TTL="$SECOND_FIELD"
120        VALUE=$(echo "$line" | awk '{print $5}')
121    else
122        TTL=10800
123        VALUE=$(echo "$line" | awk '{print $4}')
124    fi
125
126    # Convert FQDN to Gandi record name
127    # - "sbr.pm." (apex) → "@"
128    # - "name.sbr.pm." → "name"
129    # - "*.sbr.pm." → "*"
130    # - "*.name.sbr.pm." → "*.name"
131    if [[ "$FULL_NAME" == "sbr.pm." ]]; then
132        NAME="@"
133    else
134        NAME="${FULL_NAME%.sbr.pm.}"
135    fi
136
137    # Track this name as desired
138    DESIRED_NAMES["$NAME"]=1
139
140    echo -e "${BLUE}Processing: ${RESET}$NAME.$DOMAIN A $VALUE (TTL: $TTL)"
141
142    if [[ "$DRY_RUN" == "true" ]]; then
143        echo -e "  ${CYAN}[DRY RUN] Would update/create record${RESET}"
144        UPDATED=$((UPDATED + 1))
145    else
146        # Check if record exists and has same value
147        CURRENT_VALUE=$(echo "$CURRENT_RECORDS" | jq -r \
148          --arg name "$NAME" \
149          --arg type "A" \
150          '.[] | select(.rrset_name == $name and .rrset_type == $type) | .rrset_values[0]' \
151          2>/dev/null || echo "")
152
153        if [[ "$CURRENT_VALUE" == "$VALUE" ]]; then
154            echo -e "  ${GREEN}✓ Record unchanged, skipping${RESET}"
155            SKIPPED=$((SKIPPED + 1))
156        else
157            if [[ -z "$CURRENT_VALUE" ]]; then
158                echo -e "  ${YELLOW}Creating new record...${RESET}"
159            else
160                echo -e "  ${YELLOW}Updating record (was: $CURRENT_VALUE)...${RESET}"
161            fi
162
163            # Update/create the record
164            RESPONSE=$(curl -s -w "\n%{http_code}" \
165              -X PUT \
166              -H "Authorization: Bearer $GANDIV5_PERSONAL_TOKEN" \
167              -H "Content-Type: application/json" \
168              -d "{\"rrset_values\": [\"$VALUE\"], \"rrset_ttl\": $TTL}" \
169              "$API_URL/$NAME/A")
170
171            HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
172            BODY=$(echo "$RESPONSE" | sed '$d')
173
174            if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then
175                echo -e "  ${GREEN}✓ Record updated successfully${RESET}"
176                UPDATED=$((UPDATED + 1))
177            else
178                echo -e "  ${RED}✗ Failed to update record (HTTP $HTTP_CODE)${RESET}"
179                echo -e "  ${RED}Response: $BODY${RESET}"
180                FAILED=$((FAILED + 1))
181            fi
182        fi
183    fi
184
185    echo
186done <<< "$RECORDS"
187
188# Remove stale A records from Gandi that are no longer in Nix config
189echo -e "${BOLD}${BLUE}Checking for stale A records to remove...${RESET}"
190echo
191
192GANDI_A_NAMES=$(echo "$CURRENT_RECORDS" | jq -r '.[] | select(.rrset_type == "A") | .rrset_name' 2>/dev/null || true)
193
194while IFS= read -r gandi_name; do
195    [[ -z "$gandi_name" ]] && continue
196
197    if [[ -z "${DESIRED_NAMES[$gandi_name]+_}" ]]; then
198        GANDI_VALUE=$(echo "$CURRENT_RECORDS" | jq -r \
199          --arg name "$gandi_name" \
200          '.[] | select(.rrset_name == $name and .rrset_type == "A") | .rrset_values | join(", ")' \
201          2>/dev/null || echo "unknown")
202
203        echo -e "${RED}Stale record: ${RESET}$gandi_name.$DOMAIN A $GANDI_VALUE"
204
205        if [[ "$DRY_RUN" == "true" ]]; then
206            echo -e "  ${CYAN}[DRY RUN] Would delete record${RESET}"
207            DELETED=$((DELETED + 1))
208        else
209            RESPONSE=$(curl -s -w "\n%{http_code}" \
210              -X DELETE \
211              -H "Authorization: Bearer $GANDIV5_PERSONAL_TOKEN" \
212              "$API_URL/$gandi_name/A")
213
214            HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
215            BODY=$(echo "$RESPONSE" | sed '$d')
216
217            if [[ "$HTTP_CODE" == "204" ]] || [[ "$HTTP_CODE" == "200" ]]; then
218                echo -e "  ${GREEN}✓ Record deleted successfully${RESET}"
219                DELETED=$((DELETED + 1))
220            else
221                echo -e "  ${RED}✗ Failed to delete record (HTTP $HTTP_CODE)${RESET}"
222                echo -e "  ${RED}Response: $BODY${RESET}"
223                FAILED=$((FAILED + 1))
224            fi
225        fi
226
227        echo
228    fi
229done <<< "$GANDI_A_NAMES"
230
231echo -e "${BOLD}${GREEN}DNS update complete!${RESET}"
232echo
233echo -e "${CYAN}Summary:${RESET}"
234echo -e "  Updated: $UPDATED"
235echo -e "  Skipped (unchanged): $SKIPPED"
236echo -e "  Deleted: $DELETED"
237if [[ $FAILED -gt 0 ]]; then
238    echo -e "  ${RED}Failed: $FAILED${RESET}"
239fi