apu: setup dynamic dns record based on DHCP leases
This commit is contained in:
170
modules/services/dhcp-dns-sync/dhcp-leases-to-unbound.cr
Normal file
170
modules/services/dhcp-dns-sync/dhcp-leases-to-unbound.cr
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env crystal
|
||||
|
||||
require "json"
|
||||
require "file_utils"
|
||||
require "option_parser"
|
||||
|
||||
struct Lease
|
||||
include JSON::Serializable
|
||||
|
||||
@[JSON::Field(key: "Address")]
|
||||
property address : String
|
||||
|
||||
@[JSON::Field(key: "MACAddress")]
|
||||
property mac_address : String?
|
||||
|
||||
@[JSON::Field(key: "Hostname")]
|
||||
property hostname : String?
|
||||
|
||||
@[JSON::Field(key: "Lifetime")]
|
||||
property lifetime : Int64?
|
||||
end
|
||||
|
||||
struct NetworkStatus
|
||||
include JSON::Serializable
|
||||
|
||||
@[JSON::Field(key: "OfferedDHCPLeases")]
|
||||
property offered_dhcp_leases : Array(Lease)?
|
||||
end
|
||||
|
||||
def sanitize_hostname(hostname : String) : String?
|
||||
# Lowercase and strip invalid characters
|
||||
sanitized = hostname.downcase.gsub(/[^a-z0-9-]/, "-")
|
||||
|
||||
# Collapse multiple dashes into one
|
||||
sanitized = sanitized.gsub(/-+/, "-")
|
||||
|
||||
# Strip leading/trailing dashes
|
||||
sanitized = sanitized.strip('-')
|
||||
|
||||
# Ensure non-empty and not too long (max 63 chars for DNS label)
|
||||
return nil if sanitized.empty?
|
||||
return nil if sanitized.size > 63
|
||||
|
||||
# Ensure starts with letter or number (not dash)
|
||||
return nil if sanitized.starts_with?('-')
|
||||
|
||||
sanitized
|
||||
end
|
||||
|
||||
def reverse_ptr(ip : String) : String?
|
||||
parts = ip.split('.')
|
||||
return nil unless parts.size == 4
|
||||
|
||||
"#{parts[3]}.#{parts[2]}.#{parts[1]}.#{parts[0]}.in-addr.arpa."
|
||||
end
|
||||
|
||||
def generate_unbound_config(leases : Array(Lease), domain : String) : String
|
||||
lines = [] of String
|
||||
|
||||
lines << "# Auto-generated DHCP leases for #{domain}"
|
||||
lines << "# Generated at #{Time.utc}"
|
||||
lines << ""
|
||||
|
||||
leases.each do |lease|
|
||||
next unless hostname = lease.hostname
|
||||
sanitized = sanitize_hostname(hostname)
|
||||
next unless sanitized
|
||||
|
||||
fqdn = "#{sanitized}.#{domain}."
|
||||
|
||||
# A record
|
||||
lines << %{local-data: "#{fqdn} IN A #{lease.address}"}
|
||||
|
||||
# PTR record
|
||||
if ptr = reverse_ptr(lease.address)
|
||||
lines << %{local-data-ptr: "#{ptr} #{fqdn}"}
|
||||
end
|
||||
end
|
||||
|
||||
lines.join("\n") + "\n"
|
||||
end
|
||||
|
||||
def get_leases(interface : String, networkctl_path : String? = nil) : Array(Lease)
|
||||
cmd = networkctl_path ? "#{networkctl_path} status #{interface} --json=short" : "networkctl status #{interface} --json=short"
|
||||
output = `#{cmd}`
|
||||
raise "networkctl failed (exit code #{$?.exit_status}): #{output}" unless $?.success?
|
||||
|
||||
status = NetworkStatus.from_json(output)
|
||||
status.offered_dhcp_leases || [] of Lease
|
||||
end
|
||||
|
||||
def write_if_changed(content : String, path : String) : Bool
|
||||
# Check if content is the same
|
||||
if File.exists?(path)
|
||||
current = File.read(path)
|
||||
return false if current == content
|
||||
end
|
||||
|
||||
# Write to temp file and atomically move
|
||||
temp_path = "#{path}.tmp"
|
||||
File.write(temp_path, content)
|
||||
FileUtils.mv(temp_path, path)
|
||||
true
|
||||
end
|
||||
|
||||
# Configuration
|
||||
interface = "koti"
|
||||
domain = "home.arpa"
|
||||
output_path = "/var/lib/unbound/dhcp-hosts.conf"
|
||||
leases_json_path = "/var/lib/router/leases.json"
|
||||
networkctl_path : String? = nil
|
||||
|
||||
OptionParser.parse do |parser|
|
||||
parser.banner = "Usage: dhcp-leases-to-unbound [options]"
|
||||
|
||||
parser.on("-i INTERFACE", "Network interface to monitor (default: koti)") do |i|
|
||||
interface = i
|
||||
end
|
||||
|
||||
parser.on("-d DOMAIN", "Domain suffix (default: home.arpa)") do |d|
|
||||
domain = d
|
||||
end
|
||||
|
||||
parser.on("-o PATH", "Output path for unbound config (default: /var/lib/unbound/dhcp-hosts.conf)") do |o|
|
||||
output_path = o
|
||||
end
|
||||
|
||||
parser.on("--leases-json PATH", "Output path for leases JSON (default: /var/lib/router/leases.json)") do |path|
|
||||
leases_json_path = path
|
||||
end
|
||||
|
||||
parser.on("--networkctl PATH", "Path to networkctl binary (default: networkctl from PATH)") do |path|
|
||||
networkctl_path = path
|
||||
end
|
||||
|
||||
parser.on("-h", "--help", "Show this help") do
|
||||
puts parser
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
# Get leases from networkd
|
||||
leases = get_leases(interface, networkctl_path)
|
||||
|
||||
# Generate Unbound config
|
||||
config = generate_unbound_config(leases, domain)
|
||||
|
||||
# Write Unbound config if changed
|
||||
changed = write_if_changed(config, output_path)
|
||||
|
||||
# Also write leases JSON for dashboard
|
||||
FileUtils.mkdir_p(File.dirname(leases_json_path))
|
||||
File.write(leases_json_path, leases.to_json)
|
||||
|
||||
# Reload Unbound if config changed
|
||||
if changed
|
||||
puts "DHCP leases updated, reloading Unbound..."
|
||||
result = system("unbound-control reload")
|
||||
unless result
|
||||
# Fallback to systemctl if unbound-control fails
|
||||
system("systemctl reload unbound")
|
||||
end
|
||||
else
|
||||
puts "No DHCP lease changes detected."
|
||||
end
|
||||
rescue ex : Exception
|
||||
STDERR.puts "Error: #{ex.message}"
|
||||
exit 1
|
||||
end
|
||||
Reference in New Issue
Block a user