diff --git a/hosts/apu/configuration.nix b/hosts/apu/configuration.nix index e932de4..9d37d42 100644 --- a/hosts/apu/configuration.nix +++ b/hosts/apu/configuration.nix @@ -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 = { diff --git a/modules/services/default.nix b/modules/services/default.nix index 35d3074..b352a0a 100644 --- a/modules/services/default.nix +++ b/modules/services/default.nix @@ -31,5 +31,6 @@ ./actual.nix ./voidauth.nix ./gitea.nix + ./dhcp-dns-sync ]; } diff --git a/modules/services/dhcp-dns-sync/default.nix b/modules/services/dhcp-dns-sync/default.nix new file mode 100644 index 0000000..4e6bd5a --- /dev/null +++ b/modules/services/dhcp-dns-sync/default.nix @@ -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; + }; + }; + }; +} diff --git a/modules/services/dhcp-dns-sync/dhcp-leases-to-unbound.cr b/modules/services/dhcp-dns-sync/dhcp-leases-to-unbound.cr new file mode 100644 index 0000000..6101e7a --- /dev/null +++ b/modules/services/dhcp-dns-sync/dhcp-leases-to-unbound.cr @@ -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 diff --git a/modules/vlans.nix b/modules/vlans.nix index 2d51a1f..176d760 100644 --- a/modules/vlans.nix +++ b/modules/vlans.nix @@ -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} + '' + ] + ); }; };