Skip to content

Runbook 04 — DHCP + DNS (Unbound)

Goal: per-zone DHCP (Kea), Unbound as the resolver for all internal zones, and the IoT DNS intercept.

Prereqs: runbook 03 validated.


1. Unbound (resolver)

Services → Unbound DNS → General: - Enable: on - Listen Port: 53 - Network Interfaces: select MGMT, SERVERS, DMZ, WORKSTATIONS, IOT, GUEST. Do NOT select WAN or the Proton/WG interfaces. - DNSSEC: on - DHCP registration caveat (Kea): Unbound's "Register DHCP leases" option is ISC-specific ("Register ISC DHCP4 Leases") — it does not register Kea leases, so dynamic client hostnames won't auto-resolve. For names that must resolve, either use Kea's DDNS (26.1) or add static host overrides (Unbound → Overrides). Our servers are static with host overrides anyway, so this mostly doesn't matter. (Leave the ISC toggle off — we use Kea.)

Outgoing: leave Unbound resolving from roots (or set forwarders to 9.9.9.9/1.1.1.1 under Query Forwarding if you prefer forwarding). Note: once ProtonVPN egress is live (runbook 05), Unbound's outbound queries follow the system routing — keep DNS egress consistent with your privacy goal there.

DNS roles — don't conflate them. Unbound here is the internal resolver, not authoritative. On the Services → Unbound DNS → Query Forwarding tab add one entry (fields Domain / Server IP / Server Port): - Domain ${ipa_domain} → Server IP 10.20.20.2 (FreeIPA) · Port 53 — runbook 10

Leave the page's "Use System Nameservers" toggle OFF — on, it forwards everything to the system nameservers (your per-domain entry still wins, but you don't want blanket forwarding when the design is recursion-via-Proton).

Everything else Unbound recurses/forwards upstream. Bind is separate — it's the hidden authoritative primary for your public zones, internal-only in the DMZ (10.20.30.40): not in the registrar's NS records, not publicly reachable. It serves AXFR to the public Bind secondaries on the VPS edges (runbooks 09/17), which are what the registrar delegates to. It is not in the internal resolution path. Public names (e.g. cloud.${domain}) resolve via the edge secondaries to the edge IPs; if internal clients should reach them by the internal origin IP instead, add an Unbound host-override (split-horizon) → 10.20.30.10 (the origin Caddy).

Internal split-horizon host-overrides are GENERATED from apps-proxy proxy_vhosts by scripts/apps-frontdoor-sync.py (which also emits the SERVERS→DMZ firewall allow for DMZ backends) — one line in proxy_vhosts + apps-frontdoor-sync.py gives the Caddy vhost, the firewall rule, and this override. Current set: every proxy_vhosts_core name (ha, jellyfin, invidious, stats, git, search, cloud, files, dav, auth, ntfy) → 10.20.20.6 (apps-proxy). This includes git (the Forgejo internal front-door — clone/pull over the SERVERS path instead of DMZ:2222; the public face stays on the edge, see runbook 35/CLAUDE.md) and invidious (now Tier-2 via apps-proxy — the override points at 10.20.20.6, which reverse-proxies to the DMZ backend :3000; no edge/Authelia; see runbook 21). registry is the lone split-horizon name not generated here — it has its own Caddy on the registry host, so its override points at 10.20.20.15, not apps-proxy.


2. DHCP (Kea) per zone

OPNsense defaults to Kea DHCP. Services → Kea DHCP → KEA DHCPv4:

1. Settings tabenable Kea, and under Interfaces select the zone interfaces it should serve (MGMT/SERVERS/DMZ/WS/IOT/GUEST). (This selection is also what binds subnets to interfaces — see below.)

2. Subnets tab → + — add one subnet per zone:

Field Value (WORKSTATIONS example)
Subnet 10.20.40.0/24
Pools 10.20.40.100-10.20.40.199 (range start-end, one per line)
Auto collect option data UNCHECK it
Routers 10.20.40.1
DNS servers 10.20.40.1
Domain name ${domain}
Description WORKSTATIONS zone (VLAN 40) - 10.20.40.0/24

⚠️ Uncheck "Auto collect option data" and set Routers + DNS by hand — don't trust auto-collect. In this build, auto-collect handed clients the address but not the gateway/DNS options, so a client got 10.20.40.100 and then ping 8.8.8.8"Network is unreachable" (no default route). Unchecking the box reveals the Routers / DNS servers / Domain name fields; set Routers = the zone's .1 and DNS servers = the zone's .1 (Unbound). That's reliable. (Both .1 values are the same — clients use the firewall as gateway and resolver.)

Associating a subnet with its interface is automatic — there's no per-subnet interface picker. Kea binds each subnet to the interface whose IP falls inside the subnet CIDR, as long as that interface is selected in the Settings tab (step 1). So 10.20.40.0/24 is served on WORKSTATIONS (10.20.40.1) with no extra step.

Repeat for MGMT/SERVERS/DMZ/IOT/GUEST (swap the third octet — Routers/DNS = 10.20.<vlan>.1, Description = the zone name). Reserve .2–.99 for static/server assignments.

Verify (when a client is on the zone): it gets 10.20.<vlan>.10x/**24**, a default via 10.20.<vlan>.1 route (ip route), and the zone's .1 as DNS. No default route = the Routers option didn't get delivered → re-check that auto-collect is off and Routers is set.

Servers (fileserver, FreeIPA, Jellyfin, Nextcloud, origin Caddy, hidden-primary Bind) get static addresses, not DHCP — assigned on the hosts themselves and recorded as reservations/host-overrides. IPs are pinned in runbooks 09–11.


3. IoT DNS intercept (force IoT to use Unbound)

Stops IoT devices from using hardcoded public DNS. Firewall → NAT → Destination NAT (the MVC page at /ui/firewall/d_nat/) → +: - Interface: IOT - Protocol: TCP/UDP - Source Address: IOT netthis field sits under a collapsed "Source (advanced)" header; expand it to set the source. - Destination Address: IOT address + tick "Invert Destination" (any dest that is not the firewall) · Destination Port: 53 (DNS) - Redirect Target IP: 10.20.60.1 · Redirect Target Port: 53 - Firewall rule: Pass — (the dropdown is labeled "Firewall rule") the values are Manual (you add the firewall rule yourself), Pass (a hidden auto rule — simplest here), or Register rule (a visible linked rule). Pass lets the redirected DNS through. - Description: Intercept IoT DNS → Unbound

(Optionally replicate for GUEST.)


4. DoT/DoH leak — heads-up only (nothing to configure here)

No action in this runbook. Context for runbook 06: the §3 intercept only catches plain DNS (port 53). A device could bypass it with DoT (port 853) or DoH (port 443 to a public resolver). The fix is a firewall rule blocking TCP/UDP 853 outbound on the client/IoT zones — it's already in runbook 06 as "Block DoT 853" (WORKSTATIONS/GUEST/IOT). DoH is harder (rides 443); block known DoH IPs via an alias later only if you care.


Validation

  • [ ] Unbound running; listening only on internal zone IPs (not WAN). Check Services → Unbound DNS → Log File for startup with no bind errors.
  • [ ] Put a test client on a VLAN access/trunk port (or tag a Proxmox test VM's NIC to e.g. VLAN 40): it gets a 10.20.40.10x lease, gateway .1, DNS .1, and nslookup opnsense.${domain} resolves.
  • [ ] From an IOT-tagged test client, nslookup example.com 8.8.8.8 still answers from the firewall (intercept working — the query to 8.8.8.8 is redirected to Unbound).

Then → runbook 05 (ProtonVPN egress).