Commit 7b040115963c

Vincent Demeester <vincent@sbr.pm>
2025-11-23 18:56:37
feat(dns): Add automation scripts and refactor DNS configuration
This change introduces DNS management automation and refactors the DNS zone configuration to be more maintainable and programmatic. Changes: - Created dns-helpers.nix library with reusable DNS record generation functions - Added scripts/show-dns.sh to display all DNS zones from Nix configuration - Added scripts/update-gandi-dns.sh to sync DNS records to Gandi via API - Refactored sbr.pm, home, and vpn zone files to use helper functions - Added service-to-host mapping in globals.nix for programmatic service DNS - Added Makefile targets: dns-show, dns-update-gandi, dns-update-gandi-dry-run The DNS zones are now generated programmatically from globals.nix, establishing a single source of truth for infrastructure topology. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0ae7db2
lib/dns-helpers.nix
@@ -0,0 +1,57 @@
+{ globals }:
+{
+  # Helper to get first IP from machine config
+  # Prefers regular IPs, fallback to VPN IPs
+  getMachineIP =
+    machine:
+    let
+      ips = machine.net.ips or [ ];
+      vpnIps = machine.net.vpn.ips or [ ];
+      # Prefer regular IPs, fallback to VPN IPs
+      allIps = if ips != [ ] then ips else vpnIps;
+    in
+    if builtins.isList allIps then builtins.head allIps else allIps;
+
+  # Generate machine subdomains with wildcard support
+  # Takes a list of machine names and returns an attribute set of DNS records
+  mkMachineRecords =
+    machineList:
+    builtins.listToAttrs (
+      map (machineName: {
+        name = machineName;
+        value = {
+          A = [ (globals.machines.${machineName}.net.ips or (globals.machines.${machineName}.net.vpn.ips)) ];
+          subdomains."*".A = [
+            (globals.machines.${machineName}.net.ips or (globals.machines.${machineName}.net.vpn.ips))
+          ];
+        };
+      }) machineList
+    );
+
+  # Helper to generate service DNS records from globals
+  # Takes a services attribute set and returns DNS records with alias support
+  mkServiceRecords =
+    services:
+    builtins.listToAttrs (
+      builtins.concatMap (
+        serviceName:
+        let
+          service = services.${serviceName};
+          hostName = if builtins.isAttrs service then service.host else service;
+          hostIP = globals.machines.${hostName}.net.ips;
+          ip = if builtins.isList hostIP then builtins.head hostIP else hostIP;
+          aliases = if builtins.isAttrs service then (service.aliases or [ ]) else [ ];
+        in
+        [
+          {
+            name = serviceName;
+            value.A = [ ip ];
+          }
+        ]
+        ++ (map (alias: {
+          name = alias;
+          value.A = [ ip ];
+        }) aliases)
+      ) (builtins.attrNames services)
+    );
+}
scripts/show-dns.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+# Display DNS zone entries generated from Nix configuration
+
+set -euo pipefail
+
+# Color codes for output
+BOLD='\033[1m'
+GREEN='\033[0;32m'
+BLUE='\033[0;34m'
+YELLOW='\033[0;33m'
+CYAN='\033[0;36m'
+RESET='\033[0m'
+
+# Default host to use for zone generation (must have bind configured)
+HOST="${1:-demeter}"
+
+echo -e "${BOLD}${BLUE}Generating DNS zones for ${CYAN}${HOST}${RESET}${BLUE}...${RESET}"
+echo
+
+# Define zones
+ZONES=(
+    "sbr.pm"
+    "home"
+    "vpn"
+    "192.168.1.in-addr.arpa"
+    "10.100.0.in-addr.arpa"
+)
+
+# Process each zone
+for zone_name in "${ZONES[@]}"; do
+    echo -e "${CYAN}Evaluating zone: ${zone_name}...${RESET}"
+
+    # Get the zone file content directly
+    zone_content=$(nix eval --raw ".#nixosConfigurations.${HOST}.config.services.bind.zones.\"${zone_name}\".file" 2>&1 | grep -v "^warning:" | grep -v "^Using saved setting")
+
+    if [[ -n "$zone_content" ]] && [[ "$zone_content" != *"error"* ]]; then
+        echo -e "${BOLD}${GREEN}=== Zone: ${zone_name} ===${RESET}"
+        echo
+        echo "$zone_content"
+        echo
+    else
+        echo -e "${YELLOW}Could not generate zone ${zone_name}${RESET}"
+        echo
+    fi
+done
+
+echo -e "${BOLD}${GREEN}Done!${RESET}"
+echo
+echo -e "${CYAN}Usage: $0 [hostname]${RESET}"
+echo -e "${CYAN}Example: make dns-show  # uses demeter by default${RESET}"
scripts/update-gandi-dns.sh
@@ -0,0 +1,181 @@
+#!/usr/bin/env bash
+# Update Gandi DNS records from NixOS DNS configuration
+# Usage: ./scripts/update-gandi-dns.sh [--dry-run]
+
+set -euo pipefail
+
+# Colors
+BOLD='\033[1m'
+GREEN='\033[0;32m'
+BLUE='\033[0;34m'
+YELLOW='\033[0;33m'
+RED='\033[0;31m'
+CYAN='\033[0;36m'
+RESET='\033[0m'
+
+DOMAIN="sbr.pm"
+API_URL="https://api.gandi.net/v5/livedns/domains/$DOMAIN/records"
+DRY_RUN=false
+
+# Parse arguments
+for arg in "$@"; do
+    case $arg in
+        --dry-run)
+            DRY_RUN=true
+            shift
+            ;;
+    esac
+done
+
+# Check for API key (using lego's environment variable name)
+if [[ -z "${GANDIV5_PERSONAL_TOKEN:-}" ]]; then
+    echo -e "${RED}Error: GANDIV5_PERSONAL_TOKEN environment variable not set${RESET}"
+    echo -e "${YELLOW}Please set it with: export GANDIV5_PERSONAL_TOKEN=your-api-key${RESET}"
+    echo -e "${CYAN}Or source it from the agenix secret on rhea:${RESET}"
+    echo -e "${CYAN}  source /run/agenix/gandi.env${RESET}"
+    exit 1
+fi
+
+echo -e "${BOLD}${BLUE}Updating Gandi DNS records for $DOMAIN...${RESET}"
+if [[ "$DRY_RUN" == "true" ]]; then
+    echo -e "${YELLOW}DRY RUN MODE - No changes will be made${RESET}"
+fi
+echo
+
+# Get the DNS zone file content from Nix
+echo -e "${CYAN}Extracting DNS records from Nix configuration...${RESET}"
+ZONE_FILE=$(nix eval --raw '.#nixosConfigurations.demeter.config.services.bind.zones."sbr.pm".file' 2>&1 | \
+           grep -v "^warning:" | grep -v "^Using saved setting")
+
+if [[ -z "$ZONE_FILE" ]]; then
+    echo -e "${RED}Error: Could not generate zone file${RESET}"
+    exit 1
+fi
+
+echo -e "${GREEN}DNS records extracted${RESET}"
+echo
+
+# Create a temporary file for the zone
+TEMP_ZONE=$(mktemp)
+echo "$ZONE_FILE" > "$TEMP_ZONE"
+
+# Extract A records (excluding SOA, NS, and comments)
+# Format examples:
+#   name.domain. IN A ip
+#   name.domain. TTL IN A ip
+#   *.name.domain. IN A ip
+RECORDS=$(grep -E "^\S+\s+(([0-9]+\s+)?IN\s+)?A\s+" "$TEMP_ZONE" | \
+          grep -v "SOA" | \
+          grep -v "^;" || true)
+
+rm -f "$TEMP_ZONE"
+
+if [[ -z "$RECORDS" ]]; then
+    echo -e "${YELLOW}No A records found in zone file${RESET}"
+    exit 0
+fi
+
+echo -e "${GREEN}Found $(echo "$RECORDS" | wc -l) A records to process${RESET}"
+echo
+
+# Get current DNS records from Gandi
+if [[ "$DRY_RUN" == "false" ]]; then
+    echo -e "${CYAN}Fetching current DNS records from Gandi...${RESET}"
+    CURRENT_RECORDS=$(curl -s \
+      -H "Authorization: Apikey $GANDIV5_PERSONAL_TOKEN" \
+      "$API_URL" || echo "[]")
+
+    echo -e "${GREEN}Current records fetched${RESET}"
+    echo
+fi
+
+# Process each record
+UPDATED=0
+SKIPPED=0
+FAILED=0
+
+while IFS= read -r line; do
+    # Parse the line to extract: name, TTL, and value
+    # Handle various formats:
+    #   name.sbr.pm. IN A value
+    #   name.sbr.pm. TTL IN A value
+    #   *.name.sbr.pm. TTL IN A value
+
+    # Extract components
+    FULL_NAME=$(echo "$line" | awk '{print $1}')
+
+    # Check if second field is a number (TTL) or "IN"
+    SECOND_FIELD=$(echo "$line" | awk '{print $2}')
+    if [[ "$SECOND_FIELD" =~ ^[0-9]+$ ]]; then
+        TTL="$SECOND_FIELD"
+        VALUE=$(echo "$line" | awk '{print $5}')
+    else
+        TTL=10800
+        VALUE=$(echo "$line" | awk '{print $4}')
+    fi
+
+    # Remove .sbr.pm. suffix and convert to Gandi format
+    NAME="${FULL_NAME%.sbr.pm.}"
+
+    # Convert wildcard format: *.name -> *.name (Gandi uses this format)
+    # Convert root wildcard: * -> @ (Gandi's root wildcard)
+    if [[ "$NAME" == "*" ]]; then
+        NAME="@"
+    fi
+
+    echo -e "${BLUE}Processing: ${RESET}$NAME.$DOMAIN A $VALUE (TTL: $TTL)"
+
+    if [[ "$DRY_RUN" == "true" ]]; then
+        echo -e "  ${CYAN}[DRY RUN] Would update/create record${RESET}"
+        UPDATED=$((UPDATED + 1))
+    else
+        # Check if record exists and has same value
+        CURRENT_VALUE=$(echo "$CURRENT_RECORDS" | jq -r \
+          --arg name "$NAME" \
+          --arg type "A" \
+          '.[] | select(.rrset_name == $name and .rrset_type == $type) | .rrset_values[0]' \
+          2>/dev/null || echo "")
+
+        if [[ "$CURRENT_VALUE" == "$VALUE" ]]; then
+            echo -e "  ${GREEN}โœ“ Record unchanged, skipping${RESET}"
+            SKIPPED=$((SKIPPED + 1))
+        else
+            if [[ -z "$CURRENT_VALUE" ]]; then
+                echo -e "  ${YELLOW}Creating new record...${RESET}"
+            else
+                echo -e "  ${YELLOW}Updating record (was: $CURRENT_VALUE)...${RESET}"
+            fi
+
+            # Update/create the record
+            RESPONSE=$(curl -s -w "\n%{http_code}" \
+              -X PUT \
+              -H "Authorization: Apikey $GANDIV5_PERSONAL_TOKEN" \
+              -H "Content-Type: application/json" \
+              -d "{\"rrset_values\": [\"$VALUE\"], \"rrset_ttl\": $TTL}" \
+              "$API_URL/$NAME/A")
+
+            HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
+            BODY=$(echo "$RESPONSE" | sed '$d')
+
+            if [[ "$HTTP_CODE" == "201" ]] || [[ "$HTTP_CODE" == "200" ]]; then
+                echo -e "  ${GREEN}โœ“ Record updated successfully${RESET}"
+                UPDATED=$((UPDATED + 1))
+            else
+                echo -e "  ${RED}โœ— Failed to update record (HTTP $HTTP_CODE)${RESET}"
+                echo -e "  ${RED}Response: $BODY${RESET}"
+                FAILED=$((FAILED + 1))
+            fi
+        fi
+    fi
+
+    echo
+done <<< "$RECORDS"
+
+echo -e "${BOLD}${GREEN}DNS update complete!${RESET}"
+echo
+echo -e "${CYAN}Summary:${RESET}"
+echo -e "  Updated: $UPDATED"
+echo -e "  Skipped (unchanged): $SKIPPED"
+if [[ $FAILED -gt 0 ]]; then
+    echo -e "  ${RED}Failed: $FAILED${RESET}"
+fi
systems/common/services/dns/home.nix
@@ -1,5 +1,34 @@
-{ dns, ... }:
+{ dns, globals, ... }:
 with dns.lib.combinators;
