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