provisioning-core/nulib/domain/coredns/zones.nu

550 lines
14 KiB
Text
Raw Normal View History

# 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
}
}