+let
+  # Machines with home network IPs that should have wildcards
+  machinesWithWildcard = [
+    "okinawa"
+    "sakhalin"
+    "aomi"
+    "rhea"
+    "aion"
+    "shikoku"
+    "athena"
+    "demeter"
+    "nagoya"
+  ];
+
+  mkHomeMachineRecords = builtins.listToAttrs (
+    map (machineName: {
+      name = machineName;
+      value =
+        let
+          homeIP = globals.machines.${machineName}.net.ips;
+          ip = if builtins.isList homeIP then builtins.head homeIP else homeIP;
+        in
+        {
+          A = [ ip ];
+          subdomains."*".A = [ ip ];
+        };
+    }) machinesWithWildcard
+  );
+in
 {
   SOA = {
     nameServer = "ns1.home.";
@@ -18,57 +47,23 @@ with dns.lib.combinators;
 
   subdomains = {
     # Name servers
-    ns1.A = [ "192.168.1.182" ];
-    ns2.A = [ "192.168.1.183" ];
+    ns1.A = [ (builtins.head globals.machines.demeter.net.ips) ];
+    ns2.A = [ (builtins.head globals.machines.athena.net.ips) ];
 
     # Cache wildcard
-    cache.subdomains."*".A = [ "192.168.1.70" ];
+    cache.subdomains."*".A = [ (builtins.head globals.machines.sakhalin.net.ips) ];
 
-    # Machines with wildcards
-    okinawa = {
-      A = [ "192.168.1.19" ];
-      subdomains."*".A = [ "192.168.1.19" ];
-    };
-    hokkaido.A = [ "192.168.1.11" ];
-    honshu.A = [ "192.168.1.17" ];
-    kobe.A = [ "192.168.1.18" ];
-    sakhalin = {
-      A = [ "192.168.1.70" ];
-      subdomains."*".A = [ "192.168.1.70" ];
-    };
-    synodine.A = [ "192.168.1.20" ];
+    # Machines without wildcards
+    hokkaido.A = [ (builtins.head globals.machines.hokkaido.net.ips) ];
+    kobe.A = [ (builtins.head globals.machines.kobe.net.ips) ];
+    synodine.A = [ (builtins.head globals.machines.synodine.net.ips) ];
+
+    # Hardcoded entries not in globals or incomplete in globals
     wakasu = {
       A = [ "192.168.1.77" ];
       subdomains."*".A = [ "192.168.1.77" ];
     };
-    aomi = {
-      A = [ "192.168.1.23" ];
-      subdomains."*".A = [ "192.168.1.23" ];
-    };
-    rhea = {
-      A = [ "192.168.1.50" ];
-      subdomains."*".A = [ "192.168.1.50" ];
-    };
-    aion = {
-      A = [ "192.168.1.49" ];
-      subdomains."*".A = [ "192.168.1.49" ];
-    };
-    shikoku = {
-      A = [ "192.168.1.24" ];
-      subdomains."*".A = [ "192.168.1.24" ];
-    };
-    athena = {
-      A = [ "192.168.1.183" ];
-      subdomains."*".A = [ "192.168.1.183" ];
-    };
-    demeter = {
-      A = [ "192.168.1.182" ];
-      subdomains."*".A = [ "192.168.1.182" ];
-    };
-    nagoya = {
-      A = [ "192.168.1.80" ];
-      subdomains."*".A = [ "192.168.1.80" ];
-    };
+    honshu.A = [ "192.168.1.17" ];
     remakrable.A = [ "192.168.1.57" ];
     hass.A = [ "192.168.1.181" ];
 
@@ -136,5 +131,6 @@ with dns.lib.combinators;
     k8sn1.A = [ "192.168.1.130" ];
     k8sn2.A = [ "192.168.1.131" ];
     k8sn3.A = [ "192.168.1.132" ];
-  };
+  }
+  // mkHomeMachineRecords;
 }
