feat(dhcp): implement subnet get/apply/commit against Kea DHCPv4
CI / test (3.10) (push) Failing after 8s
CI / test (3.11) (push) Failing after 8s
CI / test (3.12) (push) Failing after 10s
CI / test (3.9) (push) Failing after 8s

Part of netork#85. Fills in the three device-specific methods the new
DhcpServerMixin subnet layer expects.

searchSubnet only carries uuid/subnet/description, so get_dhcp_subnets
follows each row with getSubnet for the option data. That is one request per
subnet; a firewall serves a handful, so the round trips cost less than the
reconfigure they help avoid. An option Kea does not carry is omitted rather
than reported as empty, because the generic diff reads an absent key as
"not managed" — reporting [] would make every unmanaged option look like a
pending change.

apply_dhcp_subnet honours the mixin's partial-update contract: on an update
it reads the subnet's current options first and replaces only the named
ones. Without that, managing domain_search alone would blank the routers Kea
autocollected and strand every client on that VLAN without a gateway.
Setting any option also forces option_data_autocollect off — left on, Kea
keeps re-filling routers/DNS/NTP and the next diff sees a change again,
which is a reconfigure loop rather than a converged state.

OPNsense renders repeatable option fields as comma-separated strings in some
versions and as a selection map in others, for the same logical field. Both
shapes are accepted rather than pinning the driver to one release. Pools are
a newline-separated text block.

A subnet whose detail fetch fails is skipped with a log line instead of
aborting, same rule as get_dhcp_reservations: one broken record must not
make the whole inventory unreadable.

