apu: setup dynamic dns record based on DHCP leases
Some checks failed
Build Images / build (push) Failing after 1m21s
Check / check (push) Successful in 3m36s

This commit is contained in:
Joakim Repomaa
2026-02-21 13:05:22 +02:00
parent 6690b5c1ea
commit 13e119a6c3
5 changed files with 324 additions and 1 deletions

View 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