systems/common/services/dns/sbr.pm.nix
@@ -1,5 +1,32 @@
-{ dns, ... }:
+{ dns, globals, ... }:
 with dns.lib.combinators;
+let
+  dnsHelpers = import ../../../../lib/dns-helpers.nix { inherit globals; };
+  inherit (dnsHelpers) getMachineIP mkServiceRecords;
+
+  # Only include machines that should be in sbr.pm zone
+  machineList = [
+    "shikoku"
+    "sakhalin"
+    "aix"
+    "rhea"
+    "aion"
+    "demeter"
+    "athena"
+    "nagoya"
+    "kerkouane"
+  ];
+
+  mkMachineRecords = builtins.listToAttrs (
+    map (machineName: {
+      name = machineName;
+      value = {
+        A = [ (getMachineIP globals.machines.${machineName}) ];
+        subdomains."*".A = [ (getMachineIP globals.machines.${machineName}) ];
+      };
+    }) machineList
+  );
+in
 {
   SOA = {
     nameServer = "ns1.sbr.pm.";
@@ -17,9 +44,9 @@ with dns.lib.combinators;
   ];
 
   subdomains = {
-    # Name servers
-    ns1.A = [ "192.168.1.182" ];
-    ns2.A = [ "192.168.1.183" ];
+    # Name servers (demeter and athena)
+    ns1.A = [ (getMachineIP globals.machines.demeter) ];
+    ns2.A = [ (getMachineIP globals.machines.athena) ];
 
     # Wildcard for public endpoint
     "*".A = [
@@ -28,61 +55,7 @@ with dns.lib.combinators;
         ttl = 10800;
       }
     ];
-
-    # Machines
-    wakasu = {
-      A = [ "192.168.1.77" ];
-      subdomains."*".A = [ "192.168.1.77" ];
-    };
-    shikoku = {
-      A = [ "192.168.1.24" ];
-      subdomains."*".A = [ "192.168.1.24" ];
-    };
-    sakhalin = {
-      A = [ "192.168.1.70" ];
-      subdomains."*".A = [ "192.168.1.70" ];
-    };
-    aix = {
-      A = [ "10.100.0.89" ];
-      subdomains."*".A = [ "10.100.0.89" ];
-    };
-    rhea = {
-      A = [ "192.168.1.50" ];
-      subdomains."*".A = [ "192.168.1.50" ];
-    };
-    aion = {
-      A = [ "192.168.1.49" ];
-      subdomains."*".A = [ "192.168.1.49" ];
-    };
-    demeter = {
-      A = [ "192.168.1.182" ];
-      subdomains."*".A = [ "192.168.1.182" ];
-    };
-    athena = {
-      A = [ "192.168.1.183" ];
-      subdomains."*".A = [ "192.168.1.183" ];
-    };
-    honshu = {
-      A = [ "192.168.1.15" ];
-      subdomains."*".A = [ "192.168.1.15" ];
-    };
-    nagoya = {
-      A = [ "192.168.1.80" ];
-      subdomains."*".A = [ "192.168.1.80" ];
-    };
-    kerkouane = {
-      A = [ "10.100.0.1" ];
-      subdomains."*".A = [ "10.100.0.1" ];
-    };
-
-    # Rhea media services
-    jellyfin.A = [ "192.168.1.50" ];
-    jellyseerr.A = [ "192.168.1.50" ];
-    sonarr.A = [ "192.168.1.50" ];
-    radarr.A = [ "192.168.1.50" ];
-    lidarr.A = [ "192.168.1.50" ];
-    bazarr.A = [ "192.168.1.50" ];
-    transmission.A = [ "192.168.1.50" ];
-    t.A = [ "192.168.1.50" ];
-  };
+  }
+  // mkMachineRecords
+  // mkServiceRecords globals.services;
 }
