DHCP and DNS¶
Design draft. Everything here is configured from the UI and lives in the config model (and so in git), like the rest of Stronghold.
Components¶
| Role | Software | Why |
|---|---|---|
| DHCPv4 and DHCPv6 server | Kea (kea-dhcp4, kea-dhcp6) |
Already chosen; HA hook in Fedora's kea-hooks. |
| DHCP → DNS updates | Kea DHCP-DDNS (kea-dhcp-ddns) |
Sends standard DNS updates (RFC 2136), signed with TSIG, for forward and reverse records. |
| Authoritative DNS (local zones) | Knot DNS | Accepts dynamic updates, supports every record type, signs zones with DNSSEC automatically, and can be changed at runtime in transactions. |
| Resolver for clients | Unbound | Recursion or forwarding (also over TLS), DNSSEC validation, blocklists (RPZ). Sends queries for local zones to Knot. |
| IPv6 router advertisements | NetworkManager (shared IPv6); radvd on DHCPv6 networks | NetworkManager also takes the delegated prefix's /64 for the network; prefixes, DNS server and search domain are advertised (docs/interfaces.md, "IPv6"). It can't set the flags that send clients to DHCPv6, so radvd advertises those networks. |
All of these are packaged in Fedora 44.
Unbound can't take dynamic updates, and dnsmasq (DHCP and DNS in one) only handles a few record types and has weak DHCPv6 support. Splitting the roles between Unbound (resolver) and Knot (authoritative) gives full record support and proper DHCP → DNS updates.
clients ──DHCP──> Kea ──DDNS (TSIG)──> Knot DNS (local zones)
│ ▲
└──────DNS──> Unbound ──local zones─────┘
└──────everything else──> internet
DHCP¶
Configured per network (from interfaces.md), IPv4 and IPv6 alike.
- Dynamic pools: one or more address ranges, lease time.
- Authoritative: the box is the DHCP server of the networks it serves: a client asking for an address Kea doesn't know (from another network or server) gets a NAK at once and asks again, instead of waiting out its own timeout.
- Static reservations: MAC (or DHCP client ID, or DUID for IPv6) → address and hostname, with optional options of their own.
- Options: pick from a list of known options with typed fields (router, DNS servers, domain, search list, NTP, MTU, ...), or add any option by code and type. Options can be set per network, per pool or per reservation, and removed at each level.
- Lease view: active leases with hostname, MAC, vendor, expiry. Actions: "Make static" (turns a lease into a reservation with the same address and name) and release.
- IPv6: DHCPv6 pools and reservations, and per network the router advertisement mode: SLAAC only, SLAAC + DHCPv6 for options, or DHCPv6 for addresses. Networks can take their prefix from the WAN's delegated prefix.
- HA: Kea's HA hook keeps leases in sync between the nodes.
- Leases are runtime state, not configuration: they stay out of git. Reservations are configuration and go in git.
DNS¶
Resolver¶
- Per zone: whether clients there may use the box as resolver (the firewall Gateway cell decides the rest).
- Upstream: full recursion, or forward to chosen servers, optionally over TLS.
- DNSSEC validation on by default.
- Domain overrides: send queries for a domain to a given server (e.g.
corp.example→10.0.0.53over the VPN). - Blocklists (RPZ,
internal/blocklist): lists downloaded on a schedule, names allowed past them, what they blocked counted. - Or DHCP elsewhere (
functions.dhcp:where: elsewhere, itsservers, thenetworksthe box relays to them): Kea doesn't run, a DHCP relay does (functions.md, "Where"). - Or a resolver elsewhere (stronghold.yaml's
functions.dns-resolver:where: elsewhere, itsservers): clients are given those servers, Unbound doesn't run, and these settings are kept for when it is here again (functions.md, "Where").
Local zones and records¶
- Create zones (e.g.
home.example), each optionally DNSSEC-signed. - All record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, PTR, NS, SSHFP, TLSA, HTTPS/SVCB, and others as generic records.
- Reverse zones are created automatically for each network's subnets (IPv4 and IPv6).
- Record view: one list per zone. Records from config are editable; records created from DHCP leases carry a "DHCP" badge, link to the lease, and are read-only.
- IPv6 records with a changing prefix: when the ISP's delegated prefix
changes, a static AAAA record can be given as an interface ID only (e.g.
::10); Stronghold combines it with the network's current prefix and updates the record and its PTR when the prefix changes.
Acting on leases and records¶
- Lease (DHCP tab, click it): Make static (a reservation, with the address and name editable first), Block device, Delete lease.
- Blocked devices are per network (
dhcp.blocked: [{mac, name}]): Kea puts them in itsDROPclass on that network's interface, so they get no lease, and the firewall drops their traffic through the gateway (rich rules on the source MAC, shown as system rules). Traffic between devices on the same network never reaches the gateway and isn't affected. Their current lease is deleted when the block is applied. - Deleting a lease goes through Kea's control socket (
lease_cmdshook) withupdate-ddns, so its DNS names go too. Leases are runtime state: this takes effect at once, without the confirm window. - Record (DNS tab, "Served now", click it): names from DHCP can be deleted now (with their DHCID, so DHCP can register the name again) or made static; records from the config are deleted from the draft; any name can be blocked.
- Blocked names (
resolver.blocked) don't resolve for anyone, nor anything under them (Unboundalways_nxdomain).
Views (split DNS)¶
A view is a set of network zones whose clients get the same answers. It answers "which DNS zones exist in which network zones", and lets a name have different records for different clients (split horizon).
views:
- id: guest # guests see only public names
network-zones: [guest]
- id: public # answer the internet (WAN zones only)
network-zones: [wan]
zones:
- name: example.com
views: [internal, public] # default: [internal]
records:
- { name: "@", type: NS, value: ns1 }
- { name: www, type: A, value: 10.0.1.5, views: [internal] }
- { name: www, type: A, value: 203.0.113.5, views: [public] }
internalis built in: every internal network zone that no other view lists is in it. DHCP names are registered in it.- Other internal views (guest, IoT, ...) use the resolver like
internal, but see only their own zones and records. - A public view (WAN zones only; at most one) answers the internet for
its zones and nothing else: no recursion, no internal names. It is off
unless a view lists a WAN zone. A zone in it needs an NS record at
@. - A record without
viewsis in all of its zone's views.
How it is built, given what the components can do:
- internal: Unbound, with Knot (
127.0.0.2) for the zones, including names from DHCP (as before). - Other internal views: Unbound views (
access-control-viewper network subnet). An Unbound view can't send queries to a different authoritative server, so it sees Knot's data and Unbound answers the differences itself: names whose records differ, names it must not see (NXDOMAIN), and zones Knot doesn't have. A zone ininternalthat a view leaves out can't be resolved from the internet for that view either: the view gets the public view's version if there is one, and NXDOMAIN otherwise. The UI shows this as "public version" or "hidden". - public: a second Knot instance (
stronghold-knot-public.service,/etc/knot/stronghold-public.conf) with only the public records, on port 5300 with response rate limiting. The firewall redirects port 53 on the view's WAN zones to it and shows the allow rule as a system rule, so the internet never reaches Unbound or the internal Knot. - Knot in Fedora runs unconfined (no SELinux policy module), so the second instance needs no policy changes; worth revisiting if Knot gets a policy.
Blocklists¶
Lists of names that don't resolve (ads, trackers, malware), the way pfBlockerNG or Pi-hole do it, with Unbound's RPZ (response policy zones):
# dns.yaml
resolver:
blocklists:
- id: ads
url: https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/light.txt
- id: adult
url: https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/porn-only/hosts
views: [kids] # only these views' clients; default: everyone inside
allowed: [example.com] # never blocked, nor anything under it
- Formats: hosts files (
0.0.0.0 name, which block the names listed), one domain per line, adblock rules (||name^; rules with options and exceptions are skipped), dnsmasq (address=/name/) and RPZ zones; the other formats block what's under each name too. - Downloads (
internal/blocklist): every day, with the server's ETag and Last-Modified (so an unchanged list isn't downloaded again), up to 64 MiB; a failed try is tried again after 10 minutes and the last good list stays. Each list becomes an RPZ zone in/var/lib/unbound/stronghold-rpz/<id>.rpz, written where Unbound runs (through its container's agent, like its config:functions.md), which Unbound reloads alone (unbound-control auth_zone_reload), keeping its cache. A new list gets an empty zone at once, so Unbound starts with it. - Unbound:
module-config: "respip ...", anrpz:clause per list withrpz-log(each blocked query in the journal, with the list's id), the allowed names first as their own zone (rpz-passthru). A list for some views gets their tags: each inside network's clients are tagged with their view (access-control-tag, for its IPv4 and stable IPv6 prefixes). - What was blocked:
GET /v1/dns/blocklists(and the DNS tab,stronghold blocklists) gives each list's names, last download and error, the queries blocked since Unbound started (its counters), the last ones and the most blocked names (from the journal); "Allow" next to a name adds it toallowed. "Update now" (POST /v1/dns/blocklists/update,stronghold blocklists update) downloads every list at once. - In a pair each box downloads the lists itself.
- In the lab: Hagezi's light list (62,151 names, adblock format) for everyone and StevenBlack's hosts (73,633) for the guest view only; a name only on the hosts list was blocked from the guest network and not from the LAN; an allowed name passed.
DHCP → DNS¶
- Per network switch: register leases in DNS (on for LAN, typically off for guests).
- In Knot here, or in zones elsewhere (
functions.dns-auth: a server that takes updates signed with the box's DDNS key;functions.md, "Where"). - Names: from the client's hostname (DHCP options 12 and 81, cleaned up
to valid DNS names), under the network's domain (e.g.
laptop.lan.home.example). Reservations use their configured name. - Forward and reverse: each lease gets an A or AAAA record and a matching PTR record. They are removed when the lease expires or is released.
- Conflicts: a dynamic lease never overwrites a static record or another client's name. Kea records which client owns each name (DHCID records, RFC 4703); a second client asking for a taken name gets no record, and the UI flags it.
- Security: Knot accepts updates only from Kea, signed with a TSIG key
generated on the box and kept out of git (
systemd-creds).
Config model sketch¶
Per network in interfaces.yaml:
networks:
- id: lan
zone: lan
ipv4: { address: 192.168.1.1/24 }
ipv6: { from-wan-prefix: wan, subnet-id: 1, ra: slaac }
dhcp:
pools: [192.168.1.100-192.168.1.199]
lease-time: 12h
options:
domain-search: [lan.home.example, home.example]
ntp-servers: [192.168.1.1]
dns-register: true
domain: lan.home.example
reservations:
- { mac: "aa:bb:cc:dd:ee:01", address: 192.168.1.10, name: nas }
- { mac: "aa:bb:cc:dd:ee:02", address: 192.168.1.20, name: printer,
options: { tftp-server: 192.168.1.10 } }
dns.yaml:
resolver:
mode: forward
forwarders: [{ address: 9.9.9.9, tls: dns.quad9.net }]
dnssec: true
domain-overrides:
corp.example: [10.0.0.53]
zones:
- name: home.example
dnssec: true
records:
- { name: "@", type: MX, value: "10 mail.home.example." }
- { name: mail, type: A, value: 192.168.1.30 }
- { name: mail, type: AAAA, value: "::30", prefix-from: lan }
- { name: www, type: CNAME, value: nas }
- { name: "_sip._tcp", type: SRV, value: "10 5 5060 pbx" }
How it is applied (implemented)¶
- Knot listens on
127.0.0.2:53(loopback, but not 127.0.0.1, which Unbound uses), holds every local zone plus a reverse zone per DHCP network whose domain is in a zone, and accepts updates only with the DDNS TSIG key. - No zone files: Knot keeps each zone in its journal only
(
zonefile-load: none,journal-content: all), as DHCP changes the zones too: a zone file reloaded over DHCP's changes drops them (Knot's docs say as much aboutdifference-no-serial, used before; every config change and restart lost the names). strongholdd renders the static records (fromdns.yamland reservations) as a zone file in/var/lib/knot/strongholdand applies the difference from the one it applied last (kept in/var/lib/stronghold/knot) in aknotctransaction; a new zone gets its SOA then, and the serial is Knot's. Records it didn't set (DHCP's) are left alone (internal/dhcpdns/zones.go). In an HA pair only the primary does this (docs/ha.md, "DNS names from DHCP"). - The zones' negative TTL is 30 s, so a name from a new lease resolves soon after an earlier failed lookup.
- Reservations get static A and PTR records. Kea doesn't overwrite them (DHCID conflict resolution).
- Unbound listens on each internal network's gateway address
(
ip-freebind, so a VLAN that isn't up yet doesn't stop it), sends local zones to Knot (stub-zone) and marks themtransparentanddomain-insecure(home.arpais otherwise answered locally). - The TSIG key is generated on the box (
/var/lib/stronghold/secrets) and never enters git. - Each program's own check (
kea-dhcp4 -t,kea-dhcp-ddns -t,knotc conf-check,unbound-checkconf) runs before anything restarts; a failed check or a failed start rolls the whole change back. - Not yet: DNSSEC signing of local zones, AAAA records in the delegated prefix (DHCPv6 serves the stable prefixes only; SLAAC clients in the delegated one aren't registered).
DHCPv6¶
A network with IPv6 picks what its clients get with ra
(docs/interfaces.md, "IPv6"):
networks:
- id: lan
ipv4: { address: 192.168.1.1/24 }
ipv6: { from-wan-prefix: wan, subnet-id: 1, ra: slaac+dhcpv6 }
dhcp:
pools: [192.168.1.100-192.168.1.199]
pools6: ["::1000-::1fff"] # default ::1000-::ffff, in each DHCPv6 /64
domain: lan.home.arpa
dns-register: true
reservations:
- { mac: "aa:bb:cc:dd:ee:01", address: 192.168.1.10, name: nas,
address6: "::10", duid: "00:04:8b:8a:8d:4b:5d:2b:85:77:d1:ea:d8:15:4c:25:a3:b1" }
- Modes:
slaac(the default) only advertises;slaac+dhcpv6also gives each client a DHCPv6 address (and a name);dhcpv6gives only DHCPv6 addresses, for a network with a static prefix. Both DHCPv6 modes need the network'sdhcpsettings (domain, reservations). - Which prefixes: the ones that don't change: the network's ULA /64
and its static one. A delegated prefix can change under a lease, so it
stays with SLAAC (
ra: dhcpv6withfrom-wan-prefixis refused: it would leave the prefix unused). The box's address in the first prefix is the clients' DNS server. - Kea (
kea-dhcp6,/etc/kea/kea-dhcp6.conf) serves a subnet per prefix, with the pools, DNS server, search domain and reservations. - Router advertisements: radvd advertises the DHCPv6 networks with
the "managed" and "other" flags, every /64 on the interface (
prefix ::/64, so it follows the delegated one NetworkManager puts there), with RDNSS and DNSSL; SLAAC stays on except withra: dhcpv6. NetworkManager keeps sharing the network (it still takes the /64 of the delegated prefix) and its own advertisements there are dropped bystronghold_ra6: radvd's carry a router lifetime of 1790 s, which no one else uses. - Reservations take
address6, a whole address or a suffix (::10) in the network's first DHCPv6 prefix, with static AAAA and PTR records when the reservation has a name. Kea finds the device by itsduid, else by its MAC, which it reads from the client's link-local address; that works only for a universal MAC, not a VM's (52:54:00:…is locally administered) nor a phone's private Wi-Fi address. The UI fills in the DUID from the device's DHCPv6 lease ("Make static"). - Names: with
dns-register, a lease gets AAAA and PTR (in the /64'sip6.arpazone, 16 nibbles). A client that sends its DUID as its DHCPv4 client ID (NetworkManager does) has one DHCID for both, and both Kea servers usecheck-exists-with-dhcid, so the name holds its A and its AAAA. A reserved name is a static record, so DHCPv6 doesn't add an AAAA to it: give the reservationaddress6instead. - Leases of both families are in the DHCP tab and
GET /v1/dhcp/leases(DHCPv6 ones with their DUID, no MAC); deleting one useslease6-del. - In a pair kea-dhcp6 has its own HA hook (hot-standby, port 8002 on the sync network). Kea's HA passes on the leases a server hands out, but not ones deleted by command, so deleting a lease (v4 or v6) deletes it on the peer's Kea too, over the same HA listener.
Settled¶
- Unbound as resolver and Knot DNS as authoritative server, rather than BIND for both or Knot Resolver in place of Unbound.