diff --git a/opentofu/templates/opnsense-config.xml.tmpl b/opentofu/templates/opnsense-config.xml.tmpl
index a0047d6..a684391 100644
--- a/opentofu/templates/opnsense-config.xml.tmpl
+++ b/opentofu/templates/opnsense-config.xml.tmpl
@@ -164,5 +164,45 @@
+
+
+
+
+ 1
+ 0
+ lan
+ 4000
+ 1
+ raw
+
+
+
+ {{LAN_NETWORK_CIDR}}
+ 1
+
+ {{LAN_IPADDR}}
+ {{LAN_IPADDR}}
+ {{DOMAIN}}
+
+ {{DHCP_POOL}}
+ LAN DHCP
+
+
+
+
+
diff --git a/scripts/opnsense-render-config.sh b/scripts/opnsense-render-config.sh
index e62c6a2..1d90404 100644
--- a/scripts/opnsense-render-config.sh
+++ b/scripts/opnsense-render-config.sh
@@ -29,6 +29,7 @@
WAN_IF WAN_IPADDR WAN_SUBNET_BITS WAN_GATEWAY
LAN_IF LAN_IPADDR LAN_SUBNET_BITS
ROOT_AUTHORIZED_KEYS
+ DHCP_POOL_START DHCP_POOL_END
)
# ROOT_AUTHORIZED_KEYS: the PUBLIC key text (e.g. the contents of an id_ed25519.pub).
# REQUIRED, no default -- D-112(c) makes SSH the management path for the edge, so an edge
@@ -47,6 +48,32 @@
# the friendly message rather than a `set -u` unbound-variable crash.
ROOT_AUTHORIZED_KEYS_B64="$(printf '%s\n' "$ROOT_AUTHORIZED_KEYS" | base64 -w0)"
+# --- LAN DHCP (Kea) -------------------------------------------------------------------
+# Derive the LAN network CIDR and a DETERMINISTIC subnet4 uuid, and VALIDATE that the pool
+# actually lies inside the LAN subnet (a pool outside its subnet is silently accepted by the
+# XML but serves nobody). Deterministic uuid5 => re-rendering is idempotent and does not churn
+# the config. Fails loud on any inconsistency -- no inferred values.
+_dhcp="$(python3 - "$LAN_IPADDR" "$LAN_SUBNET_BITS" "$DHCP_POOL_START" "$DHCP_POOL_END" <<'PY'
+import sys, ipaddress, uuid
+ip, bits, lo, hi = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4]
+net = ipaddress.ip_network(f"{ip}/{bits}", strict=False)
+a, b = ipaddress.ip_address(lo), ipaddress.ip_address(hi)
+for label, addr in (("DHCP_POOL_START", a), ("DHCP_POOL_END", b)):
+ if addr not in net:
+ sys.exit(f"FAIL: ${label} {addr} is not inside the LAN subnet {net}")
+if a > b:
+ sys.exit(f"FAIL: $DHCP_POOL_START {a} is above $DHCP_POOL_END {b}")
+if ipaddress.ip_address(ip) in list(ipaddress.summarize_address_range(a, b))[0]:
+ sys.exit(f"FAIL: the LAN address {ip} falls inside the DHCP pool {a}-{b} (it must not)")
+print(str(net))
+print(f"{a}-{b}")
+print(uuid.uuid5(uuid.NAMESPACE_DNS, f"opnsense-kea-dhcp4-{net}"))
+PY
+)" || { echo "$_dhcp"; exit 1; }
+LAN_NETWORK_CIDR="$(sed -n 1p <<<"$_dhcp")"
+DHCP_POOL="$(sed -n 2p <<<"$_dhcp")"
+DHCP_SUBNET_UUID="$(sed -n 3p <<<"$_dhcp")"
+
[ -r "$TEMPLATE" ] || { echo "FAIL: template not found: $TEMPLATE"; exit 1; }
route_block() { # route_block
@@ -63,7 +90,8 @@
ROUTE2_BLOCK="$(route_block ROUTE2_NETWORK ROUTE2_GATEWAY ROUTE2_DESCR)"
content="$(cat "$TEMPLATE")"
-for v in "${REQUIRED_VARS[@]}" NTP_UPSTREAM_SERVERS NTP_PREFER_SERVER ROOT_AUTHORIZED_KEYS_B64; do
+for v in "${REQUIRED_VARS[@]}" NTP_UPSTREAM_SERVERS NTP_PREFER_SERVER \
+ ROOT_AUTHORIZED_KEYS_B64 LAN_NETWORK_CIDR DHCP_POOL DHCP_SUBNET_UUID; do
content="${content//"{{$v}}"/${!v}}"
done
content="${content//"{{ROUTE1_BLOCK}}"/$ROUTE1_BLOCK}"
diff --git a/tests/opnsense-render-config/run-tests.sh b/tests/opnsense-render-config/run-tests.sh
index 81492bc..289600d 100644
--- a/tests/opnsense-render-config/run-tests.sh
+++ b/tests/opnsense-render-config/run-tests.sh
@@ -17,6 +17,7 @@
export LAN_IF="vtnet1" LAN_IPADDR="10.12.8.1" LAN_SUBNET_BITS="22"
# PUBLIC key only (a throwaway fixture value; never a real key).
export ROOT_AUTHORIZED_KEYS="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE harness@fixture"
+ export DHCP_POOL_START="10.12.8.100" DHCP_POOL_END="10.12.11.199"
# MIRROR_* removed (DOCFIX-185): edge is a normal ISP router, no egress-airgap tokens.
unset MIRROR_SYNC_PROTOCOL MIRROR_UPSTREAM_NET MIRROR_SYNC_PORT
unset ROUTE1_NETWORK ROUTE1_GATEWAY ROUTE1_DESCR ROUTE2_NETWORK ROUTE2_GATEWAY ROUTE2_DESCR
@@ -144,5 +145,59 @@
echo " FAIL T9c present -- not an OPNsense key"; FAIL=$((FAIL+1))
fi
+# --- LAN DHCP via Kea. OPNsense 26.1 has NO ISC dhcpd, so an old block is inert.
+# Until 2026-07-12 the template had NO DHCP section at all: the edge routed but served no
+# addresses, while the ledger claimed it did. These cases stop that recurring.
+
+base_env
+run_ok "T10 renders with Kea DHCP" "$TMP/t10.xml"
+
+if grep -q "" "$TMP/t10.xml" && grep -q "10.12.8.100-10.12.11.199" "$TMP/t10.xml"; then
+ echo " PASS T10b Kea block present with the requested pool"; PASS=$((PASS+1))
+else
+ echo " FAIL T10b no Kea block / wrong pool -- the edge would serve no DHCP"; FAIL=$((FAIL+1))
+fi
+
+# dhcp4 must actually be ENABLED and bound to the LAN interface identifier (not a device name).
+if sed -n '//p' "$TMP/t10.xml" | grep -q "1" \
+ && sed -n '//p' "$TMP/t10.xml" | grep -q "lan"; then
+ echo " PASS T10c dhcp4 ENABLED and bound to the 'lan' interface identifier"; PASS=$((PASS+1))
+else
+ echo " FAIL T10c dhcp4 not enabled / not bound to lan"; FAIL=$((FAIL+1))
+fi
+
+# The subnet must be the LAN NETWORK, not the LAN host address (10.12.8.1/22 -> 10.12.8.0/22).
+if grep -q "10.12.8.0/22" "$TMP/t10.xml"; then
+ echo " PASS T10d subnet is the LAN NETWORK (10.12.8.0/22), not the host address"; PASS=$((PASS+1))
+else
+ echo " FAIL T10d subnet is not the derived LAN network"; FAIL=$((FAIL+1))
+fi
+
+# The subnet4 uuid must be DETERMINISTIC, so re-rendering does not churn the config.
+base_env
+run_ok "T10e re-render" "$TMP/t10e.xml"
+u1="$(grep -o 'subnet4 uuid="[^"]*"' "$TMP/t10.xml")"
+u2="$(grep -o 'subnet4 uuid="[^"]*"' "$TMP/t10e.xml")"
+if [ -n "$u1" ] && [ "$u1" = "$u2" ]; then
+ echo " PASS T10f subnet4 uuid is deterministic across renders (idempotent)"; PASS=$((PASS+1))
+else
+ echo " FAIL T10f subnet4 uuid changed between renders -- config would churn"; FAIL=$((FAIL+1))
+fi
+
+# T11: a pool OUTSIDE the LAN subnet is XML-valid but serves nobody -> must FAIL LOUD.
+base_env
+export DHCP_POOL_START="192.168.99.100" DHCP_POOL_END="192.168.99.199"
+run_fail 1 "T11 pool outside the LAN subnet FAILS (rc 1)" "$TMP/t11.xml"
+
+# T12: a pool that swallows the router's own LAN address -> must FAIL LOUD.
+base_env
+export DHCP_POOL_START="10.12.8.1" DHCP_POOL_END="10.12.8.199"
+run_fail 1 "T12 pool containing the LAN address itself FAILS (rc 1)" "$TMP/t12.xml"
+
+# T13: reversed pool bounds -> must FAIL LOUD.
+base_env
+export DHCP_POOL_START="10.12.8.199" DHCP_POOL_END="10.12.8.100"
+run_fail 1 "T13 reversed pool bounds FAIL (rc 1)" "$TMP/t13.xml"
+
echo; echo "RESULT: PASS=$PASS FAIL=$FAIL"
[[ "$FAIL" -eq 0 ]] && { echo "ALL PASS"; exit 0; } || exit 1