README
# LibreNMS NetBox Context
NetBox device, interface, cabling and circuit context, shown inside the
LibreNMS pages an engineer is already looking at — and compared against what
LibreNMS actually observes.
NetBox is the source of truth for **intent**: identity, location, ownership,
interface configuration, cabling, VLANs and circuits. LibreNMS is the source of
truth for **reality**: availability, observed speed, administrative and
operational state, MTU as configured on the box. This plugin puts the two side
by side and says plainly where they disagree.
```text
NETBOX LIBRENMS
intent observation
│ │
│ site, rack, tenant │ up/down, traffic
│ interface config │ observed speed, MTU
│ cabling, circuits │ admin/oper state
│ │
└──────────► NETBOX CONTEXT ◄─────┘
"What is this? Where is it? Who owns it?
What should it be? What is it connected to?
Does reality match NetBox?"
```
---
## Table of contents
1. [Overview](#overview)
2. [What it looks like](#what-it-looks-like)
3. [Architecture](#architecture)
4. [Requirements](#requirements)
5. [Supported LibreNMS versions](#supported-librenms-versions)
6. [Supported NetBox versions](#supported-netbox-versions)
7. [Installation](#installation)
8. [Development installation](#development-installation)
9. [NetBox API token permissions](#netbox-api-token-permissions)
10. [Configuration](#configuration)
11. [Initial synchronisation](#initial-synchronisation)
12. [Scheduler](#scheduler)
13. [Device matching](#device-matching)
14. [Interface matching](#interface-matching)
15. [Manual mappings](#manual-mappings)
16. [Device context panel](#device-context-panel)
17. [Interface context tab](#interface-context-tab)
18. [Custom fields](#custom-fields)
19. [Cache and staleness behaviour](#cache-and-staleness-behaviour)
20. [Troubleshooting](#troubleshooting)
21. [Performance](#performance)
22. [Security](#security)
23. [Upgrading](#upgrading)
24. [Uninstalling](#uninstalling)
25. [LibreNMS daily.sh and upgrade persistence](#librenms-dailysh-and-upgrade-persistence)
26. [Using the context from another plugin](#using-the-context-from-another-plugin)
27. [Development](#development)
---
## Overview
The plugin adds three things to LibreNMS:
- a **NetBox Device Context** panel on the device overview page, including the
device's documented cabling and how it compares with what LibreNMS
discovered;
- a **NetBox Interface Context** tab on the port page;
- its own pages under **NetBox Context** in the navigation — status, device and
interface mappings, estate-wide **topology drift**, and settings.
All of them read from a local cache that a scheduled job keeps up to date.
Opening a device page never calls NetBox, so a NetBox outage degrades the panel
to "here is what we last knew, and how old it is" instead of slowing down or
breaking LibreNMS.
Beyond mirroring NetBox fields, it compares the two systems and flags:
- interface speed, MTU, description and administrative-state differences;
- device hostname, management IP, serial, hardware and platform differences;
- cabling that disagrees with the neighbours LibreNMS discovered;
- interfaces present in LibreNMS but not documented in NetBox;
- devices that cannot be matched to NetBox at all;
- matches that are **ambiguous** — reported, never guessed;
- cached data that has gone stale.
Version 1 is strictly read-only. It never writes to NetBox and never changes
LibreNMS devices or ports.
---
## What it looks like
Device overview panel:
```text
┌─────────────── NetBox Device Context ────────────────┐
│ NetBox status Active │
│ Site Haugesund │
│ Location HGS Sentrum │
│ Device role Aggregation Switch │
│ Tenant HK Fiber │
│ Manufacturer Cisco │
│ Model C9300L-24P-4G │
│ Serial FCW12345678 │
│ Rack HGS-R01 / U32 │
│ Primary IP 10.20.40.12/24 │
│ OOB IP 10.250.40.12 │
│ Tags access, critical │
│ │
│ NetBox <-> LibreNMS │
│ Hostname sw01… sw01… ✓ Match │
│ Management IP 10.20.40.12 10.20.40.12 ✓ Match │
│ Serial number FCW1234… FCW1234… ✓ Match │
│ Platform Cisco IOS IOS-XE ⚠ Mismatch │
│ │
│ Last NetBox sync: 2 minutes ago [Open in NetBox] │
└──────────────────────────────────────────────────────┘
```
Port tab:
```text
┌────────────── NetBox Interface Context ──────────────┐
│ NetBox interface TenGigabitEthernet1/1/1 │
│ Description HGS-ACCESS-042 UPLINK │
│ Type SFP+ (10GE) │
│ Configured speed 10 Gbps │
│ MTU 9216 │
│ Mode Tagged │
│ Tagged VLANs 100, 210, 300, 4010 │
│ │
│ CONNECTION │
│ hgs-access-042 / Te1/1/2 │
│ cable FO-HGS-004821 │
│ Remote site Haugesund Sentrum │
│ │
│ CIRCUIT │
│ Circuit ID HKF-38291 │
│ Provider HK Fiber │
│ │
│ CONFIGURATION COMPARISON │
│ Speed 10 Gbps 1 Gbps ⚠ Mismatch │
│ MTU 9216 9216 ✓ Match │
│ Description HGS-UPL… HGS-UPL… ✓ Match │
│ Admin state Enabled Enabled ✓ Match │
└──────────────────────────────────────────────────────┘
```
Status indicators are always a symbol **and** a word, so state is never carried
by colour alone.
---
## Architecture
```text
NetBox REST API
│
▼
NetBoxApiClient ─── pagination, retries, redirect rules, token handling
│
▼
Normalizers ─────── DeviceNormalizer, InterfaceNormalizer,
│ ConnectionNormalizer, CircuitNormalizer
▼
DTOs ────────────── NetBoxDeviceDto, NetBoxInterfaceDto, NetBoxCircuitDto
│
▼
Sync services ───── DeviceSyncService, InterfaceSyncService,
│ CircuitSyncService (chunked upserts, safe reconciliation)
▼
Local cache ─────── netbox_context_devices / _interfaces / _circuits
│
▼
Matching ────────── DeviceMatcher, InterfaceMatcher, MatchingService
│ → netbox_context_device_mappings / _interface_mappings
▼
Context ─────────── DeviceContextService, InterfaceContextService,
│ ComparisonService, FreshnessService
▼
Hooks ───────────── DeviceOverview (panel), PortTab (tab),
Settings, MenuEntry
```
Two properties hold throughout:
- **UI code makes no decisions.** Blade templates render a prepared DTO. All
matching, comparison and formatting happens in services that are testable on
their own.
- **UI requests never call NetBox.** Every page render reads local, indexed
tables.
Directory layout:
```text
librenms-netbox-context/
├── composer.json
├── config/netbox-context.php
├── database/migrations/
├── resources/views/
│ ├── hooks/ device-overview, port-tab
│ ├── admin/ settings panel, status, mapping pages
│ ├── components/ comparison, custom fields, freshness
│ └── layouts/
├── routes/web.php
├── src/
│ ├── NetBoxContextServiceProvider.php
│ ├── Console/ test, sync, match, status commands
│ ├── Contracts/ public interfaces for other plugins
│ ├── DTO/
│ ├── Enums/
│ ├── Exceptions/
│ ├── Hooks/
│ ├── Http/ controllers, middleware
│ ├── Models/
│ ├── Services/ Api, Sync, Matching, Comparison, Context
│ └── Support/ IP, hostname, speed, text, URL, redaction helpers
└── tests/ Unit, Feature, Scale
```
---
## Requirements
- PHP 8.2 or later
- LibreNMS 24.9.0 or later (see below)
- A reachable NetBox instance and a **read-only** API token
- Nothing else: no additional services, no queue worker, no frontend framework
---
## Supported LibreNMS versions
The plugin uses LibreNMS's package-plugin API (`librenms/plugin-interfaces`),
which first shipped in **LibreNMS 24.9.0**. It was developed and reviewed
against **26.7.0**.
The device overview panel uses the same panel markup LibreNMS 26.x uses for its
own overview panels. On older releases the panel still renders correctly; it
simply inherits that release's default styling.
No LibreNMS core file is modified.
### One documented deviation
LibreNMS decides whether to show the port page's **Plugins** tab with
```php
PluginManager::hasHooks(App\Plugins\Hooks\PortTabHook::class, ...) // abstract class
```
but renders that tab's content with
```php
PluginManager::call(LibreNMS\Interfaces\Plugins\Hooks\PortTabHook::class, ...) // interface
```
Package plugins publish against the interface — as LibreNMS's own
`PluginProvider` does for local plugins too — so the tab renders but the menu
entry never appears (verified in 26.7.0).
Rather than patch core, the plugin publishes a second, deliberately empty hook
(`Hooks\PortTabMenu`) under the abstract class purely to satisfy the menu
check. It renders nothing; the panel itself still comes from the interface
registration, so there is no duplication if a future LibreNMS calls both. It is
only registered when that abstract class exists, and it honours the same
authorisation rule as the tab, so the menu entry does not appear for a user who
would see nothing under it.
---
## Supported NetBox versions
Designed against the NetBox REST API as it exists in **3.5 through 4.x**.
Rather than targeting one point release, all NetBox JSON passes through a
normalisation layer that:
- accepts both `role` (NetBox ≥ 3.6) and `device_role` (older) on devices;
- tolerates any nested object being `null` or absent;
- accepts choice fields as either `{"value": …, "label": …}` or a bare string;
- treats a missing optional field as "not documented", never as an error;
- reads `connected_endpoints` (the traced path) in preference to `link_peers`
(the immediate cable peer), and falls back cleanly when a version does not
populate one of them.
If NetBox does not expose an endpoint (for example the circuits module is
unused), the connection test reports it and that feature degrades; the rest of
the plugin keeps working.
---
## Installation
Production installation is through Composer, using LibreNMS's plugin manager:
```bash
cd /opt/librenms
# Install the package
./lnms plugin:add drakelid/librenms-netbox-context ^1.0
# Run the plugin's migrations
php artisan migrate --force
```
Then:
1. Open **Overview → Plugins** (or `/plugins`) in LibreNMS and enable
**netbox-context** if it is not already enabled.
2. Open its **Settings** page.
3. Enter the NetBox URL and API token, save, and press **Test connection**.
4. Run the first synchronisation (see
[Initial synchronisation](#initial-synchronisation)).
> Verify the exact plugin-manager syntax against your LibreNMS version with
> `./lnms list plugin`. `plugin:add` has existed since LibreNMS 22.2.0.
---
## Development installation
Use a Composer path repository so edits take effect without a release:
```bash
cd /opt/librenms
composer config repositories.netbox-context \
'{"type":"path","url":"/opt/librenms-netbox-context","symlink":true}'
./lnms plugin:add drakelid/librenms-netbox-context @dev
php artisan migrate
```
Enable plugin error reporting while developing, so a broken hook shows the
error instead of silently disabling the plugin:
```bash
./lnms config:set plugins.show_errors true
```
---
## NetBox API token permissions
**Read-only is sufficient. Do not grant write permissions.** The plugin never
issues anything but `GET`.
The token needs read access to:
| NetBox object | Endpoint | Used for |
|---|---|---|
| Status | `/api/status/` | connection test, version detection |
| Devices | `/api/dcim/devices/` | device context and matching |
| Interfaces | `/api/dcim/interfaces/` | interface context, cabling, matching |
| IP addresses | `/api/ipam/ip-addresses/` | addresses assigned to interfaces |
| Circuits | `/api/circuits/circuits/` | circuit context (optional) |
Interface payloads carry their cable, VLAN and VRF information inline, so no
separate cable or VLAN permission is required.
In NetBox, create a token with **Write enabled** unchecked, and — if you use
object permissions — grant `view` on `dcim.device`, `dcim.interface`,
`ipam.ipaddress` and `circuits.circuit`.
---
## Configuration
Settings resolve in this order, highest first:
1. **Environment variables** (URL and token only)
2. **Plugin settings** stored in `netbox_context_settings`
3. **`config/netbox-context.php`** defaults
### Environment
```bash
NETBOX_CONTEXT_URL=
https://netbox.example.net
NETBOX_CONTEXT_API_TOKEN=0123456789abcdef0123456789abcdef01234567
```
Setting these keeps credentials out of the database entirely, and means a
database restore into another environment does not carry NetBox access with it.
When the token comes from the environment, the settings page says so.
### Connecting NetBox from the UI
The plugin has its own settings page at **`/plugin/netbox-context/settings`**,
reachable from the *Settings* tab next to Status and the mapping pages, and
from the plugin's entry in the LibreNMS Plugins menu.
Enter the NetBox URL and a read-only API token, then press **Save and test** —
it stores the credentials and immediately runs the full connection test,
reporting reachability, TLS, authentication and read access to each endpoint
separately. Nothing is synchronised until that succeeds.
The same connection form also appears on LibreNMS's own plugin-admin page
(**Plugins → netbox-context → Settings**), so either route works.
Both require the `netbox-context.admin` ability, which LibreNMS administrators
and holders of `plugin.admin` inherit.
### All settings
Under either settings screen:
- **Connection** — URL, API token, TLS verification, connect and request
timeouts, API page size, retry attempts.
- **Intervals** — device, interface and circuit sync intervals; stale
threshold; database chunk size; maximum tagged VLANs shown.
- **Matching** — which device and interface matching signals are enabled.
- **Comparison** — which fields are compared, and how descriptions are
normalised before comparison.
- **Display** — which panels and sections are shown.
- **Custom fields** — which NetBox custom fields to display.
The **API token** field is always blank when the page loads. **Leaving it blank
keeps the stored token**; there is a separate **Remove token** button. The
token is never rendered, never returned to the browser, and is stored encrypted
with Laravel's application key.
### NetBox-side filters
On a shared NetBox you can limit what is synchronised, in
`config/netbox-context.php`:
```php
'sync' => [
'device_filters' => ['site' => ['haugesund', 'bergen']],
'interface_filters' => [],
],
```
These are passed to NetBox as query parameters, so the filtering happens
server-side.
---
## Initial synchronisation
```bash
cd /opt/librenms
./lnms netbox-context:test # verify connectivity and permissions
./lnms netbox-context:sync --devices # devices, then automatic matching
./lnms netbox-context:sync --interfaces # interfaces, cabling, addresses
./lnms netbox-context:sync --circuits # optional
./lnms netbox-context:status # what happened
```
Or everything at once:
```bash
./lnms netbox-context:sync --full
```
A sync automatically runs the matching pass afterwards unless you pass
`--no-match`. To re-run matching alone:
```bash
./lnms netbox-context:match
./lnms netbox-context:match --device=42 # one device
./lnms netbox-context:match --prune # also drop mappings for deleted objects
```
Expect the first interface sync of a large NetBox to take several minutes; it
is paginated and chunked, and subsequent runs only write objects that changed.
---
## Scheduler
The plugin registers its own schedule with Laravel:
```text
netbox-context:sync --due every 5 minutes, without overlapping
netbox-context:match --prune daily at 03:20
```
`--due` consults the configured per-dataset intervals (devices every 5 minutes,
interfaces every 15, circuits every 30 by default) and runs only what is
actually due — so the five-minute tick is cheap.
This requires LibreNMS's own scheduler cron entry, which a standard install
already has:
```cron
* * * * * librenms /usr/bin/php /opt/librenms/artisan schedule:run >> /dev/null 2>&1
```
If you prefer explicit cron entries instead, disable nothing and simply add:
```cron
*/5 * * * * librenms /opt/librenms/lnms netbox-context:sync --devices
*/15 * * * * librenms /opt/librenms/lnms netbox-context:sync --interfaces
*/30 * * * * librenms /opt/librenms/lnms netbox-context:sync --circuits
```
Concurrent runs are prevented by an expiring cache lock, so an overlapping
manual run is skipped rather than doubling the API load.
---
## Device matching
Matching runs in a fixed order and stops at the first step that identifies
exactly one NetBox device.
| # | Step | Confidence | Notes |
|---|---|---|---|
| 1 | Manual or locked mapping | 100 | An operator decision. Never overridden. |
| 2 | Persisted mapping | 100 | The previous answer, re-verified against the cache. |
| 3 | Exact hostname | 95 | LibreNMS `hostname` vs NetBox `name`, case-insensitive, trailing dot ignored. |
| 4 | Primary IP | 90 | Management address vs NetBox primary IPv4/IPv6, canonicalised, prefix stripped. |
| 5 | Unique short hostname | 80 | `sw01` matches `sw01.example.net` — only if exactly one candidate. |
| 6 | Serial number | 75 | **Off by default.** Enable only if your serials are trustworthy. |
Rules that hold at every step:
- **Ambiguity is never resolved by guessing.** If a step finds more than one
candidate, the mapping is recorded as `ambiguous`, the candidates are stored,
and matching stops — it does not fall through to a weaker signal.
- **DNS is never consulted.** A name that resolves somewhere is not evidence of
identity.
- Every mapping records the method, the confidence and a human-readable reason:
```text
Method: exact_hostname
Confidence: 95
Reason: LibreNMS hostname 'sw01.example.net' matches the NetBox
device name exactly.
```
---
## Interface matching
The parent device must be mapped first. Candidates only ever come from that
NetBox device — interfaces are never matched across devices.
| # | Step | Confidence | Notes |
|---|---|---|---|
| 1 | Manual or locked mapping | 100 | Never overridden. |
| 2 | Persisted mapping | 100 | Re-verified against the device's interfaces. |
| 3 | Exact name | 95 | LibreNMS `ifName` (or `ifDescr` when there is no `ifName`) vs NetBox `name`, case-insensitive. |
| 4 | Canonical name | 85 | Vendor abbreviation expansion, when enabled. |
Canonicalisation expands a **known leading abbreviation** and leaves the
numeric part alone:
```text
Te1/1/1 -> tengigabitethernet1/1/1
Gi0/1 -> gigabitethernet0/1
Po10 -> port-channel10
Hu1/0/1 -> hundredgigabitethernet1/0/1
```
What it deliberately does not do:
- no substring or fuzzy matching;
- no matching on descriptions — two uplinks routinely share a description, and
a port mapped that way would attribute one circuit's context to another;
- no rewriting of unknown prefixes: Juniper's `ae0`, `xe-0/0/0` and `irb.100`
pass through untouched.
The abbreviation table is not claimed to be universal. Extend or override it in
`config/netbox-context.php`:
```php
'matching' => [
'interface' => [
'abbreviations' => ['swp' => 'swport'],
],
],
```
A canonical name that is not unique on the device is reported as ambiguous.
### Mapping states
```text
MATCHED automatic match, resolved
MANUAL set by an administrator
AMBIGUOUS several candidates; manual mapping required
UNMATCHED not documented in NetBox
STALE the mapped NetBox object is no longer in the cache
```
---
## Manual mappings
**Plugins → netbox-context → Settings → Device mappings / Interface mappings**,
or directly:
- `/plugin/netbox-context/mappings/devices`
- `/plugin/netbox-context/mappings/interfaces`
Both pages filter by state, so "show me everything ambiguous" is one click.
Panels for unmatched or ambiguous objects link straight to them.
To map by hand, enter the NetBox object id and press **Set**. Optionally tick
**lock**.
- **Manual** mappings are never repointed by the automatic matcher.
- **Locked** mappings are never repointed either, including mappings that were
originally automatic — useful for pinning a correct match on a device whose
hostname keeps changing.
- **Clear manual mapping** drops the flags and lets matching decide again; the
row itself is kept.
- An interface may only be mapped to an interface on the device's own mapped
NetBox device. Anything else is refused.
Mapping changes require the `netbox-context.admin` ability and are logged with
the username.
---
## Device context panel
**The panel arrives fully folded.** A device page is scanned in seconds, so
every section — device details, connections, topology, custom data and the
comparison — is a fold that starts closed, and an engineer opens the one they
came for:
```text
┌─────────────── NetBox Device Context ────────────────┐
│ ▸ Device details · Active · Haugesund │
│ ▸ Connections · 4 documented in NetBox │
│ ▸ Topology — NetBox ↔ discovered · ⚠ 1 finding │
│ ▸ NetBox ↔ LibreNMS · ⚠ 2 mismatches │
│ Last NetBox sync: 2 minutes ago [Open in NetBox] │
└──────────────────────────────────────────────────────┘
```
Every closed header carries a count, so **folding hides detail and never hides
a disagreement** — a shut comparison section still says `⚠ 2 mismatches`. The
folds are native `
Details
` elements: no JavaScript, no Bootstrap collapse,
keyboard and screen-reader behaviour straight from the browser, and the content
stays in the document so find-in-page still reaches it. Open/closed state is not
remembered between page loads.
Unmatched, ambiguous and error states are never folded — when something is
wrong, the panel says so on arrival.
Shows only fields NetBox actually has — no rows of dashes. Includes status,
site, location, role, tenant, manufacturer, model, serial, asset tag, rack and
position, primary and OOB IP, platform, tags, selected custom fields, an
**Open in NetBox** link, and the consistency table.
Device comparison covers hostname, management IP, serial number, hardware model
and platform. It is deliberately tolerant of harmless differences:
- one system holding the FQDN and the other the short name is a **match**;
- `Cisco C9300L-24P-4G` against `C9300L-24P-4G` is a **match** (the
manufacturer prefix is normalised away);
- model and platform differences are raised at *notice* severity, not as
warnings — a catalogue naming difference is not an incident.
When there is no match the panel says so and shows what was looked for:
```text
⚠ This device is not matched to NetBox.
Hostname checked sw01.example.net
Short hostname checked sw01
Management IP checked 10.20.40.12
```
### Connections
Below the context table, every cabled interface of the device as NetBox
documents it — the device's physical neighbours on one screen, without opening
a port at a time:
```text
Connections · 4 documented in NetBox
Interface Connects to Cable
Te1/1/1 hgs-access-042 / Te1/1/2 FO-HGS-004821
Haugesund Sentrum Connected · smf-os2
Te1/1/2 hgs-access-043 / Te1/1/2 FO-HGS-004822
Haugesund Sentrum Connected · smf-os2
Te1/1/3 Rear port — ODF-01 / Port 18 FO-HGS-004823
Te1/1/4 Circuit termination — HKF-38291 FO-HGS-004824
```
Details that matter:
- **The far end links where it is most useful.** A neighbour this LibreNMS also
monitors links to its LibreNMS device page; one it does not links out to
NetBox. An *ambiguous* mapping links to neither — an unresolved match must
not become a confident link to one of its candidates.
- **Only cabled interfaces appear.** An access switch with four uplinks shows
four rows, not forty-eight.
- **Non-interface terminations are described, not flattened.** A patch panel is
named as a front or rear port, exactly as on the port tab.
- **Cable peers are distinguished from traced paths.** When NetBox reports only
this cable's peer rather than tracing the whole path, the row says
`Cable peer — path not traced by NetBox`, because that endpoint is the near
side of a patch and not the far device.
- **The list is capped** at `display.max_connections` (default 100) and states
the total when it truncates: `Showing 100 of 384 documented connections.`
- **It costs three queries** regardless of port count — the interfaces, then
one batched lookup each for the far-end NetBox devices and their LibreNMS
mappings. Set `display.connections` to off to remove the section entirely.
### Topology drift
The cabling NetBox documents, held against the neighbours LibreNMS actually
discovered over LLDP or CDP. It appears on the device panel and, for the whole
estate, on the plugin's **Topology** page.
```text
Topology — NetBox ↔ discovered
3 of 4 comparable links agree with NetBox.
Interface State NetBox says LibreNMS discovered
Te1/1/3 ⚠ Wrong neighbour hgs-access-043 / Te1/1/2 hgs-access-044 / Te1/1/2 (lldp)
NetBox documents a cable to hgs-access-043; LibreNMS discovered
hgs-access-044.
```
The five verdicts:
| State | Meaning | Severity |
|---|---|---|
| ✓ Match | Both systems describe the same far end | — |
| ⚠ Wrong neighbour | Both describe a far end and they disagree | warning |
| ⚠ Not in NetBox | LibreNMS discovered a neighbour NetBox has no cable for | warning |
| — Not discovered | NetBox documents a cable LibreNMS has not seen | notice |
| — Not comparable | The far end could not be identified on both sides | — |
**The categories are deliberately asymmetric, because the evidence is.** A
neighbour LibreNMS has seen is proof a link exists. LibreNMS *not* seeing one
proves almost nothing: LLDP and CDP only reveal peers that speak them, so a
perfectly correct cable to a server, to a host with discovery disabled, or to a
device this LibreNMS does not poll looks exactly like a missing one. That is
why "not discovered" is a notice and carries its caveat in the UI, while
"not in NetBox" is a warning.
Rules that keep the output trustworthy:
- **Every finding is anchored to a LibreNMS port with a resolved NetBox
interface mapping.** Without that anchor there is no honest way to say
"NetBox has no cable here" — only "we do not know which NetBox interface this
is", which is a mapping problem the plugin already reports on the mapping
pages. Cabled NetBox interfaces with no mapped port are counted and stated
(*"2 cabled NetBox interfaces could not be compared…"*), never silently
dropped.
- **Far ends are compared by device id where possible**, falling back to
hostname only when one side is a device LibreNMS does not monitor. Two
devices sharing a hostname prefix do not become a match.
- **Interface names are compared canonically**, so `Te1/1/2` against
`TenGigabitEthernet1/1/2` is agreement, not drift.
- **Cables to patch panels and circuit terminations are not compared at all.**
No discovery protocol could ever confirm a rear port.
- **Inactive link rows are ignored.** LibreNMS keeps them as history; they are
not a current observation, so they cannot contradict NetBox.
- **Whole pages of devices cost a fixed set of queries**, not a set per device.
Set `display.topology_drift` to off to remove the section and the page's
findings entirely.
---
## Interface context tab
Under the port page's **Plugins** tab. Shows the NetBox interface, its
description, type, configured speed, MTU, enabled state, 802.1Q mode, VLANs,
addresses, VRF and custom fields.
**Connection.** When NetBox traces the path to another device interface, both
ends and the cable are named. When it does not — a front port, rear port, patch
panel or circuit termination — the tab says what the termination actually is:
```text
Connection documented in NetBox
Remote termination type: Rear port
Remote endpoint: ODF-01 / Port 18
```
**Circuit.** When the path terminates on a circuit: circuit ID, provider, type,
status, tenant, committed rate and description.
**Comparison.** Speed, MTU, description and administrative state.
A few details that matter in practice:
- **Speeds are normalised to bit/s before comparison.** NetBox stores kbit/s;
LibreNMS stores `ifSpeed` in bit/s and `ifHighSpeed` in Mbit/s. `ifSpeed`
saturates at 4 294 967 295 on anything faster than ~4.29 Gbit/s, so
`ifHighSpeed` is preferred and a saturated `ifSpeed` is treated as unknown
rather than as a measurement. Without this, every 10G port would report a
false mismatch.
- **A missing value on either side is `Unknown`, not a mismatch.** NetBox not
documenting an MTU is not a discrepancy.
- **Administrative state is compared against `ifAdminStatus` only.** An
interface that is admin up and oper down is an outage, which LibreNMS already
alerts on — not a documentation problem.
- **Description comparison is configurable**: trim, collapse repeated
whitespace, optional case-insensitivity. Nothing is ever stripped from the
middle of a description.
---
## Custom fields
NetBox custom fields are fetched dynamically — no field name is hard-coded.
Choose which to display in the settings page (comma-separated), or leave the
list empty to show everything NetBox returns.
Values are formatted for display:
| NetBox value | Shown as |
|---|---|
| string | the string |
| number | the number |
| boolean | `Yes` / `No` |
| list | comma-separated |
| object reference | its `display` / `name` |
| URL string | a link, **only** for `http`/`https` |
| null or empty | the row is omitted entirely |
Nothing NetBox returns is ever rendered as HTML. A value containing markup is
escaped and shown as text, and a `javascript:` or `data:` "URL" is rendered as
text rather than becoming a link.
---
## Cache and staleness behaviour
Every panel states how old its data is. Past the configured threshold
(60 minutes by default) it says so prominently:
```text
⚠ NetBox data is stale.
Last successful synchronisation: 47 minutes ago (2026-08-19 09:04:11)
```
Freshness is measured from the last **successful** sync, not the last attempt.
### Reconciliation is only ever done from a complete run
This is the most important behaviour in the plugin.
A sync marks objects NetBox did not return as stale **only if it traversed its
entire dataset successfully**. If it fails on page 37 of 50:
- the run is recorded as `partial`, with `authoritative = false`;
- what was fetched is still written to the cache;
- **nothing is marked stale**, because the run knows nothing about the pages it
never reached.
Cached objects are **never deleted** by a sync, only flagged. An object that
comes back next run simply clears its flag. Mapping rows — especially manual
ones — are never touched by synchronisation at all.
Sync outcomes:
| Status | Meaning |
|---|---|
| `success` | Whole dataset traversed; reconciliation performed |
| `partial` | Some data arrived, traversal incomplete; no reconciliation |
| `failed` | Nothing usable arrived |
---
## Troubleshooting
Start here:
```bash
./lnms netbox-context:status # or /plugin/netbox-context/status
./lnms netbox-context:test
```
The status page shows connection details, NetBox version, cache counts, mapping
tallies and the last 20 synchronisation runs with their counters and errors.
| Symptom | Likely cause and fix |
|---|---|
| Panel says "not matched to NetBox" | Compare the diagnostics against NetBox. Usually the hostname differs; enable short-hostname fallback or map manually. |
| Panel says "ambiguous" | Two NetBox devices share the name. Map manually — the candidates are listed. |
| "not documented in NetBox" on a port | The interface really is absent from NetBox, or its name differs beyond normalisation. Check the interface mappings page. |
| Everything is stale | Synchronisation is not running. Check the LibreNMS scheduler cron, then run `netbox-context:sync --devices` by hand. |
| `403 Forbidden` in the test | The token lacks read access to that object type. See [token permissions](#netbox-api-token-permissions). |
| `401 Unauthorized` | Wrong or revoked token. Re-enter it; it is not retried, by design. |
| `404` on circuits | The circuits module is not in use. Turn off circuit sync in the settings. |
| "stored NetBox token could not be decrypted" in the log | `APP_KEY` changed. Re-enter the token. |
| Sync says `partial` repeatedly | NetBox is timing out or rate limiting. Increase the request timeout, reduce the API page size. |
| Cross-host redirect refused | NetBox is redirecting to a different hostname. Configure the URL NetBox actually serves on. |
Plugin log lines are written to LibreNMS's own log (`logs/librenms.log`) and are
prefixed `netbox-context:`. Synchronisation logs one aggregate line per run, not
one per object:
```text
netbox-context: interfaces sync success duration=18.4s objects=183442
created=0 updated=421 unchanged=182997 stale=24 errors=0 pages=734
```
---
## Performance
Designed for 5 000+ devices and 250 000+ interfaces.
**Rendering.** A device panel is three indexed queries (freshness, mapping,
cached device); a port tab is at most six, and only reaches that when the
interface is both cabled to a known device and terminated on a circuit. Neither
grows with the size of the cache, and neither calls NetBox. The scale suite
asserts these bounds directly.
**Synchronisation.** Paginated API access, one page normalised and written at a
time, bounded memory. Each chunk costs three statements regardless of chunk
size: read current hashes, upsert what differs, stamp the rest as seen. Objects
whose content hash is unchanged are not rewritten — on a settled network a
sync of 183 000 interfaces updates a few hundred rows.
**Matching.** Ports are matched against a per-device hash index built from one
indexed query, so the cost is linear in ports. No interface is ever compared
with another device's interfaces.
**Indexes.** Mappings are unique on `device_id` / `port_id` and indexed on the
NetBox side; interfaces carry composite indexes on
`(netbox_device_id, name_normalized)` and `(netbox_device_id, name_canonical)`,
which is exactly what the matcher queries.
Tuning knobs: `connection.page_size` (API page size), `sync.chunk_size`
(database chunk size), and the per-dataset intervals.
---
## Security
**Credentials.** The API token is read from the environment first, otherwise
from the plugin's own settings table where it is stored **encrypted** with
Laravel's application key — not in LibreNMS's plaintext plugin-settings JSON.
It is never rendered, never logged, and never sent to the browser. The settings
page shows only `•••••••••••••••• configured`. An empty token field on save
means "keep the current token"; removal is a separate, explicit action.
**Transport.** TLS verification is on by default; disabling it is an
administrator action with the consequence stated next to the checkbox. Only
`http` and `https` are accepted, and credentials embedded in the URL are
rejected. Connect and request timeouts are enforced.
**Redirects.** Never followed automatically. A same-host redirect is re-issued
by hand; a redirect to another host is refused outright, so the `Authorization`
header is never handed to a host the operator did not configure.
**Request targets.** The plugin only ever requests paths from a fixed set of
endpoint constants, built against the configured base URL. No browser-supplied
value reaches the HTTP client. Object links shown in the UI are constructed
from the base URL and a numeric id, not from anything NetBox returned.
Note that private (RFC1918) NetBox URLs are explicitly supported — NetBox
normally lives on the management network, and an SSRF filter that blocked
private ranges would break every real deployment. The control is that only a
plugin administrator can set the base URL.
**Authorisation.**
- All plugin routes require authentication.
- Plugin pages require `netbox-context.view`.
- Settings, sync and mapping changes require `netbox-context.admin`, which
LibreNMS administrators and holders of `plugin.admin` inherit.
- The device panel and port tab defer to **LibreNMS's own device and port
policies**. A user who cannot see a device in LibreNMS learns nothing about
it here.
- State-changing requests go through the `web` middleware group and carry CSRF
protection.
**Output.** Every NetBox-derived value is escaped by Blade. Nothing NetBox
returns is rendered as HTML, and only `http`/`https` values become links.
**Logging.** Errors are scrubbed by a redaction helper that removes known token
values and anything shaped like an `Authorization` header before the message
reaches a log, an exception or a page.
---
## Upgrading
```bash
cd /opt/librenms
./lnms plugin:add drakelid/librenms-netbox-context ^1.0 # or composer update
php artisan migrate --force
```
Schema changes always arrive as **new** migrations; released migrations are
never edited. Check `CHANGELOG.md` for breaking changes before a major version.
Cached NetBox data and mappings survive upgrades. If a new version adds cached
fields, run a full sync afterwards to populate them:
```bash
./lnms netbox-context:sync --full
```
---
## Uninstalling
Disabling or removing the plugin **does not destroy anything**. Cached data and
mappings — including every manual mapping — are left in place, so re-enabling
restores the previous state.
```bash
# 1. Disable (hooks stop rendering, routes return 404)
./lnms plugin:disable netbox-context
# 2. Remove the package
./lnms plugin:remove drakelid/librenms-netbox-context
# 3. Optional: purge plugin data. This is irreversible and destroys
# every manual mapping.
php artisan migrate:rollback --path=vendor/drakelid/librenms-netbox-context/database/migrations
```
Step 3 is never performed automatically. If you only want to drop the API
token, use **Remove token** on the settings page instead.
---
## LibreNMS daily.sh and upgrade persistence
The plugin is a **Composer package**, not files copied into a directory
LibreNMS overwrites. Nothing it owns lives inside the LibreNMS source tree.
After `daily.sh` or any normal LibreNMS upgrade:
| Survives | Why |
|---|---|
| The package itself | It is a Composer dependency; `daily.sh` runs `composer install`, which reinstalls it. |
| Plugin registration | Laravel package auto-discovery reads `extra.laravel.providers` from the package's own `composer.json`. |
| Database tables | Plugin-owned migrations; `daily.sh` runs `artisan migrate`, which is additive. |
| Configuration | Stored in `netbox_context_settings` (a plugin table) and/or the environment. |
| API token | Same, encrypted; or in the environment, which upgrades do not touch. |
| Mappings | `netbox_context_device_mappings` / `_interface_mappings` — never truncated by a sync or an upgrade. |
| Scheduled sync | Registered by the service provider through Laravel's scheduler, not by a cron file that could be replaced. |
No LibreNMS core file is modified, so there is nothing for an upgrade to
conflict with.
For a path-repository development install, note that `composer install` during
`daily.sh` will keep resolving the symlink — keep the checkout in place, or
switch to the Packagist version for production.
---
## Using the context from another plugin
The correlation this plugin maintains is deliberately reusable. Two published
interfaces are bound in the container:
```php
use LibreNMS\Plugins\NetBoxContext\Contracts\DeviceContextProviderInterface;
use LibreNMS\Plugins\NetBoxContext\Contracts\InterfaceContextProviderInterface;
$context = app(DeviceContextProviderInterface::class)->forDevice($device);
if ($context->hasNetBoxDevice()) {
$site = $context->netboxDevice->site_name;
$tenant = $context->netboxDevice->tenant_name;
$issues = $context->mismatches();
}
$portContext = app(InterfaceContextProviderInterface::class)->forPort($port);
$circuit = $portContext->circuit?->cid;
```
Both answer from the local cache and never call NetBox, so they are safe to use
in a page render. Implementations must keep that guarantee.
---
## Development
```bash
composer install
composer test # unit + feature suites
composer test:unit
composer test:feature
composer test:scale # slow; builds large fixtures
composer analyse # PHPStan level 5 (larastan)
composer lint # Laravel Pint, check only
composer fix # Pint, apply
```
The suite runs standalone on Orchestra Testbench, so the matchers, comparison
engine, API client and sync services can be exercised without a LibreNMS
install. Set `LIBRENMS_ROOT` — or keep a LibreNMS checkout beside this package —
and the same tests run against the real LibreNMS models instead of the stubs in
`tests/Stubs/librenms.php`.
Coverage worth knowing about:
- `tests/Unit/DeviceMatcherTest.php`, `InterfaceMatcherTest.php` — the full
matching matrix, including ambiguity, locked mappings and "no false match".
- `tests/Unit/ComparisonServiceTest.php` — including the 100G `ifSpeed`
saturation case and admin-vs-oper state.
- `tests/Unit/NormalizerTest.php` — NetBox schema variations, null nested
objects, non-interface cable terminations.
- `tests/Unit/SecurityTest.php` — token redaction, URL scheme rejection,
cross-host redirect detection.
- `tests/Feature/DeviceSyncTest.php` — **partial-sync safety**: a run that
fails halfway must not declare anything deleted.
- `tests/Feature/NetBoxApiClientTest.php` — pagination, retries, 401/403/404/
429/500, malformed JSON, redirect handling.
- `tests/Feature/ContextPanelTest.php` — both panels, including escaping of
NetBox-supplied markup and authorisation denial.
- `tests/Feature/AdminRoutesTest.php` — authorisation, token-preservation
semantics, cross-device mapping rejection.
- `tests/Scale/MatchingScaleTest.php` — linear matching and bounded query
counts.
---
## Licence
GPL-3.0-or-later. See [LICENSE](LICENSE).
# netboxcontext