HomeDocs › DHCP Drivers

DHCP Driver Specification

DHCP drivers are the backend-specific layer that turns SpatiumDDI’s internal DHCP model into operations on real DHCP servers. The service layer only ever speaks to DHCPDriver (CLAUDE.md non-negotiable #10) — no Kea / PowerShell specifics leak above this line.

The driver registry (registry.py) classifies drivers along two axes:

Axis Values What it means
AGENTLESS_DRIVERS windows_dhcp, fortigate Driver runs from the control plane. No co-located agent, no ConfigBundle long-poll.
READ_ONLY_DRIVERS windows_dhcp Driver implements lease reads only. Config push / reload / restart raise NotImplementedError.
CLOUD_DHCP_DRIVERS fortigate Agentless and write-capable: pushes whole-scope config to a provider REST API. is_cloud() gates the write-through + port defaults.

All other drivers (currently just kea) are agented: the control plane renders a ConfigBundle, hash-keyed by SHA-256 ETag, and the co-located spatium-dhcp-agent long-polls /config to pick up changes.


1. Driver shapes

Today’s drivers split into three shapes:

DHCP driver shapes — agented vs agentless, read-only vs write

The abstract base (DHCPDriver) has methods for both halves. Read-only agentless drivers (Windows) implement only the read methods + a stub apply_config / reload / restart / validate_config that raises; the API layer consults READ_ONLY_DRIVERS before offering write endpoints. Cloud agentless drivers (FortiGate) are write-capable but push the whole scope object synchronously over a REST API instead of rendering a daemon config bundle — see §5.


2. Abstract base class

Key methods on DHCPDriver:

class DHCPDriver(ABC):
    name: str = "abstract"

    # Rendering (agented drivers).
    @abstractmethod
    def render_config(self, bundle: ConfigBundle) -> str: ...

    # Applying on the server host (agent-side).
    @abstractmethod
    async def apply_config(self, server: Any, bundle: ConfigBundle) -> None: ...
    @abstractmethod
    async def reload(self, server: Any) -> None: ...
    @abstractmethod
    async def restart(self, server: Any) -> None: ...
    @abstractmethod
    def validate_config(self, bundle: ConfigBundle) -> tuple[bool, list[str]]: ...

    # Reads.
    @abstractmethod
    async def get_leases(self, server: Any) -> list[dict[str, Any]]: ...
    @abstractmethod
    async def health_check(self, server: Any) -> tuple[bool, str]: ...
    @abstractmethod
    def capabilities(self) -> dict[str, Any]: ...

    # Optional per-object write APIs (Windows DHCP today). The default
    # batch impls below loop over the singular methods, which only the
    # Windows driver overrides; non-Windows drivers raise NotImplementedError.
    async def apply_reservations(self, server: Any, *,
                                 items: Sequence[ReservationItem]
                                 ) -> list[ReservationResult]: ...
    async def remove_reservations(self, server: Any, *,
                                  items: Sequence[RemoveReservationItem]
                                  ) -> list[ReservationResult]: ...
    async def apply_exclusions(self, server: Any, *,
                               items: Sequence[ExclusionItem]
                               ) -> list[ExclusionResult]: ...

Neutral data classes (ScopeDef, PoolDef, StaticAssignmentDef, ClientClassDef, ConfigBundle) are frozen dataclasses — hashing them gives the ETag that drives long-poll.


3. Kea driver (agented + write)

Located at app/drivers/dhcp/kea.py. Agent image: agent/dhcp/.

Update strategy

Operation Mechanism Notes
Full scope/pool/reservation push ConfigBundle → agent fetches via long-poll → agent renders + writes /etc/kea/kea-dhcp4.confconfig-testconfig-reload over the Kea unix control socket Incremental config — no daemon restart.
Validate config-test on the unix socket (with the full config doc as arguments) Preflight — a config Kea rejects is never reloaded.
Read leases Agent tails the memfile lease CSV (/var/lib/kea/kea-leases4.csv) Not the lease_cmds HTTP API. The hook is still loaded (HA depends on it), but leases reach the control plane by CSV tail → POST /dhcp/agents/lease-events.
HA state status-get on the unix socket, every ~15 s Drives the live HA pill in the UI.
Metrics statistic-get-all on the unix socket, every ~60 s Deltas of the pkt4-* counters.

There is no HTTP Control Agent. kea-ctrl-agent was removed in #637: Kea 3.0 deprecates it, and SpatiumDDI never spoke to it. Everything above rides the unix control sockets (/run/kea/kea4-ctrl-socket, /run/kea/kea6-ctrl-socket) via agent/dhcp/spatium_dhcp_agent/kea_ctrl.py. :8000 is the HA hook’s peer-to-peer listener and is now the only TCP port the image exposes.

Note also that the backend’s KeaDriver.apply_config() / reload() / get_leases() are no-ops — the backend renders config only for the ETag / audit / shape-validation path. The renderer that actually feeds the daemon is the agent’s render_kea.py. Two renderers exist; the agent one is the load-bearing one.

The agent drives Kea by:

  1. Rendering the config bundle into Kea JSON (Dhcp4 for IPv4, Dhcp6 for IPv6 — address-family split on DHCPScope.address_family).
  2. Sending config-test with the rendered doc to catch validation errors before touching the live config (a daemon too old to know the command answers result=2, which is treated as a soft pass).
  3. Calling config-reload, which re-reads the file without dropping in-flight leases.

Kea 3.0 path restrictions

Kea 3.0 (CVE-2025-32801/2/3) refuses to start if hook libraries, unix control sockets, lease files or log files sit outside its compiled-in default directories. Enforcement is a literal string compare of the config path’s parent — there is no realpath(), so even /var/run/kea vs /run/kea fails. Alpine builds Kea with -Dprefix=/usr -Dlocalstatedir=/var -Drunstatedir=/run, so the enforced defaults are /usr/lib/kea/hooks, /run/kea, /var/lib/kea, /var/log/keaexactly SpatiumDDI’s existing layout, which is why the 2.6 → 3.0 jump needed no config changes. Do not relocate any of them. Verify with kea-dhcp4 -W (“Hooks directory”) and kea-dhcp4 -T <conf>.

The IPv6 path renders a Dhcp6 tree in parallel to Dhcp4. Dhcp6 option-name translation lands via _KEA_OPTION_NAMES_V6 in both backend/app/drivers/dhcp/kea.py and the agent’s render_kea._options_from_mapping_v6; v4-only options (routers, broadcast-address, mtu, time-offset, tftp-*) are dropped from v6 scopes with a warning.

Wire shape from control plane → renderer

The HTTP envelope returned by GET /api/v1/dhcp/agents/config is:

{
  "server_id": "<uuid>",
  "etag": "sha256:…",
  "bundle": {
    "server_name": "…",
    "driver": "kea",
    "roles": [...],
    "server": { "interfaces": ["*"], "dhcp_socket_type": "raw" },
    "scopes": [
      {
        "subnet_cidr": "10.0.0.0/24",
        "lease_time": 3600,
        "options": {},
        "pools": [{"start_ip": "...", "end_ip": "...", "pool_type": "dynamic"}, ],
        "statics": [{"ip_address": "...", "mac_address": "...", "hostname": "..."}, ],
        "ddns_enabled": false
      }, 
    ],
    "client_classes": [{"name": "...", "match_expression": "...", "options": {}}, ],
    "failover": { ... } | null
  },
  "pending_ops": [...]
}

Shipped 2026.04.21-2: render_kea.py:_scope_to_subnet maps each wire scope to a Kea subnet4 entry — subnet_cidrsubnet, pools[start_ip,end_ip,pool_type]{"pool": "start - end"} (only dynamic pools are emitted; excluded / reserved are IPAM bookkeeping and must not become Kea lease pools), statics[ip_address,mac_address,hostname]reservations[ip-address,hw-address,hostname]. Client-class match_expression → Kea test. Subnet id is derived deterministically from the CIDR via truncated SHA-256, so a config reload never orphans active leases by renumbering subnets.

Socket type (issue #365). The server.dhcp_socket_type field drives Dhcp4.interfaces-config.dhcp-socket-type. It is derived from the server group’s dhcp_socket_mode in services/dhcp/config_bundle.py (directraw, relayudp) and is part of ConfigBundle.compute_etag(), so flipping the mode shifts the ETag and the agent re-renders on its next long-poll. The default is raw (AF_PACKET) so Kea hears broadcast DHCPDISCOVERs from directly-attached clients; the agent’s render_kea.py also falls back to raw when an older control plane omits the server block. raw needs CAP_NET_RAW (appliance DaemonSet + shipped compose Kea services grant it). DHCPv6 has no socket-type concept — the field applies to Dhcp4 only.

HA coordination

Kea’s built-in libdhcp_ha.so hook handles pool coordination between paired servers. Under the group-centric data model (shipped 2026.04.21-2), SpatiumDDI treats a DHCPServerGroup with exactly two Kea members as an implicit HA pair — HA tuning lives on the group, per-peer URLs live on each DHCPServer.ha_peer_url. There is no separate “failover channel” object any more.

Agent bootstrap

Identical pattern to the DNS agent (DNS_AGENT.md):

  1. Agent starts with SPATIUM_AGENT_KEY (PSK) in its environment; the control plane validates it against its own DHCP_AGENT_KEY.
  2. Calls POST /api/v1/dhcp/agents/register with the PSK in the X-DHCP-Agent-Key header → receives a per-server rotating JWT (agent_token).
  3. Long-polls GET /api/v1/dhcp/agents/config with the JWT and an If-None-Match ETag.
  4. On 401 or 404, re-bootstraps from the PSK.
  5. Caches the last-good bundle under /var/lib/spatium-dhcp-agent/.

4. Windows DHCP driver (agentless + read-only, Path A)

Located at app/drivers/dhcp/windows.py. Class: WindowsDHCPReadOnlyDriver.

Capabilities

Operation Status How
get_leases Get-DhcpServerv4ScopeGet-DhcpServerv4Lease per scope via WinRM.
get_scopes Get-DhcpServerv4Scope + options + exclusions + reservations in one PowerShell call, JSON-serialised back.
apply_scope / remove_scope Add-DhcpServerv4Scope / Remove-DhcpServerv4Scope. Called per-object from the API — not via a bundle push.
apply_reservation / remove_reservation Add-DhcpServerv4Reservation / Remove-DhcpServerv4Reservation.
apply_exclusion / remove_exclusion Add-DhcpServerv4ExclusionRange / Remove-DhcpServerv4ExclusionRange.
render_config Raises NotImplementedError. Windows DHCP is cmdlet-driven, not config-file-driven.
apply_config / reload / restart / validate_config Raise NotImplementedError.

The /sync endpoint (bundle push) rejects read-only drivers; the /{server_id}/sync-leases endpoint and per-object CRUD drive all writes instead.

Credentials

Stored on DHCPServer.credentials_encrypted as a Fernet-encrypted JSON dict:

{
  "username": "CORP\\spatium-dhcp",
  "password": "…",
  "winrm_port": 5985,
  "transport": "ntlm",
  "use_tls": false,
  "verify_tls": false
}

The service account needs to be in the Windows DHCP Users local group (read-only) or DHCP Administrators (for per-object writes). See WINDOWS.md for the account setup and WinRM configuration.

PowerShell calls

The driver shells out to pre-built PowerShell strings with $ErrorActionPreference = 'Stop' and ConvertTo-Json -Compress -Depth 3 for machine-readable output. An example — the lease pull:

$scopes = Get-DhcpServerv4Scope | Where-Object { $_.State -eq 'Active' }
$all = @()
foreach ($s in $scopes) {
    $leases = Get-DhcpServerv4Lease -ScopeId $s.ScopeId -AllLeases
    foreach ($l in $leases) {
        $all += [PSCustomObject]@{
            ScopeId       = $s.ScopeId.ToString()
            IPAddress     = $l.IPAddress.ToString()
            ClientId      = $l.ClientId
            HostName      = $l.HostName
            AddressState  = $l.AddressState
            LeaseExpiryTime = if ($l.LeaseExpiryTime) { $l.LeaseExpiryTime.ToString('o') } else { $null }
        }
    }
}
$all | ConvertTo-Json -Compress -Depth 3

WinRM transport is pywinrm (winrm.Session), wrapped in asyncio.to_thread because pywinrm is synchronous. Transport, port, and TLS options come from the credential dict.

Lease → IPAM mirror

Leases drive a scheduled Celery beat task (app.tasks.dhcp_pull_leases.auto_pull_dhcp_leases). Beat fires every 60s; the task gates on PlatformSettings.dhcp_pull_leases_enabled / dhcp_pull_leases_interval_seconds, so the UI can change cadence without restarting beat.

Per poll cycle:

  1. Enumerate agentless DHCP servers.
  2. For each, call driver.get_leases(server).
  3. Upsert into DHCPLease by (server_id, ip_address).
  4. If the lease’s IP falls inside a known subnet, mirror it into IPAddress with status="dhcp" and auto_from_lease=True.
  5. Absence-delete — any active DHCPLease row for this server whose IP didn’t come back in the wire response is deleted along with its auto_from_lease=True IPAM mirror. The driver’s get_leases() returns only currently-active leases, so absence means the server deleted it (admin purged / client released / etc.). PullLeasesResult gains removed + ipam_revoked counters; both flow into the scheduled-task audit row and the manual sync response. See features/DHCP.md §15.3 for the rationale.
  6. The time-based dhcp_lease_cleanup sweep still handles leases that drift past expires_at between polls. The two mechanisms overlap harmlessly.

Scope reconcile (topology)

The same beat task runs a topology phase ahead of the lease phase, for any driver that implements get_scopes — which today means Windows DHCP alone. For each wire scope whose CIDR exactly matches an IPAM Subnet.network (no auto-create), the scope row is upserted and its pools + reservations are diff-merged against the wire. Reservations are keyed on MAC, pools on (start, end); a MAC / range that is already tracked is updated in place, so a reservation keeps its row id across polls.

That id stability is load-bearing (#620). A reservation owns an ip_address mirror that back-links to it by id, so anything that re-creates the row (the pre-#620 reconciler Core-DELETEd and re-inserted every reservation, every poll) leaves the mirror pointing at a row Postgres has dropped — an address that is neither allocated nor free, and that deleting the reservation in the UI never frees, because the lookup keys on the current id. Merging also means an unchanged reservation is not written at all, which is what keeps the reconciler from re-publishing its DNS records on every tick.

Reservations found on the server are mirrored into IPAM exactly like UI-created ones (status="static_dhcp"), via the same upsert_ipam_for_static helper — a reservation is an allocation whoever made it, and an IPAM that doesn’t show it is an IPAM that will hand the address out twice. The mirror upsert (and therefore the DNS re-sync it performs) fires only when the reservation’s address or hostname actually moved, or when its mirror is missing or stale.

Three things the merge deliberately refuses to do:

PullLeasesResult reports scopes_imported / scopes_refreshed / scopes_skipped_no_subnet plus pools_synced / statics_synced (created or changed) and pools_removed / statics_removed. Because the merge is a diff, a steady-state poll reports zeros.

The orphaned-mirror backstop

A reservation owns its ip_address mirror by id, and every path that destroys a reservation is responsible for releasing that mirror first. When one doesn’t, the result is nasty out of proportion to the mistake: the address sits at status="static_dhcp" pointing at a row Postgres has dropped, so it is neither allocated nor free, nothing hands it out, and no amount of clicking in the UI frees it — every release path looks the mirror up by the reservation’s current id and matches nothing. It took a one-shot repair migration (d7b3f2a9c15e) to clear the last batch.

So there is a recurring backstop: app.tasks.dhcp_mirror_sweep.sweep_orphaned_mirrors, hourly, which is that migration’s step 1 made permanent. It frees any static_dhcp row whose non-NULL back-link resolves to no live reservation, deleting the row (a freed-but-persisted row still renders in the subnet table and still counts toward utilization) after tearing down its DNS. If the reservation is merely soft-deleted, the operator-authored columns are snapshotted onto it first so a Trash restore stays lossless.

The predicate is deliberately narrow: a static_dhcp row with a NULL back-link is left alone, because an operator can set that status by hand and a sweep that deletes hand-made rows is worse than the bug it fixes. On a healthy install it matches nothing and writes nothing — so a non-zero mirrors_freed (logged at WARNING, and audited) is itself the signal that some path is still skipping its mirror release.

Batched WinRM writes

Per-object writes against Windows DHCP used to round-trip WinRM per reservation / exclusion — a bulk delete of 200 reservations took minutes. The driver now groups writes into a single PowerShell script per (server, scope) chunk.

Driver surface. New plural methods on the ABC:

class DHCPDriver(ABC):
    async def apply_reservations(
        self, server: Any, *, items: Sequence[ReservationItem]
    ) -> list[ReservationResult]: ...

    async def remove_reservations(
        self, server: Any, *, items: Sequence[RemoveReservationItem]
    ) -> list[ReservationResult]: ...

    async def apply_exclusions(
        self, server: Any, *, items: Sequence[ExclusionItem]
    ) -> list[ExclusionResult]: ...

Default ABC impls call the singular method in a loop — Kea inherits the plural interface without changes.

Windows batch size — 30 ops per chunk. pywinrm.run_ps ships the script as a single CMD.EXE command line (8191-char cap, see DNS_DRIVERS.md §3.7 for the full math). DHCP payloads are leaner than DNS — each reservation / exclusion op is ~60 raw chars of JSON vs. DNS’s ~160 — so the cmdline limit is farther away, but capped at 30 to stay comfortably inside it. Documented in _WINRM_BATCH_SIZE in drivers/dhcp/windows.py.

Dispatcher. push_statics_bulk_delete groups by (server, scope) so the IPAM purge-orphans path went from N×M WinRM calls to one per server. Same state-aware commit pattern as DNS — only state=applied ops delete the DB row.


5. FortiGate driver (agentless + cloud push)

fortigate.py is the first cloud agentless DHCP driver: the control plane drives a FortiGate’s per-interface DHCP server directly over the FortiOS REST API with an API-admin Bearer token, VDOM-scoped, no co-located agent. It subclasses AgentlessDHCPDriverBase (the shared cloud base) rather than rendering a daemon config.

Model mapping

One SpatiumDDI DHCPServer(driver="fortigate") = one FortiGate device + VDOM. A DHCPScope (one subnet) maps to the system.dhcp.server object on the interface whose primary IP+netmask CIDR equals the scope CIDR — SpatiumDDI never creates interfaces or changes interface IPs; no match / multiple matches raise a clear error. Dynamic pools → ip-range; excluded/reserved pools → exclude-range (clipped to the dynamic range — FortiGate rejects an exclude outside the ip-range); statics → reserved-address; scope options → first-class fields (default-gateway / dns-server1..4 / domain / ntp-server1..3 / filename / lease-time) or the generic options subtable. Numeric options (mtu, time-offset) are emitted as type: "hex" big-endian — a string MTU would reach the client as ASCII, not a 16-bit integer.

Write unit

The whole DHCP-server object per scope: any scope / pool / static / option edit rebuilds the full desired object from the DB and PUTs it (create-if-absent). The cloud write-through (services/dhcp/cloud_writethrough.py) runs synchronously, before commit, so a REST failure raises CloudPushError (502) and rolls the transaction back — keeping the DB and the FortiGate in sync. push_cloud_scope_upsert fans a scope out to every cloud member of its group; cascade / group / restore paths reach it through the shared windows_writethrough seam.

Ownership + adopt-guard (#630)

So a push never silently overwrites or deletes a DHCP server an operator hand-managed on the FortiGate, the control plane records the FortiOS mkey of the object SpatiumDDI created on the scope (DHCPScope.provider_refs, keyed per cloud server) and passes it back as provider_ref:

_remove_scope only deletes an object whose mkey we recorded. The GET /{id}/fortigate-interfaces preflight surfaces any pre-existing DHCP server (ip-range / reservation / option counts + a managed flag) so the clobber risk is visible before an adopt-and-sync.

Credentials + TLS

Fernet-encrypted on DHCPServer.credentials_encrypted: {"api_token", "vdom", "verify_tls", "ca_bundle_pem"}. verify_tls defaults to True (the admin Bearer token is sensitive) with an optional ca_bundle_pem to pin a private-CA FortiGate; disabling verification logs a WARNING. The API echoes the non-secret vdom + verify_tls on the server response so the edit modal seeds the checkbox from the stored value instead of silently re-disabling it. API-created cloud servers default to port 443 when the caller omits port.

Endpoints

POST /dhcp/servers/test-fortigate-credentials (dry-run probe), GET /dhcp/servers/{id}/fortigate-interfaces (preflight), POST /dhcp/servers/{id}/sync?adopt_existing= (synchronous full reconcile). All three call assert_safe_target (advisory SSRF guard) before dialing the host.


6. Error handling

All driver methods:

For WinRM drivers, pywinrm errors get caught and re-raised as DriverConnectionError with the PowerShell std_err in the message — the API surfaces this verbatim in the 502 response so the UI “Test Connection” button shows the real Windows error.


7. Adding a new driver

  1. Subclass DHCPDriver. Implement all abstract methods. If read-only, raise NotImplementedError on writes.
  2. Register in app/drivers/dhcp/registry.py:
    _DRIVERS["my_driver"] = MyDriverClass
    
  3. If agentless, add to AGENTLESS_DRIVERS. If read-only, add to READ_ONLY_DRIVERS.
  4. Add the driver name to the enum in DHCPServer.driver (Alembic migration).
  5. Update the UI’s server create modal to render the right credential fields (see how windows_dhcp conditionally shows WinRM fields in frontend/src/pages/dhcp/CreateServerModal.tsx).
  6. Add a “Test Connection” PowerShell / API probe (e.g. POST /dhcp/servers/test-windows-credentials) so operators can validate before saving.

8. Importing existing daemon configs (issue #129)

The DHCP configuration importer is separate from the driver abstraction: drivers render + push config to managed servers; the importer reads a foreign daemon’s config one-shot and writes the canonical SpatiumDDI rows. It never touches a live daemon’s config file. Code lives in backend/app/services/dhcp_import/.

Canonical IR (canonical.py) — every source parses into the same neutral shapes: ImportedScope (CIDR, address family, lease times, options, DDNS), ImportedPool, ImportedReservation, ImportedClientClass, rolled into an ImportPreview. The shared commit.py is the only module that touches the DB, so all three sources share IPAM linkage, conflict handling, audit logging, and the per-scope savepoint pattern.

Parsers:

IPAM linkageDHCPScope.subnet_id is mandatory, so each commit resolves a Subnet: link to an existing one whose CIDR matches, or auto-create under the operator-chosen IP space + block (containment + non-overlap validated). Link-only mode (no space/block) reports an actionable per-scope error for unmatched CIDRs rather than failing the whole batch.

Provenanceimport_source (kea / windows_dhcp / isc_dhcp)

See Migration for the operator-facing flow and the DNS-importer sibling.



Kea 3.0 — HA has no rolling-upgrade path (#637)

Kea 3.0’s HA hook is wire-incompatible with peers older than 2.7.0. 3.0 introduced the “released” lease state (value 3) into the lease updates that HA partners exchange, and an older peer rejects them outright.

There is therefore no rolling 2.6 → 3.0 upgrade for an HA pair — both members must cross in the same window. This is upstream’s guidance, not a SpatiumDDI limitation, and there is nothing the orchestrator can do about it.

That collides directly with the #296 rolling-upgrade orchestrator, whose whole primitive is one node at a time: cordon → drain → swap slot → reboot → uncordon → next. Midway through such a run, one Kea is on 3.0 and its partner is still on 2.6, and the pair falls out of sync until the second node lands.

Two things make this visible rather than a surprise:

  1. dhcp_server.kea_version — the agent reads the running daemon’s version off the control socket (version-get) and reports it on every heartbeat. This is the Kea daemon version, distinct from agent_version (the Python agent). NULL means “not reported yet” and must be treated as unknown, never as “old”.
  2. The kea_ha_version_skew preflight check — runs as part of the rolling-upgrade preflight and classifies the fleet:

    Condition Level Why
    Members of one HA group already on different Kea majors warn HA lease sync is broken now — and finishing the upgrade is what converges them.
    An HA group is on pre-3.0 Kea warn This run will cross the boundary node-at-a-time; HA degrades until it completes.
    A member has never reported a version warn Unknown, so we can’t rule out the crossing.
    Every member already on Kea ≥ 3 ok Nothing to do — and the check self-clears, so it isn’t a permanent nag.

Two pieces of scoping keep the check honest, and getting either wrong turns it into something that blocks unrelated work:

The check never returns fail. A fail sets can_start=False, and blocking would be exactly backwards: an already-mixed pair has broken HA right now, and completing the rolling upgrade is what gets both members onto one version. Refusing to start would strand the operator in the broken state. DHCP also keeps serving throughout (each Kea answers from its own lease store) — only the HA replication between peers is disrupted.