550 lines
14 KiB
Text
550 lines
14 KiB
Text
|
|
# 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<string>
|
||
|
|
] -> list<string> {
|
||
|
|
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
|
||
|
|
}
|
||
|
|
}
|