Synchronize TP-Link omada wifi controller objects/connections/... to netbox.
  • Python 94.2%
  • HTML 5%
  • Nix 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Oluf Lorenzen 8aaa273258
Stop the reconcile missing-note from stacking past the description column
When reconcile marked an object missing it appended a
'[tag] last_seen=... present_in_omada=false' note to the object's description,
which is varchar(200) on the models it writes. The matcher anchored the note to
the START of the string while the note is appended at the END, so an existing
note was never found: every reconcile run stacked a fresh ~80-char line, and
client IPs already carrying one note grew past 200 on the next marking,
killing the whole run with 'value too long for type character varying(200)'
(live: run 62).

The matcher now finds the note anywhere (the leading newline separator is left
outside the match so a replace keeps it), the note is truncated to the column
limit as a safety net, and _persist treats a too-long value as a per-object
conflict instead of an unhandled run-killer. Marking a device missing no
longer writes a description note it cannot store (dcim.Device has no such
field).
2026-10-08 13:58:16 +02:00
docs Make dry-run the default sync mode 2026-10-02 15:10:29 +02:00
LICENSES from-copier 2026-09-26 13:47:20 +02:00
netbox_sync_omada Stop the reconcile missing-note from stacking past the description column 2026-10-08 13:58:16 +02:00
nix Make dry-run the default sync mode 2026-10-02 15:10:29 +02:00
tests Stop the reconcile missing-note from stacking past the description column 2026-10-08 13:58:16 +02:00
.copier-answers.yml from-copier 2026-09-26 13:47:20 +02:00
.editorconfig from-copier 2026-09-26 13:47:20 +02:00
.gitignore Ignore the .worklist tool state 2026-09-28 16:18:52 +02:00
AGENTS.md Note the immutable NixOS deployment and keep tooling quiet about .tmp 2026-10-07 10:50:23 +02:00
CHANGELOG.md Stop the reconcile missing-note from stacking past the description column 2026-10-08 13:58:16 +02:00
COMPATIBILITY.md Add Nix packaging, compatibility matrix and packaging docs 2026-09-27 10:03:21 +02:00
flake.lock Add Nix flake exposing the plugin, a NixOS module and a dev shell 2026-09-27 17:57:03 +02:00
flake.nix Add Nix flake exposing the plugin, a NixOS module and a dev shell 2026-09-27 17:57:03 +02:00
Makefile from-copier 2026-09-26 13:47:20 +02:00
mise.toml Add plugin foundation, Omada Open API client and sync engine 2026-09-26 22:53:33 +02:00
pyproject.toml Note the immutable NixOS deployment and keep tooling quiet about .tmp 2026-10-07 10:50:23 +02:00
pyrightconfig.json Add NetBox-native UI layer: views, tables, forms, filtersets, templates 2026-09-27 09:54:30 +02:00
README.md Adopt existing NetBox devices from the endpoint edit form 2026-10-07 13:48:53 +02:00
REUSE.toml Adopt existing NetBox devices from the endpoint edit form 2026-10-07 13:48:53 +02:00
TODO.md Note the immutable NixOS deployment and keep tooling quiet about .tmp 2026-10-07 10:50:23 +02:00
uv.lock Drop imports and tooling exemptions the newer Ruff flags 2026-09-30 20:45:30 +02:00

Netbox sync from Omada Controller

A Netbox plugin to sync devices, interfaces, IP addresses, VLANs, prefixes, wireless LANs and clients from a TP-Link Omada controller.

➡️ Quick Links: Repository · Issues

🌱 Status

Netbox sync from Omada Controller is pretty new and still under development. It is alpha software: try it against a preview and a throwaway NetBox first.

What works today: the Omada Open API client (read-only), the sync engine with an external-identity ledger, all eight feature blocks, reconciliation including grace-gated optional deletion, the full web UI (preview/apply, tabs, settings) and the background jobs with scheduling.

Not there yet: the REST API and an automated test suite — see CHANGELOG.md and COMPATIBILITY.md.

📚 Documentation

NetBox support: 4.6.0 – 4.7.99 (the plugin deliberately avoids 4.7-only APIs and symbol changes). Python support: 3.12 – 3.14.

🗃️ Installation

