Skip to content

Reconciling a UniFi site against NetBox in one prompt

A source of truth is only true until someone touches the network. Swap an access point, let DHCP hand out a new management address, add a unit in a back room, and the controller is immediately right while NetBox is quietly wrong. Nobody catches that by hand-diffing ten devices every week, so the documentation drifts and the trust in it drifts with it.

The usual answer is a sync script. We wanted to give one instruction instead:

No pipeline. No field mapping spelled out. When the network changes next month, the same sentence runs again.

The agent talks to two backends through one gateway: it reads the live UniFi controller and reads and writes NetBox, and every call goes through the gateway’s audited, policy-gated surface rather than a shell or a stored admin token. In Code Mode it is a short script the model writes itself, and its shape is a comparison, not a bulk import:

const site = "23by5njv"; // desc: "HOME"
const live = await toolmesh.unifi_list_devices({ site });
for (const ap of live) {
const model = MODEL_MAP[ap.model] ?? ap.model; // U7PG2 -> "UniFi AC Pro Gen2", etc.
const [nb] = await toolmesh.netbox_list_devices({ name: ap.name });
if (!nb) { // new device -> create
await toolmesh.netbox_create_device({ name: ap.name, device_type: typeId(model), role: AP, site: LII });
log("created", ap.name);
} else if (mgmtIp(nb) !== ap.ip) { // moved address -> update one field
await toolmesh.netbox_update_device(nb.id, { primary_ip4: ipId(ap.ip) });
log("updated mgmt IP", ap.name, mgmtIp(nb), "->", ap.ip);
} else {
log("ok", ap.name); // already matches -> write nothing
}
}

Two things in there are worth pulling out, because they are where reconciliation is harder than import.

The model codes are not product names. To decide whether a UniFi device already matches its NetBox record, the comparison has to normalize what the controller reports. UniFi returns each device as an internal code: U7PG2 is the UAP-AC-Pro (the 7 has nothing to do with Wi-Fi 7), UAL6 is a U6 Lite, U7NHD a nanoHD, U6EXT a U6 Extender, U7PRO an actual U7 Pro, and US8P60 the US-8-60W switch. That translation table is the difference between “this AP is already correct” and a duplicate device created next to the real one.

“Leave correct records alone” is the load-bearing clause. Every branch above is a get-or-create-or-update. A matching device produces a write of nothing. That is what makes the sentence safe to run on a schedule against a NetBox that already has data, instead of a one-time import you never dare repeat.

We ran it against the live controller. Here is the real result, not a constructed one:

reconcile HOME -> NetBox (site LII)
AP-Kueche U7NHD 192.168.100.43 ok
AP-Praxis U7NHD 192.168.100.46 ok
AP-Labor U7PG2 192.168.100.41 ok
AP-Buero U7NHD 192.168.100.44 ok
AP-Garage U7PG2 192.168.100.42 ok
AP-U7-Pro Heizungsraum U7PRO 192.168.100.154 ok
AP-U6-Extender U6EXT 192.168.100.199 ok
AP-Mobil UAL6 192.168.100.47 ok
AP-Wohnzimmer U7PG2 192.168.100.45 ok
UbSwi-01 US8P60 192.168.100.183 ok
-- 10 checked, 10 matched, 0 written.

Nine access points across five models and one PoE switch, every device already present, every management IP still matching, the IoT VLAN and the management prefix already in place. Net change to NetBox: zero.

That is the anticlimax that is actually the point. An agent that writes nothing when nothing has drifted is precisely the property that lets you point it at a CMDB you care about and run it again and again. You do not have to trust it not to clobber your data. You can watch it do nothing.

Drift is the case that earns its keep. If AP-Garage had come up on .142 instead of .42, the run would have printed one line, updated mgmt IP AP-Garage 192.168.100.42 -> 192.168.100.142, written that single record, and left the other nine reading ok. The audit log shows exactly which field changed, on which device, run by which account, with no diff to read through.

Reconciling the devices keeps the inventory honest. The device-type definitions behind them do not get checked by a name-and-IP pass, and they drift in their own way. This run was a good moment to catch one: the U7 Pro in the boiler room was linked, correctly, to a device type whose slug matched, but that type was still labelled “AC Pro, 802.11ac”. The device matched, so the reconciliation would not flag it; the label was simply wrong, since the model code and the unit’s Wi-Fi 7 firmware say otherwise. We corrected it in the same session. A source of truth is only as good as the definitions it points at, and those deserve a deliberate look rather than an assumption.

”I have done this with Nornir for years”

Section titled “”I have done this with Nornir for years””

You have, and it works. The difference is that there is no script here to maintain. The mapping table lives in the model’s context, the instruction is plain language, and “also reconcile the client list” or “skip the guest SSID” is a change to the sentence, not to a codebase and its tests. When Ubiquiti renames a model code in a controller update, you fix one line of a prompt.

The UniFi and NetBox connectors are open: DADL, one YAML file per API, behind an Apache-2.0 gateway. Clone it and point it at your own controller and your own NetBox. If you would rather have it run as a managed instance, talk to us at [email protected]. Questions and war stories are welcome in GitHub Discussions.