feat(dhcp): implement subnet get/apply/commit against Kea DHCPv4
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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user