3.1 How SpatiumDDI works
When a record doesn’t resolve, or a server shows a rejected config, you need to know where the change is and who is supposed to act on it next. This module gives you that model: one control plane that holds the truth, DNS and DHCP servers that are projections of it, and the few mechanisms in between. Every later decision in Part 3 (engines, topologies, HA, hardening, migration) builds on it.
What you’ll learn
Section titled “What you’ll learn”- Name the control-plane components and say what each one does
- Tell the data-plane shapes apart, including the one exception to “read-only”
- Walk an agent through its lifecycle, from bootstrap key to heartbeat
- Trace one record change from the API to a
diganswer - Explain what keeps serving when the control plane is unreachable
One source of truth, many projections
Section titled “One source of truth, many projections”Three rules shape the whole design, and the project treats them as non-negotiable:
- API-first. Every UI action is a REST call under
/api/v1. The browser, your scripts and the Copilot’s MCP endpoint share one surface, and the API checks permissions itself. - The control plane is the source of truth. PostgreSQL holds the desired state. SpatiumDDI renders config for the DNS and DHCP daemons and runs them; the servers hold no authoritative state of their own.
- Agents survive a control-plane outage. Every DNS and DHCP agent caches its last-known-good config on disk and keeps serving if the control plane is unreachable.
The control plane
Section titled “The control plane”In Docker Compose the control plane is seven services. Kubernetes and the OS appliance run the same set from the Helm chart.
| Component | What it does | Scaling |
|---|---|---|
api |
FastAPI app: the REST API and its OpenAPI docs (/api/docs), sign-in and RBAC, the agent register, heartbeat and long-poll endpoints, and the Copilot’s MCP endpoint |
Stateless; run several |
worker |
Celery workers for anything that mustn’t block a request: rendering each DNS server’s config bundle, IPAM↔DNS sync, health checks, lease sweeps, integration syncs, alert evaluation. Tasks are idempotent and safe to retry | Several, by queue (ipam, dns, dhcp, default, bundles); a custom queue list must keep bundles |
beat |
The Celery scheduler. It only enqueues periodic tasks | Exactly one; two would double-enqueue |
frontend |
nginx serving the React UI and proxying /api |
Stateless |
migrate |
A one-shot alembic upgrade head. On Compose the api and worker wait for it to finish |
Once per upgrade |
| PostgreSQL 16 | The source of truth: IPAM, DNS, DHCP, auth, audit, settings | HA with CloudNativePG (Helm chart, OS appliance); no supported Compose HA in 1.0 |
| Redis | Celery broker, cache and sessions, and the agent-wake bus | HA with Sentinel |
Treat PostgreSQL and Redis as internal only: never publish them to the host or the network. The api’s readiness probe, /health/ready, compares the schema version its image expects with the database, so a new api pod stays out of service until the schema reaches that version. During a rolling upgrade, a pod that was already serving keeps serving a newer schema only while every table and column it uses still exists (#2777).
The data plane: four shapes and one exception
Section titled “The data plane: four shapes and one exception”You can mix these freely in one install. Behind all of them, the services talk only to a neutral driver interface (DNSDriver, DHCPDriver), never to BIND9, Kea or PowerShell directly.
| Shape | Members | How SpatiumDDI reaches them |
|---|---|---|
| Agented, on-prem | BIND9, PowerDNS, Technitium and Kea, shipped as the dns-bind9, dns-powerdns, dns-technitium and dhcp-kea images with the agent baked in |
The agent calls out to the api; nothing calls in |
| Agentless, on-prem | Windows DNS, Windows DHCP, and an existing Technitium server (technitium_api, experimental in 1.0) |
The api dials out: RFC 2136 on port 53, WinRM on 5985/5986, or Technitium’s HTTP API |
| Agentless, cloud DNS | Cloudflare, Route 53, Azure DNS, Google Cloud DNS, DigitalOcean, Hetzner, Linode, Vultr | The api calls the provider’s API |
| Read-only integration mirrors | Kubernetes, Docker, Proxmox VE, AWS/Azure/GCP, Tailscale, NetBird, UniFi, OPNsense, Palo Alto, Fortinet, Meraki | Scheduled pulls into IPAM, never written back |
FortiGate DHCP is also agentless: the api pushes whole scopes over the FortiOS REST API.
The exception to “never written back” is active block sync: an opt-in, off-by-default path that pushes a SpatiumDDI-owned block set to an OPNsense alias, a UniFi client, a Palo Alto dynamic address group or a Meraki client policy, or serves it as a feed the firewall polls. It needs the manage_block_sync permission, and the Palo Alto and Meraki targets also need manage_firewall_enforcement, which no built-in role carries.
On the OS appliance, a host-side spatium-supervisor looks after the machine itself: pairing, role assignment, A/B upgrades, firewall and host settings. The DNS and DHCP containers only do service work.
The agent lifecycle
Section titled “The agent lifecycle”DNS and DHCP agents follow the same lifecycle. Here it is for a DNS agent such as Ridgeline’s dns-bind9.
- Bootstrap with a pre-shared key. The agent starts with
CONTROL_PLANE_URLandDNS_AGENT_KEY(the DHCP agent usesSPATIUM_API_URLandSPATIUM_AGENT_KEY, the key checked against the control plane’sDHCP_AGENT_KEY). It keeps a stable agent ID on disk and callsPOST /api/v1/dns/agents/register. On the OS appliance you never paste a key: the supervisor pairs with an 8-digit code and hands the per-role keys to the containers. - Receive a JWT. The control plane creates or updates the server row and returns an agent token: a JWT signed with
SECRET_KEY, valid for 24 hours, rotated through the heartbeat and scoped to that one server. On a 401 or 404 the agent drops the token and bootstraps again, so a server row deleted by mistake reappears within a heartbeat. - Long-poll for config. The agent holds
GET /dns/agents/configopen with its last ETag. The control plane answers at once when the ETag differs or ops are waiting, and with304when the poll times out. For DNS the bundle isn’t built in that request: the worker renders each server’s bundle once per change and stores it, and the poll streams the stored bundle with the server’s pending ops (#1111). DHCP bundles are still built per request. A Redis wake, published when a render lands or a DHCP change commits, makes changes land in well under a second. - Keep a last-known-good copy. The cache in
/var/lib/spatium-dns-agent/config/holds the bundle last fetched (current.json) and the last one that applied cleanly (previous.json). On boot the agent applies its cache before it contacts the control plane. The one exception is a bundle the daemon died on: then the agent asks the control plane first, and serves the cache only if the control plane is unreachable or sends no bundle within 10 minutes (AGENT_BOOT_HOLD_MAX_WAIT_S). - Revert and quarantine a bad bundle. Applying is phased: render, validate, then swap and reload. BIND9 validates with
named-checkconfandnamed-checkzonein a staging directory, then confirms each changed zone’s new serial after the reload. Kea’s config is checked withkea-dhcp4 -tin a separate process, so a rejected config never reaches the running daemon. If an apply fails, the agent reverts toprevious.json, quarantines the failed ETag, retries on a 60 s → 5 min → 15 min ladder, and reports it: a chip on the server row, a banner on the server, and theagent_config_rejectedalert. - Apply record changes per server, over loopback. Every enabled agent-based server in a group gets its own queued op. BIND9 applies it with TSIG-signed
nsupdateagainst its ownnamed; PowerDNS and Technitium use their REST APIs on127.0.0.1. In a group without views, record changes never reload the daemon; in a group with views, a record change re-renders the zone instead. Structural changes (zones, views, ACLs, options) take the render, validate and reload path. - Heartbeat. Every 30 seconds (±3 s) the agent reports its daemon’s status and acknowledges applied ops. A beat job runs every 60 seconds and marks a server
unreachableafter three missed heartbeats (90 s).
Trace a record change end to end
Section titled “Trace a record change end to end”A Ridgeline operator allocates print-01 at 10.20.21.10 in Staff VLAN 21, with the zone corp.example.edu. Here’s the path that change takes.
- The api commits. It checks the operator’s permissions, writes the address and its two records (
print-01.corp.example.eduA10.20.21.10, and the PTR10in21.20.10.in-addr.arpa, the reverse zone created when the subnet was saved), and writes the audit row in the same transaction. The response returns once the write is durable in PostgreSQL. - Ops are queued. One
DNSRecordOpis written per enabled agent-based server in thedefaultgroup (at Ridgeline that’s onlydns-bind9), and the same transaction marks that server’s bundle dirty. The op carries the zone serial the control plane has bumped (YYYYMMDDNN). - The worker renders. After the commit, a render of
dns-bind9’s bundle is queued on the worker’sbundlesqueue. The worker renders and stores it, then publishes a wake on Redis. - The agent wakes.
dns-bind9’s waiting long-poll returns with the stored bundle and the pending ops that render covers. An op committed after the render read the database rides with the next render. - No reload. A record change doesn’t move the bundle’s structural ETag, so the agent skips render-and-reload and only runs the ops.
nsupdateover loopback. The agent sends a TSIG-signed RFC 2136 update to its ownnamedon127.0.0.1. The TSIG key never leaves the container.- Acknowledge. The next heartbeat acknowledges the op and it becomes
applied. A failed apply is retried with a growing backoff, from 30 seconds up to 15 minutes. After 8 attempts, about 45 minutes, the op becomesfailedand the default-ondns_record_op_failedalert (“DNS record change not applied”) fires. The server’s Sync tab lists each op with its state and attempts.
Check it from the lab host:
dig @127.0.0.1 -p 1053 print-01.corp.example.edu +shortdig @127.0.0.1 -p 1053 -x 10.20.21.10 +shortIf a second server joined default, it would get its own op and apply it independently. If that server were offline, its op would wait as pending and drain when it came back. Agentless servers (Windows DNS, technitium_api and the cloud drivers) have no agent: the op row is written pending in the same transaction, the control plane calls the server right after the commit, and a refused write is retried with backoff (#1539).
What keeps working when the control plane is down
Section titled “What keeps working when the control plane is down”| Keeps working | Stops until the control plane is back |
|---|---|
| DNS answers from every agent-managed server | The UI and the API |
| DHCP leases, including failover inside a Kea HA pair | Every change: records, zones, scopes, reservations |
| Windows and cloud DNS, which never needed SpatiumDDI to answer | Scheduled jobs: syncs, health checks, alerts, integration pulls |
| Agent restarts, which come up warm from cache | Fresh lease, query and metric data in IPAM, logs and dashboards |
That last item arrives late rather than never. DNS and DHCP agents spool query logs, metrics, DHCP activity and Kea lease events to disk, and replay them in order, with their original timestamps, when the control plane answers again (#1077). The spool holds up to 256 MiB per agent, and query-log batches older than 24 hours are dropped. The server list shows Replaying … backlog while a backlog drains and Spool trimmed if the cap forced a drop.
Every change is audited in the same transaction
Section titled “Every change is audited in the same transaction”Each mutation writes an append-only audit row in the same database transaction as the change, before the response returns. An audit gap and a rolled-back write are therefore the same event: you can’t get one without the other. Three more guarantees sit on top:
- The log is hash-chained: each row stores the previous row’s hash. A nightly job verifies the rows added since its last run and re-walks the whole chain about once a week, so an edit to an old row can take about eight days to surface. A break raises the critical
audit-chain-brokenalert, and the Audit Log page shows Chain verified or Tampering detected. - Database triggers refuse
DELETEandUPDATEon the table, even for superadmins. - Forwarding to a SIEM happens after the commit and is best-effort, so a dead collector never rolls back a change.
The data model at a glance
Section titled “The data model at a glance”- IPAM is a strict tree: IP space, then blocks (which can nest), then subnets, then addresses. Deleting a space or block moves everything under it (blocks, subnets and their DHCP scopes) to Trash as one batch, and one restore brings it all back.
- DNS and DHCP are group-centric. Zones, views and options live on the DNS server group; scopes, pools, reservations and client classes live on the DHCP server group. Servers are members that render the group’s config, which is why a DNS group runs a single engine. DHCP leases are per server, because each Kea keeps its own lease file.
- Settings inherit. Unless you override them, a subnet takes its DNS and DHCP group bindings and DDNS settings from the block and space above it. At Ridgeline, the
defaultgroups andcorp.example.eduare pinned on10.20.0.0/16and flow down to Staff VLAN 21, which is also why saving the subnet created its reverse zone,21.20.10.in-addr.arpa, on its own. - Deletes are soft. Spaces, blocks, subnets, zones, records and scopes go to Trash for 30 days, and a cascade comes back in one restore. A single deleted IP address is the exception: it becomes an
orphanrow instead.
Knowledge check
Section titled “Knowledge check”Go deeper
Section titled “Go deeper”- System architecture: the control plane, config delivery and the HA design
- DNS agent and container architecture: bootstrap, stored bundles, the config cache, revert and quarantine, the outage spool, per-server fan-out
- DNS driver specification: how each engine applies changes
- Data model: the schema, grouped by domain
- Observability: the audit log, forwarding and health endpoints