apu: setup dynamic dns record based on DHCP leases
This commit is contained in:
@@ -211,6 +211,13 @@
|
|||||||
|
|
||||||
services.resolved.enable = false;
|
services.resolved.enable = false;
|
||||||
|
|
||||||
|
modules.services.dhcp-dns-sync = {
|
||||||
|
enable = true;
|
||||||
|
interface = "koti";
|
||||||
|
domain = "home.arpa";
|
||||||
|
interval = "30s";
|
||||||
|
};
|
||||||
|
|
||||||
services.unbound = {
|
services.unbound = {
|
||||||
enable = true;
|
enable = true;
|
||||||
settings = {
|
settings = {
|
||||||
|
|||||||
@@ -31,5 +31,6 @@
|
|||||||
./actual.nix
|
./actual.nix
|
||||||
./voidauth.nix
|
./voidauth.nix
|
||||||
./gitea.nix
|
./gitea.nix
|
||||||
|
./dhcp-dns-sync
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
130
modules/services/dhcp-dns-sync/default.nix
Normal file
130
modules/services/dhcp-dns-sync/default.nix
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
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
|
||||||
@@ -26,6 +26,11 @@ let
|
|||||||
default = { };
|
default = { };
|
||||||
};
|
};
|
||||||
ipv6 = lib.mkEnableOption "ipv6";
|
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,
|
id,
|
||||||
ipv6,
|
ipv6,
|
||||||
staticLeases,
|
staticLeases,
|
||||||
|
domain,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
{
|
{
|
||||||
@@ -115,8 +121,17 @@ let
|
|||||||
dhcpServerConfig = {
|
dhcpServerConfig = {
|
||||||
PoolOffset = 255;
|
PoolOffset = 255;
|
||||||
DNS = "10.${toString id}.0.1";
|
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}
|
||||||
|
''
|
||||||
|
]
|
||||||
|
);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user