The plugin must be installed into the same Python environment as NetBox, not into a separate virtualenv, and NetBox's version must match the supported range above.

From PyPI

source /opt/netbox/venv/bin/activate
pip install netbox-sync-omada

Or, as a dependency of a uv-managed NetBox deployment:

uv add netbox-sync-omada

Enable the plugin

In your NetBox configuration.py:

PLUGINS = ["netbox_sync_omada"]

PLUGINS_CONFIG = {
    "netbox_sync_omada": {
        # Optional — every setting has a default, see "Settings" below.
        # Dry-run by default: preview collects and compares but never writes.
        "default_sync_mode": "preview",
        "default_sync_tag": "synced-omada",
        "default_sync_interval_minutes": None,
        "mark_missing_devices": "offline",
        "allow_delete_missing": False,
        "delete_grace_seconds": 2_592_000,
        "history_keep_days": 90,
        "history_keep_count": 1000,
    },
}

Then migrate, collect static files if your deployment requires it, and restart both the NetBox web process and the rqworker process:

python manage.py migrate
python manage.py collectstatic --no-input
systemctl restart netbox netbox-rq

With Nix / NixOS

This repository is a flake exposing the package, a NixOS module and a development shell:

inputs.netbox-sync-omada.url = "git+https://git.h.oluflorenzen.de/finkregh/netbox-sync-omada.git";

# in a NixOS configuration
services.netboxSyncOmada = {
  enable = true;
  settings = { default_sync_tag = "synced-omada"; };
};

See docs/nix.md for the module options, the local-checkout variant and how to fill in the packaging hash at release time.

🚀 Usage

1. Provide the Open API credentials

The database never stores a secret. An endpoint stores only the name of an environment variable (kind env, the default) or the path of a file (kind file, optionally written as file:/run/secrets/omada-client-secret) that holds the value. The value is resolved on demand, used for one sync run, and never persisted, logged or exposed through the UI.

Create the credentials in the Omada controller (Settings → Controller → Open API) first, then make them available to NetBox:

# Environment variables for both the web process and the worker.
export OMADA_CLIENT_ID="<open-api-client-id>"
export OMADA_CLIENT_SECRET="<open-api-client-secret>"

For a systemd deployment put them in an EnvironmentFile= (mode 0600) on both netbox.service and netbox-rq.service; a file reference works the same way but is easier to rotate. The web process needs the credentials too, because the Detect, prefill and preview actions run there.

2. Create the endpoint

Omada Sync → Endpoints → Add:

Field Notes
Base URL Controller root, e.g. https://omada.example.internal:8043, or your regional Omada Pro cloud controller.
Controller id omadacId — not a secret. Use Detect to read it from GET /api/info, or type it in.
Credential refs The env-var names or file: paths from step 1, plus the matching reference kind.
Verify SSL / CA file Self-hosted controllers usually ship a self-signed certificate; either upload a CA bundle or turn verification off deliberately.
Sync tag Ownership boundary. Objects carrying this tag belong to the endpoint; objects without it are never modified or removed.
Site / VLAN group Defaults for objects that are not bound to a site mapping. The plugin never creates sites or VLAN groups.
Feature toggles Devices, interfaces/switch ports, management IPs, VLANs, prefixes, wireless LANs, cables; clients are off by default (they churn).

3. Preview and apply

Preview enqueues a read-only preview run in the background (the controller is polled once, on an RQ worker, never on the web request thread — so a large or slow controller cannot stall a web worker) and shows a page that refreshes until the run is ready, with the job's live log inline and an “Open live log” link. The review then lists every proposed create/update/mark-missing/delete per item; from there you can Apply the whole run or select individual items and apply only those. The approved observation is signed and carried into the background job, so an apply writes exactly what you approved — it never re-polls upstream.

4. Schedule it

Set Sync interval (min) on the endpoint and make sure the NetBox worker is running (python manage.py rqworker). An hourly system job enqueues one sync per due endpoint; an endpoint can never run twice concurrently, and repeated failures back off instead of hammering the controller. Leaving the interval empty means "manual only".