systems/common/services/dns/vpn.nix
@@ -1,5 +1,35 @@
-{ dns, ... }:
+{ dns, globals, ... }:
 with dns.lib.combinators;
+let
+  # Machines that have VPN entries
+  machineList = [
+    "okinawa"
+    "aomi"
+    "shikoku"
+    "sakhalin"
+    "rhea"
+    "aion"
+    "athena"
+    "demeter"
+    "nagoya"
+    "kyushu"
+  ];
+
+  mkVpnMachineRecords = builtins.listToAttrs (
+    map (machineName: {
+      name = machineName;
+      value =
+        let
+          vpnIP = globals.machines.${machineName}.net.vpn.ips;
+          ip = if builtins.isList vpnIP then builtins.head vpnIP else vpnIP;
+        in
+        {
+          A = [ ip ];
+          subdomains."*".A = [ ip ];
+        };
+    }) machineList
+  );
+in
 {
   SOA = {
     nameServer = "ns1.vpn.";
@@ -18,54 +48,15 @@ with dns.lib.combinators;
 
   subdomains = {
     # Name servers
-    ns1.A = [ "10.100.0.2" ];
-    ns2.A = [ "10.100.0.16" ];
+    ns1.A = [ (builtins.head globals.machines.shikoku.net.vpn.ips) ];
+    ns2.A = [ (builtins.head globals.machines.sakhalin.net.vpn.ips) ];
 
-    # Cache/Massimo wildcards
+    # Cache/Massimo wildcards - these don't exist in globals, keeping hardcoded
     cache.subdomains."*".A = [ "10.100.0.6" ];
     massimo.subdomains."*".A = [ "10.100.0.6" ];
 
-    # Machines with wildcards
-    okinawa = {
-      A = [ "10.100.0.14" ];
-      subdomains."*".A = [ "10.100.0.14" ];
-    };
-    aomi = {
-      A = [ "10.100.0.17" ];
-      subdomains."*".A = [ "10.100.0.17" ];
-    };
-    shikoku = {
-      A = [ "10.100.0.2" ];
-      subdomains."*".A = [ "10.100.0.2" ];
-    };
-    sakhalin = {
-      A = [ "10.100.0.16" ];
-      subdomains."*".A = [ "10.100.0.16" ];
-    };
-    rhea = {
-      A = [ "10.100.0.50" ];
-      subdomains."*".A = [ "10.100.0.50" ];
-    };
-    aion = {
-      A = [ "10.100.0.49" ];
-      subdomains."*".A = [ "10.100.0.49" ];
-    };
-    athena = {
-      A = [ "10.100.0.83" ];
-      subdomains."*".A = [ "10.100.0.83" ];
-    };
-    demeter = {
-      A = [ "10.100.0.82" ];
-      subdomains."*".A = [ "10.100.0.82" ];
-    };
-    nagoya = {
-      A = [ "10.100.0.80" ];
-      subdomains."*".A = [ "10.100.0.80" ];
-    };
-    kyushu = {
-      A = [ "10.100.0.19" ];
-      subdomains."*".A = [ "10.100.0.19" ];
-    };
+    # hass - hardcoded as it's not in the machine list
     hass.A = [ "10.100.0.81" ];
-  };
+  }
+  // mkVpnMachineRecords;
 }
globals.nix
@@ -534,4 +534,17 @@ _: {
       };
     };
   };
+  services = {
+    # Media services on rhea
+    jellyfin.host = "rhea";
+    jellyseerr.host = "rhea";
+    sonarr.host = "rhea";
+    radarr.host = "rhea";
+    lidarr.host = "rhea";
+    bazarr.host = "rhea";
+    transmission = {
+      host = "rhea";
+      aliases = [ "t" ];
+    };
+  };
 }
Makefile
@@ -105,6 +105,19 @@ keyboards/eyelash_corne/draw:
 keyboards:
 	@$(MAKE) -C keyboards help
 
+# DNS
+.PHONY: dns-show
+dns-show:
+	@bash scripts/show-dns.sh
+
+.PHONY: dns-update-gandi
+dns-update-gandi:
+	@bash scripts/update-gandi-dns.sh
+
+.PHONY: dns-update-gandi-dry-run
+dns-update-gandi-dry-run:
+	@bash scripts/update-gandi-dns.sh --dry-run
+
 # Maintenance
 .PHONY: clean
 clean: clean-system clean-results