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 IP10.20.20.2(FreeIPA) · Port53— runbook 10Leave 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_vhostsbyscripts/apps-frontdoor-sync.py(which also emits the SERVERS→DMZ firewall allow for DMZ backends) — one line inproxy_vhosts+apps-frontdoor-sync.pygives the Caddy vhost, the firewall rule, and this override. Current set: everyproxy_vhosts_corename (ha, jellyfin, invidious, stats, git, search, cloud, files, dav, auth, ntfy) →10.20.20.6(apps-proxy). This includesgit(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) andinvidious(now Tier-2 via apps-proxy — the override points at10.20.20.6, which reverse-proxies to the DMZ backend:3000; no edge/Authelia; see runbook 21).registryis the lone split-horizon name not generated here — it has its own Caddy on the registry host, so its override points at10.20.20.15, not apps-proxy.
2. DHCP (Kea) per zone¶
OPNsense defaults to Kea DHCP. Services → Kea DHCP → KEA DHCPv4:
1. Settings tab — enable 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.100and thenping 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.1and DNS servers = the zone's.1(Unbound). That's reliable. (Both.1values 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**, adefault via 10.20.<vlan>.1route (ip route), and the zone's.1as 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 net — this 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.10xlease, gateway.1, DNS.1, andnslookup opnsense.${domain}resolves. - [ ] From an IOT-tagged test client,
nslookup example.com 8.8.8.8still answers from the firewall (intercept working — the query to 8.8.8.8 is redirected to Unbound).
Then → runbook 05 (ProtonVPN egress).