To schedule every new endpoint instead of opting each one in by hand, set default_sync_interval_minutes in PLUGINS_CONFIG (with Nix: delegatedSync.settings). New endpoints then inherit that interval; endpoints that already exist keep their own value, so change those in the UI. The same applies to the other plugin-wide defaults — they seed an endpoint when it is created. Scheduled and API-triggered runs have no user behind them, so their changelog entries are attributed to a disabled netbox-sync-omada service account rather than to nobody.

The default mode is preview (dry-run). A scheduled or API run only collects and compares unless mode=apply is requested or an operator flips default_sync_mode to apply in the settings singleton. This is what lets a fresh deployment sync its first data without writing a single object until the output has been reviewed.

The endpoint list shows a computed Sync state badge (Running / Queued / Retry due / Retry / Due / Waiting / Disabled) and a Schedule badge, both sortable, plus a Last test column. Each row has Sync now and Test connection buttons, and the list offers bulk Sync Selected, Clear Stuck Syncs and Recalculate Schedule actions that honour the active filters. The endpoint's Status tab repeats the actions next to the scheduler state and the last connection test. An endpoint also has a Devices tab (the devices it owns, from the identity ledger), and every device page has an Omada Sync tab showing which endpoint owns it and how many tagged interfaces, IPs and VLANs that endpoint manages on it. The endpoint edit form has an “Existing devices” set for adopting devices NetBox already has (created by hand or an earlier tool): pick the device and give the Omada serial and/or MAC of the controller device it stands for, and the sync updates it instead of creating a duplicate (serial first, then MAC). A binding whose Omada identity never appears in a run is reported as a conflict.

5. Missing objects and deletion

Reconciliation is always tag-scoped. Missing devices/interfaces/IP addresses are marked (offline / disable / deprecated) and get an explanatory note that is removed again when the object reappears.

Deletion is off by default and needs two independent switches — the plugin-wide allow_delete_missing and the endpoint's own flag — plus a grace period (delete_grace_seconds, default 30 days). Only IP addresses and cables are ever eligible, only when the plugin created them, and devices, interfaces, VLANs, prefixes and wireless LANs can never be deleted by any configuration. Every run's created objects can be reverted from the Sync runs page.

6. Collection options

Every endpoint has a Collection behaviour card with the knobs that change what the sync collects, as opposed to how it reconciles it. All of them are also writable over the REST API.

Option Default Effect
ignored_ssids empty SSIDs whose wireless network and clients are never written. An entry may be an ssidId or an SSID name. Empty means "sync everything".
infer_interface_types on Derive the interface type from the port's static type enum: copper ports become 1000base-t, pluggable cages 1000base-x-sfp. Turn it off to leave every port as other. The negotiated link speed is deliberately never used — a down port reports 10 Mbps regardless and an empty cage reports no useful speed.
annotate_unknown_mappings on Append a note to the description of an interface the sync could not map (for example a port whose VLAN the controller never reported); description is the only free-text field dcim.Interface has. Off means the skip is only reported in the sync run.
metadata_write_mode only when changed Whether the omada_metadata custom field is rewritten on every sync. if_changed suppresses the write entirely when the stored value is already equal, which is what keeps a no-op sync from producing a page of changelog entries — the engine cannot suppress them by narrowing update_fields, only by not saving at all.
management_subnets empty network -> mask length, e.g. {"192.168.0.0/16": 24}. Omada reports management addresses and client leases as bare hosts, so the mask has to be derived; a configured subnet beats a prefix found in NetBox and is the escape hatch when an aggregate (or a sibling sync) would otherwise win. The entry's own mask is ignored — each /24 subnet inside the mapped network is matched.
create_dhcp_ipranges on Create an ipam.IPRange from the DHCP pool bounds the controller reports.
create_unknown_clients off Create a device for a client MAC that no device matched.
sync_client_attributes off Write the extra client attributes (OS name, device category, vendor) onto the object.
client_hostname_as_device_name on Name a device created from a client sighting after the client's hostname when it is RFC-1123-safe, otherwise fall back to the MAC.

The last three only apply while sync_clients is enabled, and create_dhcp_ipranges / management_subnets only while sync_prefixes / sync_management_ips are. create_unknown_clients, annotate_unknown_mappings, infer_interface_types and metadata_write_mode are also on the endpoints' bulk-edit form.

7. Settings

