Files
nilsandClaude Sonnet 5.5 2eab735f59 Publish DHCP clients' names in DNS
router-webui leases-dns reads Kea's leases and publishes <name>.<domain>
with reverse records in the running Unbound (router.dns.dynamicLeases).
Names the configuration publishes are never taken over; a client's name
is cut to its first label and kept inside the local domain; of two
clients the newer lease wins. The DNS page switches it on and shows what
is published and what was skipped; router-setup enables it with a domain.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 00:18:01 +01:00

131 lines
5.3 KiB
Nix

{ config, lib, ... }:
let
cfg = config.router;
lans = lib.attrValues cfg.lans;
ipv4 = (import ./lib.nix { inherit lib; }).ipv4;
# Tunnels that opted into DNS: listen on each of their addresses.
dnsAddrs = lib.concatMap (w: w.addresses) (lib.filter (w: w.dns) (lib.attrValues cfg.wireguard));
addrOf = a: builtins.head (lib.splitString "/" a);
# IPv4 gets the proper network address; IPv6 netblocks are masked by Unbound.
netOf =
a:
let
parts = lib.splitString "/" a;
in
if lib.hasInfix ":" a then
a
else
ipv4.cidr (builtins.head parts) (lib.toInt (builtins.elemAt parts 1));
dns = cfg.dns;
inherit (dns) domain;
fqdn = name: if lib.hasInfix "." name || domain == null then name else "${name}.${domain}";
isV6 = lib.hasInfix ":";
rrOf = a: if isV6 a then "AAAA" else "A";
# name -> addresses: explicit hosts plus DHCP reservations (A records only).
reservations = lib.concatMap (
l: lib.mapAttrsToList (n: r: lib.nameValuePair n [ r.address ]) l.dhcp.reservations
) lans;
# The router's own name resolves to its addresses on the LANs (and their
# ULAs), so clients can reach it by name; with a domain also bare, for
# clients that have no search domain.
selfName = config.networking.hostName;
selfAddrs =
map (l: l.ipv4.address) lans
++ map (l: addrOf l.ipv6.address) (lib.filter (l: l.ipv6.address != null) lans);
# Unless dns.hosts names the router itself.
selfDefined = lib.hasAttr selfName dns.hosts || lib.hasAttr (fqdn selfName) dns.hosts;
selfRecord = lib.optional (!selfDefined) (lib.nameValuePair (fqdn selfName) selfAddrs);
# (No reverse record for the bare name: the address has one already.)
selfBare = lib.optional (!selfDefined && fqdn selfName != selfName) (
lib.nameValuePair selfName selfAddrs
);
hostRecords = lib.mapAttrsToList (n: a: lib.nameValuePair (fqdn n) a) dns.hosts;
reservationRecords = map (r: lib.nameValuePair (fqdn r.name) r.value) reservations;
records =
selfRecord
++ hostRecords
++ reservationRecords
++ lib.mapAttrsToList (n: a: lib.nameValuePair n a) dns.overrides;
# What the configuration publishes itself: dynamic names (the clients' host
# names, see dns.dynamicLeases) must never take one of these over.
staticNames = builtins.toJSON {
inherit domain;
names = map (r: r.name) (selfRecord ++ hostRecords ++ reservationRecords) ++ map (r: r.name) selfBare;
overrides = lib.attrNames dns.overrides;
forwardZones = lib.attrNames dns.forwardZones;
};
# Unbound reads these as one quoted argument.
localData = lib.concatMap (r: map (a: ''"${r.name}. ${rrOf a} ${a}"'') r.value) (records ++ selfBare);
# Reverse records for hosts and reservations (not for wildcard overrides).
ptrs = lib.concatMap (r: map (a: ''"${a} ${r.name}."'') r.value) (
lib.filter (r: !(lib.hasAttr r.name dns.overrides)) records
);
privateZones = lib.attrNames dns.forwardZones ++ lib.optional (domain != null) domain;
# Peers of tunnels that use the resolver may be remote networks with no
# address of their own on the tunnel (site-to-site); allow what they route.
dnsPeerNets = lib.filter (n: !lib.hasSuffix "/0" n) (
lib.concatMap (w: lib.concatMap (p: p.allowedIPs) (lib.attrValues w.peers)) (
lib.filter (w: w.dns) (lib.attrValues cfg.wireguard)
)
);
in
{
config = lib.mkIf cfg.enable {
environment.etc."router/dns-static.json".text = staticNames;
warnings = lib.optional (dns.dynamicLeases && domain == null) ''
router.dns.dynamicLeases has no effect without router.dns.domain: the clients' names need a domain to live in.
'';
services.unbound = {
enable = true;
# What router-webui leases-dns publishes the clients' names through.
localControlSocketPath = lib.mkIf dns.dynamicLeases "/run/unbound/unbound.ctl";
settings = {
server = {
interface = [
"127.0.0.1"
]
++ map (l: l.ipv4.address) lans
++ map (l: addrOf l.ipv6.address) (lib.filter (l: l.ipv6.address != null) lans)
++ map addrOf dnsAddrs;
# LAN addresses are assigned by networkd, possibly after Unbound starts.
ip-freebind = true;
access-control = [
"127.0.0.0/8 allow"
]
++ map (l: "${l.ipv4.subnet} allow") lans
++ map (l: "${l.ipv6.address} allow") (lib.filter (l: l.ipv6.address != null) lans)
++ map (a: "${netOf a} allow") dnsAddrs
++ map (n: "${n} allow") dnsPeerNets;
local-zone =
lib.optional (domain != null) "${domain}. static"
# The router's bare name is a zone of its own (not under the domain).
++ lib.optional (!lib.hasInfix "." selfName) "${selfName}. transparent"
++ map (n: "${n}. redirect") (lib.attrNames dns.overrides);
local-data = localData;
local-data-ptr = ptrs;
domain-insecure = privateZones ++ lib.attrNames dns.overrides;
};
}
// lib.optionalAttrs (cfg.dns.forwarders != [ ] || dns.forwardZones != { }) {
forward-zone =
lib.mapAttrsToList (name: addrs: {
inherit name;
forward-addr = addrs;
}) dns.forwardZones
++ lib.optional (cfg.dns.forwarders != [ ]) {
name = ".";
forward-addr = cfg.dns.forwarders;
};
};
};
};
}