feat(dhcp): add DhcpServerMixin for static DHCP reservations

Adds the generic half of DHCP reservation management: diff_dhcp_reservations
matches desired against live reservations by normalised MAC, and
apply_dhcp_reservationset walks the diff and commits once at the end.

Both are concrete here because neither is vendor-specific — only
get_dhcp_reservations/apply_dhcp_reservation/commit_dhcp_reservations touch
the device (Kea REST on OPNsense, dnsmasq/odhcpd UCI on OpenWrt).

Two deliberate choices:

- The MAC is the matching key, not a description as with firewall rules. A
  reservation has a natural identity and this is it. That also means a host
  moving to another VLAN is an update of the existing entry rather than a
  second one for the same MAC.
- An empty diff skips the commit. Committing reloads the DHCP daemon and
  drops in-flight requests, which is too high a price for a no-op run. This
  differs from apply_firewall_ruleset, which always commits.

Live reservations with no desired counterpart are never reported for
deletion — a DHCP server routinely carries hand-created entries the caller's
desired set was never meant to describe.

Mixed into FirewallDriver and ResidentialGatewayDriver: both device types
commonly run the DHCP server for their networks.
This commit is contained in:
2026-08-19 07:24:39 +07:00
parent a211629875
commit b8977cdaa5
7 changed files with 463 additions and 2 deletions
+6
View File
@@ -28,11 +28,15 @@ Also provided:
* :class:`~napalm_device_types.config_lifecycle.ConfigLifecycleMixin` --
stand-alone mixin to reduce duplication of config lifecycle methods across
drivers.
* :class:`~napalm_device_types.dhcp.DhcpServerMixin` -- static DHCP
reservation read/diff/apply, mixed into the firewall and gateway base
classes.
"""
from napalm_device_types.base import DeviceTypeDriver, FingerprintRule, PortSpec
from napalm_device_types.access_point import AccessPointDriver
from napalm_device_types.config_lifecycle import ConfigLifecycleMixin
from napalm_device_types.dhcp import DhcpServerMixin, normalize_mac
from napalm_device_types.firewall import FirewallDriver
from napalm_device_types.hypervisor import HypervisorDriver
from napalm_device_types.os import OSDriver
@@ -45,6 +49,7 @@ __all__ = [
"AccessPointDriver",
"ConfigLifecycleMixin",
"DeviceTypeDriver",
"DhcpServerMixin",
"FingerprintRule",
"FirewallDriver",
"HypervisorDriver",
@@ -55,4 +60,5 @@ __all__ = [
"StorageDriver",
"SwitchDriver",
"driver_supports_ping",
"normalize_mac",
]
+211
View File
@@ -0,0 +1,211 @@
# -*- coding: utf-8 -*-
"""Static DHCP reservation management, shared by firewalls and gateways.
Both :class:`~napalm_device_types.firewall.FirewallDriver` and
:class:`~napalm_device_types.residential_gateway.ResidentialGatewayDriver`
mix this in, because both device types commonly run the DHCP server for
their networks.
Only ``get_dhcp_reservations``/``apply_dhcp_reservation``/
``commit_dhcp_reservations`` are device-specific and must be implemented by
a concrete driver; the diff and the apply loop are vendor-neutral algorithms
and live here -- see README.md "Design principle: generic vs. device-specific
logic".
"""
from __future__ import annotations
import re
from typing import Any, Dict, Iterator, List, Optional
from napalm_device_types.models import (
DhcpReservationDiffDict,
DhcpReservationDict,
DhcpReservationUpdateDict,
)
# `mac` is the identity, so it is matched rather than compared. `uuid` is
# assigned by the device and never part of the desired state.
_DHCP_RESERVATION_COMPARE_FIELDS = (
"ip",
"hostname",
"description",
"subnet",
)
_HEX_ONLY = re.compile(r"[^0-9a-f]")
def normalize_mac(mac: Optional[str]) -> str:
"""Reduces a MAC address to lowercase colon-separated form.
Devices report MACs in whatever form their config store happens to use --
``AA-BB-CC-DD-EE-01``, ``aabb.ccdd.ee01``, ``AABBCCDDEE01``. Reservations
are matched on this value, so it has to be canonical before comparison.
A value that is not 12 hex digits is returned lowercased and stripped
instead of raising: a malformed device response should degrade to "this
entry never matches" rather than abort the whole diff.
"""
if not mac:
return ""
lowered = mac.strip().lower()
hex_digits = _HEX_ONLY.sub("", lowered)
if len(hex_digits) != 12:
return lowered
return ":".join(hex_digits[i : i + 2] for i in range(0, 12, 2))
class DhcpServerMixin:
"""Mixin providing static DHCP reservation read/diff/apply.
Concrete drivers **must** provide ``get_dhcp_reservations()``,
``apply_dhcp_reservation()`` and ``commit_dhcp_reservations()``.
"""
# ------------------------------------------------------------------
# Device-specific -- must be implemented by the concrete driver.
# ------------------------------------------------------------------
def get_dhcp_reservations(self) -> List[DhcpReservationDict]:
"""
Returns all static DHCP reservations currently configured on the
device, across all subnets.
This is the *configured* state, not the observed leases -- see
``get_dhcp_leases()`` for the latter.
:raises NotImplementedError: If the driver does not support reading
DHCP reservations.
"""
raise NotImplementedError
def apply_dhcp_reservation(
self, reservation: DhcpReservationDict, *, uuid: Optional[str] = None
) -> Dict[str, Any]:
"""
Creates or updates a single static DHCP reservation on the device.
:param reservation: The desired reservation state, vendor-neutral.
:param uuid: If given, update the existing reservation with this ID
in-place. If ``None``, create a new one.
:raises NotImplementedError: If the driver does not support writing
DHCP reservations.
:raises ValueError: If `reservation` names a subnet the device does
not serve.
:raises RuntimeError: If the device rejects the write.
:returns: A dict with at least ``{"success": bool}``.
"""
raise NotImplementedError
def commit_dhcp_reservations(self) -> Dict[str, Any]:
"""
Applies pending reservation changes (e.g. Kea's ``service/reconfigure``,
or a dnsmasq reload).
Call once after one or more `apply_dhcp_reservation()` calls -- not
after every single reservation, and not at all when nothing changed:
on most implementations this reloads the DHCP daemon.
:raises NotImplementedError: If the driver does not support this
(e.g. reservations take effect immediately on write).
:returns: A dict with at least ``{"success": bool}``.
"""
raise NotImplementedError
# ------------------------------------------------------------------
# Generic, vendor-neutral algorithms.
# ------------------------------------------------------------------
def diff_dhcp_reservations(
self, desired: List[DhcpReservationDict]
) -> DhcpReservationDiffDict:
"""
Compares `desired` against the device's current reservations and
returns what would need to change to reach that state.
Matches on the normalised MAC address. A desired reservation with no
live counterpart becomes an "add"; a live one whose MAC matches but
whose other fields differ becomes an "update". Live reservations with
no matching desired entry are **not** reported for deletion -- a DHCP
server routinely carries hand-created reservations that a caller's
`desired` set was never meant to describe, and this method cannot tell
those apart from ones simply no longer wanted. Callers wanting
delete/cleanup semantics must implement that themselves, deliberately.
:param desired: The complete desired reservation set.
:returns: ``{"add": [...], "update": [{"uuid", "reservation",
"changed_fields"}, ...]}``.
"""
live_by_mac: Dict[str, DhcpReservationDict] = {
normalize_mac(reservation.get("mac")): reservation
for reservation in self.get_dhcp_reservations()
}
add: List[DhcpReservationDict] = []
update: List[DhcpReservationUpdateDict] = []
for desired_reservation in desired:
live = live_by_mac.get(normalize_mac(desired_reservation.get("mac")))
if live is None:
add.append(desired_reservation)
continue
changed_fields = [
field
for field in _DHCP_RESERVATION_COMPARE_FIELDS
if live.get(field) != desired_reservation.get(field)
]
if changed_fields:
update.append(
{
"uuid": live["uuid"],
"reservation": desired_reservation,
"changed_fields": changed_fields,
}
)
return {"add": add, "update": update}
def apply_dhcp_reservationset(
self, desired: List[DhcpReservationDict]
) -> Iterator[str]:
"""
Computes the diff against `desired` and applies it, yielding one
human-readable progress line per change, then commits.
Unlike ``apply_firewall_ruleset``, an empty diff does **not** commit:
committing reloads the DHCP daemon and drops in-flight requests, which
is too high a price for a no-op run.
:param desired: The complete desired reservation set.
:yields: Progress lines, one per applied add/update, plus a final
commit line.
"""
diff = self.diff_dhcp_reservations(desired)
for reservation in diff["add"]:
self.apply_dhcp_reservation(reservation)
yield f"[add] {reservation['mac']} -> {reservation['ip']}"
for entry in diff["update"]:
self.apply_dhcp_reservation(entry["reservation"], uuid=entry["uuid"])
fields = ", ".join(entry["changed_fields"])
yield (
f"[update] {entry['reservation']['mac']} -> "
f"{entry['reservation']['ip']} ({fields})"
)
if not diff["add"] and not diff["update"]:
yield "[commit] no changes"
return
self.commit_dhcp_reservations()
yield (
f"[commit] applied {len(diff['add'])} add(s), "
f"{len(diff['update'])} update(s)"
)
+2 -1
View File
@@ -12,6 +12,7 @@ Usage::
from typing import Any, Dict, Iterator, List, Optional
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types.dhcp import DhcpServerMixin
from napalm_device_types._ucd_metrics import IF_SKIP_DEFAULT, collect_ucd_metrics
from napalm_device_types.models import (
FirewallRuleDict,
@@ -40,7 +41,7 @@ _FIREWALL_RULE_COMPARE_FIELDS = (
)
class FirewallDriver(DeviceTypeDriver):
class FirewallDriver(DhcpServerMixin, DeviceTypeDriver):
TYPE_LABEL: str = "Firewall"
"""
Abstract intermediate driver for firewall/security devices.
+34
View File
@@ -353,6 +353,40 @@ class FirewallRuleDiffDict(TypedDict):
update: List[FirewallRuleUpdateDict]
class DhcpReservationDict(TypedDict):
"""A single static DHCP reservation (MAC -> IP), in vendor-neutral form.
`mac` is the stable matching key across get_dhcp_reservations()/
diff_dhcp_reservations()/apply_dhcp_reservation() -- unlike firewall
rules, a reservation has a natural identity, and it is the MAC address.
It is compared after normalisation (see ``dhcp.normalize_mac``), so
devices that report ``AA-BB-CC-DD-EE-01`` still match a desired
``aa:bb:cc:dd:ee:01``.
`subnet` is the CIDR the reservation lives in. It is a *compared* field,
not part of the key: a host that moves to another VLAN keeps its MAC, and
that is an update of the existing reservation rather than a second one.
"""
uuid: str
mac: str
ip: str
hostname: str
description: str
subnet: str
class DhcpReservationUpdateDict(TypedDict):
uuid: str
reservation: DhcpReservationDict
changed_fields: List[str]
class DhcpReservationDiffDict(TypedDict):
add: List[DhcpReservationDict]
update: List[DhcpReservationUpdateDict]
class VPNTunnelDict(TypedDict):
type: str
local_endpoint: str
+2 -1
View File
@@ -20,6 +20,7 @@ Usage::
from typing import Dict, List
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types.dhcp import DhcpServerMixin
from napalm_device_types._ucd_metrics import IF_SKIP_DEFAULT, collect_ucd_metrics
from napalm_device_types.models import (
HealthMetricsDict,
@@ -34,7 +35,7 @@ from napalm_device_types.models import (
)
class ResidentialGatewayDriver(DeviceTypeDriver):
class ResidentialGatewayDriver(DhcpServerMixin, DeviceTypeDriver):
TYPE_LABEL: str = "Gateway"
"""
Abstract intermediate driver for residential gateways (router + firewall + AP).