16 new tests. Not yet verified against a live device — no reachable OPNsense
at the time of writing, same caveat the reservation support shipped with.
This commit is contained in:
Christian Manivong
2026-08-20 07:21:55 +07:00
parent 1eb378c04c
commit 8ba95a0709
2 changed files with 409 additions and 0 deletions
+175
View File
@@ -1568,6 +1568,181 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
self._post("/api/kea/service/reconfigure")
return {"success": True}
# ── Kea DHCPv4 subnets ────────────────────────────────────────────────
# OPNsense renders repeatable option fields ("AsList") as comma-separated
# strings, and the pool list as a newline-separated text block. Which of
# the two shapes a given field uses is a property of the model, not of the
# request, so the mapping is a constant rather than something to sniff.
_KEA_LIST_OPTIONS = (
"routers",
"domain_name_servers",
"domain_search",
"ntp_servers",
)
_KEA_SCALAR_OPTIONS = ("domain_name",)
@staticmethod
def _kea_read_list(raw: Any) -> list[str]:
"""Read an OPNsense list field, in either shape it comes back in.
Plain string: ``"a,b"``. Selection map: ``{"a": {"selected": 1}, ...}``
-- which OPNsense uses for some model field types and versions. Both
appear in the wild for the same logical field, so both are accepted
rather than pinning the driver to one OPNsense release.
"""
if isinstance(raw, dict):
return [
str(key)
for key, meta in raw.items()
if isinstance(meta, dict) and str(meta.get("selected", 0)) == "1"
]
if isinstance(raw, list):
return [str(item).strip() for item in raw if str(item).strip()]
return [part.strip() for part in str(raw or "").split(",") if part.strip()]
@staticmethod
def _kea_read_scalar(raw: Any) -> str:
if isinstance(raw, dict):
selected = [
str(key)
for key, meta in raw.items()
if isinstance(meta, dict) and str(meta.get("selected", 0)) == "1"
]
return selected[0] if selected else ""
return str(raw or "").strip()
def get_dhcp_subnets(self) -> list[dict[str, Any]]:
"""Return every Kea DHCPv4 subnet with its pools and options.
``searchSubnet`` only carries uuid/subnet/description, so each row is
followed by a ``getSubnet`` call for the option data. That is one
request per subnet; a firewall serves a handful of them, so the extra
round trips cost less than the reconfigure they help avoid.
An option Kea does not carry is *omitted* from ``option_data`` rather
than reported as empty. The generic diff treats an absent key as "not
managed", so reporting ``[]`` here would make every unmanaged option
look like a pending change and reload the daemon on every run.
A subnet whose detail fetch fails is skipped with a log line instead
of aborting: one broken record must not make the whole inventory
unreadable, same rule as ``get_dhcp_reservations()``.
:raises RuntimeError: if the Kea plugin isn't installed/enabled.
"""
rows = self._kea_subnets()
subnets: list[dict[str, Any]] = []
for row in rows:
uuid = str(row.get("uuid") or "")
if not uuid:
continue
try:
detail = self._get(f"/api/kea/dhcpv4/getSubnet/{uuid}")
except Exception as exc:
logger.warning("Kea subnet %s detail fetch failed, skipping: %s", uuid, exc)
continue
record = detail.get("subnet") or {}
raw_options = record.get("option_data") or {}
option_data: dict[str, Any] = {}
for name in self._KEA_LIST_OPTIONS:
values = self._kea_read_list(raw_options.get(name))
if values:
option_data[name] = values
for name in self._KEA_SCALAR_OPTIONS:
value = self._kea_read_scalar(raw_options.get(name))
if value:
option_data[name] = value
pools = [
line.strip()
for line in str(record.get("pools") or "").splitlines()
if line.strip()
]
subnets.append({
"uuid": uuid,
"subnet": str(record.get("subnet") or row.get("subnet") or ""),
"description": str(record.get("description") or ""),
"pools": pools,
"option_data": option_data,
"match_client_id": str(record.get("match-client-id") or "0") == "1",
})
return subnets
def apply_dhcp_subnet(
self, subnet: dict[str, Any], *, uuid: str | None = None
) -> dict[str, Any]:
"""Create or update a single Kea DHCPv4 subnet.
``option_data`` is a partial update, as the mixin contract requires:
on an update the subnet's current options are read first and only the
named ones are replaced. Without that, managing `domain_search` alone
would blank the `routers` Kea autocollected and strand every client on
that VLAN without a gateway.
Setting any option explicitly also turns ``option_data_autocollect``
off. Left on, Kea keeps re-filling routers/DNS/NTP from the interface
and the next diff sees a change again -- a reconfigure loop rather
than a converged state.
Does not reconfigure the service -- call ``commit_dhcp_subnets()``
once after a batch.
:raises RuntimeError: if Kea rejects the write.
"""
desired_options = dict(subnet.get("option_data") or {})
merged_options: dict[str, str] = {}
if uuid and desired_options:
try:
current = self._get(f"/api/kea/dhcpv4/getSubnet/{uuid}")
raw = (current.get("subnet") or {}).get("option_data") or {}
except Exception as exc:
raise RuntimeError(
f"Kea subnet {uuid} could not be read before update: {exc}"
) from exc
for name in self._KEA_LIST_OPTIONS:
values = self._kea_read_list(raw.get(name))
if values:
merged_options[name] = ",".join(values)
for name in self._KEA_SCALAR_OPTIONS:
value = self._kea_read_scalar(raw.get(name))
if value:
merged_options[name] = value
for name, value in desired_options.items():
merged_options[name] = ",".join(value) if isinstance(value, list) else str(value)
record: dict[str, Any] = {"subnet": subnet.get("subnet", "")}
if subnet.get("description") is not None:
record["description"] = subnet.get("description") or ""
if subnet.get("pools") is not None:
record["pools"] = "\n".join(subnet.get("pools") or [])
if subnet.get("match_client_id") is not None:
record["match-client-id"] = "1" if subnet.get("match_client_id") else "0"
if desired_options:
record["option_data"] = merged_options
record["option_data_autocollect"] = "0"
path = (
f"/api/kea/dhcpv4/setSubnet/{uuid}" if uuid else "/api/kea/dhcpv4/addSubnet"
)
result = self._post(path, {"subnet": record})
if result.get("result") != "saved":
raise RuntimeError(
f"Kea rejected DHCP subnet {subnet.get('subnet')}: {result}"
)
return {"success": True}
def commit_dhcp_subnets(self) -> dict[str, Any]:
"""Reload Kea so pending subnet writes take effect."""
self._post("/api/kea/service/reconfigure")
return {"success": True}
def get_services(self) -> list[dict[str, Any]]:
"""Return running services from OPNsense.