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

@@ -211,6 +211,13 @@
services.resolved.enable = false;
modules.services.dhcp-dns-sync = {
enable = true;
interface = "koti";
domain = "home.arpa";
interval = "30s";
};
services.unbound = {
enable = true;
settings = {

View File

@@ -31,5 +31,6 @@
./actual.nix
./voidauth.nix
./gitea.nix
./dhcp-dns-sync
];
}

View File

@@ -0,0 +1,130 @@
{
lib,
config,
pkgs,
...
}:
let
cfg = config.modules.services.dhcp-dns-sync;
dhcp-leases-to-unbound =
pkgs.runCommand "dhcp-leases-to-unbound"
{
code = ./dhcp-leases-to-unbound.cr;
nativeBuildInputs = [ pkgs.crystal ];
meta.mainProgram = "dhcp-leases-to-unbound";
}
''
mkdir -p $out/bin
crystal build $code --release -o $out/bin/dhcp-leases-to-unbound
'';
in
{
options.modules.services.dhcp-dns-sync = {
enable = lib.mkEnableOption "Enable DHCP to DNS synchronization";
interface = lib.mkOption {
type = lib.types.str;
default = "koti";
description = "Network interface to monitor for DHCP leases";
};
domain = lib.mkOption {
type = lib.types.str;
default = "home.arpa";
description = "Domain suffix for DHCP hostnames";
};
unboundConfigPath = lib.mkOption {
type = lib.types.str;
default = "/var/lib/unbound/dhcp-hosts.conf";
description = "Path to write Unbound include file";
};
leasesJsonPath = lib.mkOption {
type = lib.types.str;
default = "/var/lib/router/leases.json";
description = "Path to write leases JSON file";
};
interval = lib.mkOption {
type = lib.types.str;
default = "30s";
description = "Interval for checking DHCP lease updates";
};
};
config = lib.mkIf cfg.enable {
# Create user and group for the service
users.users.dhcp-dns-sync = {
isSystemUser = true;
group = "dhcp-dns-sync";
description = "DHCP DNS sync service user";
};
users.groups.dhcp-dns-sync = { };
# Ensure directories and files exist with proper permissions
systemd.tmpfiles.rules = [
"d /var/lib/unbound 0755 unbound unbound -"
"d /var/lib/router 0755 dhcp-dns-sync dhcp-dns-sync -"
"f ${cfg.unboundConfigPath} 0644 dhcp-dns-sync dhcp-dns-sync -"
];
# Extend Unbound configuration to include generated file
services.unbound.settings = {
server = {
local-zone = [ "${cfg.domain}. static" ];
include = cfg.unboundConfigPath;
};
};
# Make sure Unbound control is enabled
services.unbound.settings.remote-control.control-enable = true;
# Systemd service
systemd.services.dhcp-dns-sync = {
description = "Sync DHCP leases to Unbound DNS";
after = [
"systemd-networkd.service"
"unbound.service"
];
requires = [ "unbound.service" ];
wants = [ "unbound-control.socket" ];
serviceConfig = {
Type = "oneshot";
User = "dhcp-dns-sync";
Group = "dhcp-dns-sync";
# Allow access to networkctl via D-Bus
SupplementaryGroups = [ "systemd-network" ];
# Read/write paths
ReadWritePaths = [
"/var/lib/unbound"
"/var/lib/router"
];
# Execute paths
ExecPaths = [ "/run/current-system/sw/bin" ];
};
script = ''
${lib.getExe dhcp-leases-to-unbound} \
-i ${cfg.interface} \
-d ${cfg.domain} \
-o ${cfg.unboundConfigPath} \
--leases-json ${cfg.leasesJsonPath} \
--networkctl ${lib.getExe' pkgs.systemd "networkctl"}
'';
};
# Systemd timer
systemd.timers.dhcp-dns-sync = {
description = "Periodic DHCP to DNS sync";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "10s";
OnUnitActiveSec = cfg.interval;
};
};
};
}

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

View File

@@ -26,6 +26,11 @@ let
default = { };
};
ipv6 = lib.mkEnableOption "ipv6";
domain = lib.mkOption {
type = lib.types.str;
default = "home.arpa";
description = "Domain name to advertise via DHCP";
};
};
};
@@ -96,6 +101,7 @@ let
id,
ipv6,
staticLeases,
domain,
...
}:
{
@@ -115,8 +121,17 @@ let
dhcpServerConfig = {
PoolOffset = 255;
DNS = "10.${toString id}.0.1";
EmitDNS = true;
};
extraConfig = lib.concatLines (lib.mapAttrsToList buildStaticLease staticLeases);
extraConfig = lib.concatLines (
(lib.mapAttrsToList buildStaticLease staticLeases)
++ [
''
[DHCPServer]
SendOption=15:string:${domain}
''
]
);
};
};