feat(dhcp): implement DhcpServerMixin against Kea DHCPv4
CI / test (3.10) (push) Failing after 10s
CI / test (3.11) (push) Failing after 8s
CI / test (3.12) (push) Failing after 10s
CI / test (3.9) (push) Failing after 7s

Fills in the three device-specific methods so the generic diff/apply from
napalm-device-types works against OPNsense: searchReservation for the read,
addReservation/setReservation for the write, service/reconfigure for the
commit.

Until now the driver could only create and delete reservations as a
side-effect of VM provisioning, and never read them back — so there was no
way to see what a firewall already had.

Kea's `subnet` field on a reservation is a model relation that comes back as
the related subnet's CIDR in some versions and as its UUID in others. Both
are accepted and normalised to a CIDR; an unresolvable relation degrades to
an empty string rather than raising, so one orphaned entry cannot make the
whole inventory unreadable.

apply_dhcp_reservation deliberately does not reconfigure: that is the
commit's job, so a batch costs one daemon reload instead of one per entry.

Verified against mocked Kea responses only — no live OPNsense was available
at the time of writing. The CIDR-vs-UUID branch in particular is defensive
rather than empirically confirmed.
This commit is contained in:
Christian Manivong
2026-08-19 07:24:48 +07:00
parent f934cc0cfa
commit 1eb378c04c
2 changed files with 336 additions and 0 deletions
+128
View File
@@ -1440,6 +1440,134 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
return result
# ------------------------------------------------------------------
# DhcpServerMixin implementation (Kea DHCPv4). The diff and the apply
# loop are generic and live in napalm_device_types.dhcp; only these
# three methods know about Kea's REST shape.
# ------------------------------------------------------------------
def _kea_subnets(self) -> list[dict[str, Any]]:
"""Return Kea's managed subnets, or raise if the plugin is absent."""
try:
return self._get("/api/kea/dhcpv4/searchSubnet").get("rows") or []
except Exception as exc:
raise RuntimeError(f"Kea DHCPv4 plugin unavailable: {exc}") from exc
def _kea_subnet_uuid_for(self, subnet: str, ip: str) -> str:
"""Resolve a reservation's target subnet to Kea's subnet UUID.
Prefers an exact CIDR match on ``subnet``; falls back to whichever
managed subnet contains ``ip`` when the caller left ``subnet`` empty.
"""
rows = self._kea_subnets()
if subnet:
for row in rows:
if str(row.get("subnet") or "").strip() == subnet:
return str(row["uuid"])
raise ValueError(f"No Kea-managed subnet matches {subnet}")
ip_obj = ip_address(ip)
for row in rows:
try:
if ip_obj in ip_network(row["subnet"], strict=False):
return str(row["uuid"])
except (ValueError, KeyError):
continue
raise ValueError(f"No Kea-managed subnet contains {ip}")
def get_dhcp_reservations(self) -> list[dict[str, Any]]:
"""Return all Kea DHCPv4 static reservations, vendor-neutral.
This is the *configured* state — ``get_dhcp_leases()`` returns what
is actually leased. Kea's ``subnet`` field on a reservation is a
model relation: depending on version it comes back as the related
subnet's CIDR or as its UUID, so both are accepted and normalised to
a CIDR here. An unresolvable relation degrades to an empty string
rather than raising — one orphaned reservation must not make the
whole inventory unreadable.
:raises RuntimeError: if the Kea plugin isn't installed/enabled.
"""
try:
data = self._post(
"/api/kea/dhcpv4/searchReservation",
{"current": 1, "rowCount": -1},
)
except Exception as exc:
raise RuntimeError(f"Kea DHCPv4 plugin unavailable: {exc}") from exc
rows = data.get("rows") or []
if not rows:
return []
cidr_by_uuid = {
str(row.get("uuid") or ""): str(row.get("subnet") or "")
for row in self._kea_subnets()
}
known_cidrs = set(cidr_by_uuid.values())
reservations: list[dict[str, Any]] = []
for row in rows:
raw_subnet = str(row.get("subnet") or "").strip()
if raw_subnet in known_cidrs:
subnet = raw_subnet
else:
subnet = cidr_by_uuid.get(raw_subnet, "")
reservations.append({
"uuid": str(row.get("uuid") or ""),
"mac": str(row.get("hw_address") or ""),
"ip": str(row.get("ip_address") or ""),
"hostname": str(row.get("hostname") or ""),
"description": str(row.get("description") or ""),
"subnet": subnet,
})
return reservations
def apply_dhcp_reservation(
self, reservation: dict[str, Any], *, uuid: str | None = None
) -> dict[str, Any]:
"""Create or update a single Kea DHCPv4 static reservation.
Does not reconfigure the service — call ``commit_dhcp_reservations()``
once after a batch, so a ruleset apply costs one daemon reload rather
than one per reservation.
:raises ValueError: if no Kea-managed subnet matches the reservation.
:raises RuntimeError: if Kea rejects the write.
"""
subnet_uuid = self._kea_subnet_uuid_for(
str(reservation.get("subnet") or "").strip(),
str(reservation.get("ip") or ""),
)
payload = {
"reservation": {
"subnet": subnet_uuid,
"ip_address": reservation.get("ip", ""),
"hw_address": reservation.get("mac", ""),
"hostname": reservation.get("hostname", ""),
"description": reservation.get("description", ""),
}
}
path = (
f"/api/kea/dhcpv4/setReservation/{uuid}"
if uuid
else "/api/kea/dhcpv4/addReservation"
)
result = self._post(path, payload)
if result.get("result") != "saved":
raise RuntimeError(
f"Kea rejected DHCP reservation for {reservation.get('ip')}: {result}"
)
return {"success": True}
def commit_dhcp_reservations(self) -> dict[str, Any]:
"""Reload Kea so pending reservation 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.