# CoreDNS Zone File Management # Create, update, and manage DNS zone files # ../utils/log.nu does not exist — dangling import removed (ADR-025 L2). use domain/coredns/corefile.nu [generate-zone-file] # Create zone file with SOA and NS records export def create-zone-file [ zone_name: string # Zone name (e.g., "provisioning.local") zones_path: string # Path to zones directory --config: record = {} # Optional configuration ] -> bool { log info $"Creating zone file for ($zone_name)" let expanded_path = $zones_path | path expand let zone_file = $"($expanded_path)/($zone_name).zone" # Create zones directory if not exists if not ($expanded_path | path exists) { mkdir $expanded_path } # Generate initial zone file with SOA and NS records let zone_content = generate-zone-file $zone_name [] $config let result = (do { $zone_content | save -f $zone_file true } | complete) if $result.exit_code == 0 { log info $"Zone file created: [$zone_file]" true } else { log error $"Failed to create zone file: [$zone_file]" false } } # Add A record to zone export def add-a-record [ zone_name: string # Zone name hostname: string # Hostname (without domain) ip_address: string # IPv4 address --zones-path: string = "~/.provisioning/coredns/zones" --ttl: int = 300 --comment: string = "" ] -> bool { log info $"Adding A record: ($hostname) -> ($ip_address)" let record = { name: $hostname type: "A" value: $ip_address ttl: $ttl comment: $comment } add-record $zone_name $record --zones-path $zones_path } # Add AAAA record to zone export def add-aaaa-record [ zone_name: string # Zone name hostname: string # Hostname ipv6_address: string # IPv6 address --zones-path: string = "~/.provisioning/coredns/zones" --ttl: int = 300 --comment: string = "" ] -> bool { log info $"Adding AAAA record: ($hostname) -> ($ipv6_address)" let record = { name: $hostname type: "AAAA" value: $ipv6_address ttl: $ttl comment: $comment } add-record $zone_name $record --zones-path $zones_path } # Add CNAME record to zone export def add-cname-record [ zone_name: string # Zone name alias: string # Alias name target: string # Target hostname --zones-path: string = "~/.provisioning/coredns/zones" --ttl: int = 300 --comment: string = "" ] -> bool { log info $"Adding CNAME record: ($alias) -> ($target)" let record = { name: $alias type: "CNAME" value: $target ttl: $ttl comment: $comment } add-record $zone_name $record --zones-path $zones_path } # Add MX record to zone export def add-mx-record [ zone_name: string # Zone name hostname: string # Hostname (@ for zone apex) mail_server: string # Mail server hostname priority: int # Priority (lower is higher priority) --zones-path: string = "~/.provisioning/coredns/zones" --ttl: int = 300 --comment: string = "" ] -> bool { log info $"Adding MX record: ($hostname) -> ($mail_server) (priority: ($priority))" let record = { name: $hostname type: "MX" value: $mail_server priority: $priority ttl: $ttl comment: $comment } add-record $zone_name $record --zones-path $zones_path } # Add TXT record to zone export def add-txt-record [ zone_name: string # Zone name hostname: string # Hostname text: string # Text value --zones-path: string = "~/.provisioning/coredns/zones" --ttl: int = 300 --comment: string = "" ] -> bool { log info $"Adding TXT record: ($hostname) -> ($text)" let record = { name: $hostname type: "TXT" value: $"\"($text)\"" ttl: $ttl comment: $comment } add-record $zone_name $record --zones-path $zones_path } # Generic add record function def add-record [ zone_name: string record: record --zones-path: string ] -> bool { let expanded_path = $zones_path | path expand let zone_file = $"($expanded_path)/($zone_name).zone" if not ($zone_file | path exists) { log error $"Zone file not found: ($zone_file)" return false } # Read existing zone file let content = open $zone_file # Check if record already exists let record_name = $record.name let record_pattern = $"^($record_name)\\s+" if ($content | str contains -r $record_pattern) { log warn $"Record ($record_name) already exists, updating..." remove-record $zone_name $record_name --zones-path $zones_path } # Generate record line let record_line = generate-record-line $record # Find insertion point (after NS records, before other records) let lines = $content | lines mut updated_lines = [] mut inserted = false for line in $lines { $updated_lines = ($updated_lines | append $line) # Insert after NS records section if (not $inserted) and ($line | str contains "IN NS") { $updated_lines = ($updated_lines | append "") $updated_lines = ($updated_lines | append $record_line) $inserted = true } } # If no NS record found, append at end if not $inserted { $updated_lines = ($updated_lines | append "") $updated_lines = ($updated_lines | append $record_line) } # Increment serial number $updated_lines = (increment-zone-serial $updated_lines) # Write updated zone file let result = (do { $updated_lines | str join "\n" | save -f $zone_file true } | complete) if $result.exit_code == 0 { log info $"Record added to [$zone_file]" true } else { log error $"Failed to update zone file: [$zone_file]" false } } # Remove record from zone export def remove-record [ zone_name: string # Zone name hostname: string # Hostname to remove --zones-path: string = "~/.provisioning/coredns/zones" ] -> bool { log info $"Removing record: ($hostname)" let expanded_path = $zones_path | path expand let zone_file = $"($expanded_path)/($zone_name).zone" if not ($zone_file | path exists) { log error $"Zone file not found: ($zone_file)" return false } let content = open $zone_file let lines = $content | lines # Filter out lines matching hostname let record_pattern = $"^($hostname)\\s+" let filtered = $lines | where {|line| not ($line | str contains -r $record_pattern)} # Increment serial number let updated = increment-zone-serial $filtered let result = (do { $updated | str join "\n" | save -f $zone_file true } | complete) if $result.exit_code == 0 { log info $"Record removed from [$zone_file]" true } else { log error $"Failed to update zone file: [$zone_file]" false } } # List all records in zone export def list-zone-records [ zone_name: string # Zone name --zones-path: string = "~/.provisioning/coredns/zones" --format: string = "table" # Output format: table, json, yaml ] -> any { let expanded_path = $zones_path | path expand let zone_file = $"($expanded_path)/($zone_name).zone" if not ($zone_file | path exists) { log error $"Zone file not found: ($zone_file)" return [] } let content = open $zone_file let lines = $content | lines # Parse DNS records mut records = [] for line in $lines { # Skip comments, empty lines, and directives if ($line | str starts-with ";") or ($line | str starts-with "$") or ($line | str trim | is-empty) { continue } # Skip SOA record (multi-line) if ($line | str contains "SOA") { continue } # Parse record line let parsed = parse-record-line $line if ($parsed | is-not-empty) { $records = ($records | append $parsed) } } # Format output match $format { "json" => { $records | to json } "yaml" => { $records | to yaml } _ => { $records } } } # Parse DNS record line def parse-record-line [ line: string ] -> record { # Split line into parts let parts = $line | str trim | split row -r '\s+' | where {|x| $x != ""} if ($parts | length) < 4 { return {} } let name = $parts | get 0 let class_or_ttl = $parts | get 1 let type_or_class = $parts | get 2 let type_or_value = $parts | get 3 # Determine if TTL is present mut ttl = null mut record_type = "" mut value = "" if $class_or_ttl == "IN" { # No TTL: name IN type value $record_type = $type_or_class $value = $type_or_value } else { # With TTL: name ttl IN type value $ttl = $class_or_ttl | into int $record_type = $type_or_value if ($parts | length) >= 5 { $value = $parts | get 4 } } { name: $name type: $record_type value: $value ttl: $ttl } } # Generate DNS record line (duplicated from corefile.nu for independence) def generate-record-line [ record: record ] -> string { let name = $record.name let type = $record.type let value = $record.value let ttl = $record.ttl? | default "" let priority = $record.priority? | default "" let comment = $record.comment? | default "" mut parts = [] # Name (padded to 16 chars) $parts = ($parts | append ($name | fill -a left -w 16)) # TTL (optional) if ($ttl != "") { $parts = ($parts | append ($ttl | into string | fill -a left -w 8)) } else { $parts = ($parts | append ("" | fill -a left -w 8)) } # Class (always IN) $parts = ($parts | append "IN") # Type $parts = ($parts | append ($type | fill -a left -w 8)) # Priority (for MX and SRV) if ($priority != "") { $parts = ($parts | append ($priority | into string)) } # Value $parts = ($parts | append $value) let record_line = $parts | str join " " # Add comment if present if ($comment != "") { $"($record_line) ; ($comment)" } else { $record_line } } # Increment zone serial number def increment-zone-serial [ lines: list ] -> list { mut updated = [] mut serial_incremented = false for line in $lines { if (not $serial_incremented) and ($line | str contains "; Serial") { # Extract current serial let serial_match = $line | parse -r '(\d+)\s*; Serial' if ($serial_match | is-not-empty) { let current_serial = $serial_match | get 0.capture0 | into int let new_serial = $current_serial + 1 let updated_line = $line | str replace $"($current_serial)" $"($new_serial)" $updated = ($updated | append $updated_line) $serial_incremented = true continue } } $updated = ($updated | append $line) } $updated } # Reload zone file (signal CoreDNS to reload) export def reload-zone [ zone_name: string # Zone name ] -> bool { log info $"Reloading zone: ($zone_name)" # Send SIGUSR1 to CoreDNS to reload let pid = get-coredns-pid if $pid == null { log warn "CoreDNS not running, no reload needed" return false } let result = (do { kill -s USR1 $pid true } | complete) if $result.exit_code == 0 { log info "CoreDNS reload signal sent" true } else { log error "Failed to reload CoreDNS" false } } # Get CoreDNS PID def get-coredns-pid [] -> int { let pid_file = "~/.provisioning/coredns/coredns.pid" | path expand if ($pid_file | path exists) { open $pid_file | into int } else { # Try to find via ps let ps_result = ps | where name =~ "coredns" if ($ps_result | is-not-empty) { $ps_result | get 0.pid } else { null } } } # Validate zone file syntax export def validate-zone-file [ zone_name: string # Zone name --zones-path: string = "~/.provisioning/coredns/zones" ] -> record { log debug $"Validating zone file for ($zone_name)" let expanded_path = $zones_path | path expand let zone_file = $"($expanded_path)/($zone_name).zone" if not ($zone_file | path exists) { return { valid: false errors: ["Zone file not found"] warnings: [] } } mut errors = [] mut warnings = [] let content = open $zone_file let lines = $content | lines # Check for required records let has_soa = $lines | any {|line| $line | str contains "SOA"} let has_ns = $lines | any {|line| $line | str contains "IN NS"} if not $has_soa { $errors = ($errors | append "Missing SOA record") } if not $has_ns { $errors = ($errors | append "Missing NS record") } # Check for $ORIGIN directive let has_origin = $lines | any {|line| $line | str starts-with "$ORIGIN"} if not $has_origin { $warnings = ($warnings | append "Missing $ORIGIN directive") } { valid: ($errors | is-empty) errors: $errors warnings: $warnings } } # Backup zone file export def backup-zone-file [ zone_name: string # Zone name --zones-path: string = "~/.provisioning/coredns/zones" ] -> bool { let expanded_path = $zones_path | path expand let zone_file = $"($expanded_path)/($zone_name).zone" if not ($zone_file | path exists) { log error $"Zone file not found: ($zone_file)" return false } let timestamp = date now | format date "%Y%m%d-%H%M%S" let backup_file = $"($zone_file).($timestamp).bak" let result = (do { cp $zone_file $backup_file true } | complete) if $result.exit_code == 0 { log info $"Zone file backed up to [$backup_file]" true } else { log error "Failed to backup zone file" false } }