Omada Sync → Settings holds the plugin-wide defaults (sync mode, tag, per-model missing policy, deletion gates and grace, history retention). Endpoint values win over the defaults, and PLUGINS_CONFIG seeds the singleton only on its first access — once the row exists, edit it in the UI. default_sync_mode (preview/apply) is the mode the scheduler and the REST sync action use when a request does not name one; it defaults to preview, so nothing writes until it is explicitly set to apply. The plugin uses the normal netbox_sync_omada logger, so its verbosity is controlled by NetBox's LOGGING.

The Scheduler safety card holds three operational bounds: sync_job_timeout_seconds caps one collection phase, sync_stale_job_marker_minutes is how long a claimed sync slot may look in flight before a dead worker's claim is freed, and sync_missed_schedule_grace_minutes re-anchors a schedule that is overdue by more than that after downtime instead of queueing a catch-up run for every endpoint at once.

8. REST API

The models are also available over NetBox's REST API under /api/plugins/sync-omada/, using your normal NetBox API tokens. The resources are:

Path Methods Notes
endpoints/ GET, POST, PUT, PATCH, DELETE full CRUD
endpoints/{id}/sync/ POST queue a run; 202 with a job_id
site-mappings/ GET, POST, PUT, PATCH, DELETE full CRUD
runs/ GET sync history, read-only
runs/{id}/revert/ POST queue a revert of that run's created objects
external-objects/ GET the upstream-identity ledger, read-only
settings/ GET, PUT, PATCH the singleton policy row; no create/delete

client_id_ref and client_secret_ref are references, never values: they name an environment variable or a file: path (see Provide the Open API credentials above). A value that looks like a resolved secret is rejected with 400 rather than stored, so a client secret can never be written through the API.

Triggering a sync is a POST to the sync action. It returns 409 when the endpoint is disabled or when a run already holds the endpoint's slot, so a client cannot queue two runs for one endpoint; the slot the UI uses and the slot the API uses are the same one.

curl -sS -X POST \
  -H "Authorization: Token $NETBOX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mode": "apply"}' \
  https://netbox.example.com/api/plugins/sync-omada/endpoints/1/sync/
# {"job_id": 42, "status": "queued"}

mode is apply (default) or preview; a preview run computes the plan without writing anything. The action requires the change_omadaendpoint permission, and the revert action requires delete_syncrun — the same permissions the corresponding UI buttons need.

🧑‍💻 Development

Preparation

We're using uv to manage this project and its dependencies. After cloning the repository using Git, a simple uv sync should get everything you need.

git clone https://git.h.oluflorenzen.de/finkregh/netbox-sync-omada.git netbox-sync-omada
cd netbox-sync-omada
uv sync

Nix

A development shell with Python, uv, Ruff and the Nix formatter is available:

nix develop
# or run a single command inside it
nix develop -c statix check .
nix build .#default
nix flake check --all-systems
nix fmt

direnv

We recommend using direnv to add installed dependencies to your $PATH, so that you don't need to prepend uv run to every command. For example, after installing direnv, you can use direnv edit in this repository's directory and add the following line:

PATH_add .venv/bin

The rest of this readme assumes that you have .venv/bin in your $PATH.

EditorConfig

Make sure your editor or IDE supports the EditorConfig standard, so that your code adheres to the project's preferred indentation, line lengths, etc.

Makefile

There is a Makefile that contains frequently used commands to speed up development, but it's optional to use. The following targets exist:

  • fmt: Use Ruff to format and lint the code.
  • qa: Use mypy for static type analysis.
  • reuse: Use reuse lint to make sure every file contains licensing information.
  • test: Use pytest for automated testing and code coverage.
  • noqa: Add noqa statements to ignore everything Ruff complains about. Only use this after fixing everything that needs fixing.

make all, or simply make, will run fmt, qa, reuse, and test. You should do this before every commit and fix all issues that are reported.

make test currently exits with "no tests ran": the automated test suite is still to be written. Until then, the way to exercise the plugin is a local NetBox checkout with this repository installed editable, PLUGINS = ["netbox_sync_omada"] in its configuration, and a preview against a real controller.

📃 License

This project is licensed under the terms of the MIT License.

The project also conforms to the REUSE Specification, version 3.3. You can use the reuse tool to interpret the machine-readable licensing information.