Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3518a6e
tests + device_fields: transaction.atomic, full_clean, select_for_upd…
marcinpsk Mar 8, 2026
f1b1f45
fix: use validated-cache namespace for BG job gate + unconditional VC…
marcinpsk Mar 8, 2026
b943969
fix: replace multi-server VC tests with branch-appropriate variants
marcinpsk Mar 9, 2026
c8fd7a7
feat: multi-server librenms_id with JSON custom field and server mana…
marcinpsk Mar 7, 2026
5e3ad5c
fix: strip stale derived fields in cable verify POST, preserve server…
marcinpsk Mar 7, 2026
5c6f9a9
fix: escape LibreNMS labels in cable verify HTML to prevent XSS
marcinpsk Mar 7, 2026
3427ed9
fix: reviewer findings — bool guards, server_key propagation, conflic…
marcinpsk Mar 7, 2026
8fc6b32
fix: IP verify cache key mismatch, cable form server_key, django.setu…
marcinpsk Mar 7, 2026
313e897
fix: reviewer findings — XSS, bool guards, cache TTL, VM safety, lega…
marcinpsk Mar 7, 2026
c33c91a
fix: bool guards in find_by_librenms_id and _normalize_librenms_mappi…
marcinpsk Mar 7, 2026
fbd6dc8
fix: reviewer findings batch 2 — XSS escape, bool guards, auto_save, …
marcinpsk Mar 7, 2026
27b2242
fix: add missing multi-server support to VM and bulk import operations
marcinpsk Mar 7, 2026
e99896a
fix: reviewer findings batch 3 — server_key propagation, validation, …
marcinpsk Mar 7, 2026
72be41a
fix: reviewer findings batch 4 — error surfacing, cache TODO, proxy r…
marcinpsk Mar 8, 2026
f9ae8bd
tests: fix 3 CI failures — matched→found key, missing vc_position=None
marcinpsk Mar 8, 2026
b82b097
fix: reviewer findings batch 5 — VC incoming serial, test coverage
marcinpsk Mar 8, 2026
460c05c
fix: reviewer findings batch 6 — naming prefs resolver, test assertion
marcinpsk Mar 8, 2026
e8aea5b
fix: reviewer findings batch 7 — naming source, VM action guard, ID v…
marcinpsk Mar 8, 2026
2e52600
fix: use validated-cache namespace for BG job gate + unconditional VC…
marcinpsk Mar 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions netbox_librenms_plugin/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -577,19 +577,19 @@ def _populate_librenms_locations(self):
"""Fetch and populate LibreNMS locations in the dropdown."""
from django.core.cache import cache

from netbox_librenms_plugin.import_utils.cache import get_location_choices_cache_key
from netbox_librenms_plugin.librenms_api import LibreNMSAPI

try:
# Use caching to avoid repeated API calls
cache_key = "librenms_locations_choices"
# Instantiate the API client to resolve the authoritative server_key
api = LibreNMSAPI()
cache_key = get_location_choices_cache_key(api.server_key)
cached_choices = cache.get(cache_key)

if cached_choices:
self.fields["librenms_location"].choices = cached_choices
return

# Fetch locations from LibreNMS
api = LibreNMSAPI()
success, locations = api.get_locations()

if success and locations:
Expand Down
4 changes: 3 additions & 1 deletion netbox_librenms_plugin/import_utils/bulk_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ def bulk_import_devices_shared(
api=api,
use_sysname=use_sysname_opt,
strip_domain=strip_domain_opt,
server_key=api.server_key,
)

# Build manual mappings from validation + any provided overrides
Expand All @@ -148,7 +149,7 @@ def bulk_import_devices_shared(

result = import_single_device(
device_id,
server_key=server_key,
server_key=api.server_key, # use resolved key, not raw parameter (may be None)
sync_options=sync_options,
manual_mappings=device_mappings if device_mappings else None,
libre_device=libre_device,
Expand Down Expand Up @@ -503,6 +504,7 @@ def process_device_filters(
api=api_for_validation,
include_vc_detection=vc_detection_enabled,
force_vc_refresh=clear_cache,
server_key=api.server_key,
use_sysname=use_sysname,
strip_domain=strip_domain,
)
Expand Down
7 changes: 6 additions & 1 deletion netbox_librenms_plugin/import_utils/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
logger = logging.getLogger(__name__)


def get_location_choices_cache_key(server_key: str) -> str:
"""Return the cache key for LibreNMS location choices for a given server."""
return f"librenms_locations_choices:{server_key}"


def get_cache_metadata_key(server_key: str, filters: dict, vc_enabled: bool) -> str:
"""
Generate a consistent cache metadata key from filter parameters.
Expand Down Expand Up @@ -62,7 +67,7 @@ def get_active_cached_searches(server_key: str) -> list[dict]:
}

# Get cached location choices for enrichment
location_cache_key = "librenms_locations_choices"
location_cache_key = get_location_choices_cache_key(server_key)
cached_locations = cache.get(location_cache_key)
if cached_locations:
location_choices = dict(cached_locations)
Expand Down
40 changes: 34 additions & 6 deletions netbox_librenms_plugin/import_utils/device_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
find_matching_platform,
find_matching_site,
match_librenms_hardware_to_device_type,
set_librenms_device_id,
)
from .cache import get_import_device_cache_key
from .virtual_chassis import (
Expand Down Expand Up @@ -129,6 +130,7 @@ def validate_device_for_import(
force_vc_refresh: bool = False,
use_sysname: bool = True,
strip_domain: bool = False,
server_key: str = "default",
) -> dict:
"""
Validate if a LibreNMS device can be imported to NetBox.
Expand Down Expand Up @@ -203,6 +205,7 @@ def validate_device_for_import(
"serial_action": None, # None, "link", "conflict", "update_serial", "hostname_differs"
"serial_confirmed": False, # True when librenms_id match and serial matches
"serial_duplicate": False, # True when incoming serial is already on a different device
"librenms_id_needs_migration": False, # True when existing device has legacy bare-int ID
"name_matches": False, # True when existing device name matches LibreNMS sysName
"name_sync_available": False, # True when existing device name differs from sysName
"suggested_name": None, # sysName to suggest when name_sync_available is True
Expand Down Expand Up @@ -275,10 +278,13 @@ def validate_device_for_import(

from virtualization.models import VirtualMachine

server_key = api.server_key if api is not None else server_key

# Check for existing VM first (by librenms_id custom field)
# Always query with int to match custom field type
try:
existing_vm = VirtualMachine.objects.filter(custom_field_data__librenms_id=int(librenms_id)).first()
from netbox_librenms_plugin.utils import find_by_librenms_id

existing_vm = find_by_librenms_id(VirtualMachine, int(librenms_id), server_key)
except (ValueError, TypeError):
# librenms_id is not convertible to int; no match will be found
existing_vm = None
Expand All @@ -290,18 +296,29 @@ def validate_device_for_import(
result["import_as_vm"] = True # Force VM mode since VM exists
result["can_import"] = False

# Check if name matches sysName
# Detect legacy bare-integer or string-digit format so UI can offer a migration action.
# Direct access needed to detect legacy format for migration prompt:
# LibreNMSAPI.get_librenms_id() returns an int in both formats, so only the
# raw type check on custom_field_data reveals whether migration is needed.
_vm_cf_id = existing_vm.custom_field_data.get("librenms_id")
if (isinstance(_vm_cf_id, int) and not isinstance(_vm_cf_id, bool)) or (
isinstance(_vm_cf_id, str) and _vm_cf_id.isdigit()
):
result["librenms_id_needs_migration"] = True

# Check if name matches resolved name (accounts for use_sysname/strip_domain)
# Note: name_sync_available/suggested_name are intentionally not set for VMs
# because UpdateDeviceNameView only supports Device objects; VM name-sync
# would require a separate implementation.
if hostname and existing_vm.name == hostname:
result["name_matches"] = True

# Check for existing Device (by librenms_id custom field)
# Always query with int to match custom field type
if not result["existing_device"]:
try:
existing_device = Device.objects.filter(custom_field_data__librenms_id=int(librenms_id)).first()
from netbox_librenms_plugin.utils import find_by_librenms_id

existing_device = find_by_librenms_id(Device, int(librenms_id), server_key)
except (ValueError, TypeError):
# librenms_id is not convertible to int; no match will be found
existing_device = None
Expand All @@ -312,6 +329,16 @@ def validate_device_for_import(
result["existing_match_type"] = "librenms_id"
result["can_import"] = False

# Detect legacy bare-integer or string-digit format so UI can offer a migration action.
# Direct access needed to detect legacy format for migration prompt:
# LibreNMSAPI.get_librenms_id() returns an int in both formats, so only the
# raw type check on custom_field_data reveals whether migration is needed.
_dev_cf_id = existing_device.custom_field_data.get("librenms_id")
if (isinstance(_dev_cf_id, int) and not isinstance(_dev_cf_id, bool)) or (
isinstance(_dev_cf_id, str) and _dev_cf_id.isdigit()
):
result["librenms_id_needs_migration"] = True

# Check if name matches resolved name (VC-aware: compare against VC member name)
if hostname and existing_device.virtual_chassis and existing_device.vc_position:
incoming_serial = libre_device.get("serial") or ""
Expand Down Expand Up @@ -726,6 +753,7 @@ def import_single_device(
api=api,
use_sysname=use_sysname_opt,
strip_domain=strip_domain_opt,
server_key=api.server_key,
)

# Check if device already exists
Expand Down Expand Up @@ -811,7 +839,6 @@ def import_single_device(
"role": device_role,
"status": "active" if libre_device.get("status") == 1 else "offline",
"comments": f"Imported from LibreNMS by netbox-librenms-plugin on {import_time}",
"custom_field_data": {"librenms_id": int(device_id)},
}

# Add optional fields
Expand All @@ -836,6 +863,7 @@ def import_single_device(

# Create the device
device = Device(**device_data)
set_librenms_device_id(device, device_id, api.server_key)
device.full_clean()
device.save()
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand Down
34 changes: 23 additions & 11 deletions netbox_librenms_plugin/import_utils/vm_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging

from dcim.models import DeviceRole
from django.db import transaction
from django.utils import timezone
from virtualization.models import Cluster

Expand All @@ -13,7 +14,9 @@
logger = logging.getLogger(__name__)


def create_vm_from_librenms(libre_device: dict, validation: dict, use_sysname: bool = True, role=None):
def create_vm_from_librenms(
libre_device: dict, validation: dict, use_sysname: bool = True, role=None, server_key: str = "default"
):
"""
Create a NetBox VirtualMachine from LibreNMS device data.

Expand All @@ -22,6 +25,7 @@ def create_vm_from_librenms(libre_device: dict, validation: dict, use_sysname: b
validation: Validation result from validate_device_for_import with import_as_vm=True
use_sysname: If True, prefer sysName; if False, use hostname
role: Optional DeviceRole to assign to the VM
server_key: LibreNMS server key used to store the librenms_id custom field

Returns:
Created VirtualMachine instance
Expand Down Expand Up @@ -58,15 +62,20 @@ def create_vm_from_librenms(libre_device: dict, validation: dict, use_sysname: b
raise ValueError(f"device_id is a boolean ({raw_device_id!r}); expected an integer")
librenms_device_id = int(raw_device_id)

# Create the VM with librenms_id custom field
vm = VirtualMachine.objects.create(
name=vm_name,
cluster=cluster,
role=role, # Optional VM role
platform=platform,
comments=f"Imported from LibreNMS (device_id={librenms_device_id}) by netbox-librenms-plugin on {import_time}",
custom_field_data={"librenms_id": librenms_device_id},
)
from ..utils import set_librenms_device_id

# Create the VM and assign its LibreNMS ID atomically so a failure in
# set_librenms_device_id never leaves a VM without a mapping.
with transaction.atomic():
vm = VirtualMachine.objects.create(
name=vm_name,
cluster=cluster,
role=role, # Optional VM role
platform=platform,
comments=f"Imported from LibreNMS (device_id={librenms_device_id}) by netbox-librenms-plugin on {import_time}",
)
set_librenms_device_id(vm, librenms_device_id, server_key)
vm.save()
Comment thread
coderabbitai[bot] marked this conversation as resolved.

logger.info(f"Created VM {vm.name} (ID: {vm.pk}) from LibreNMS device {libre_device['device_id']}")
return vm
Expand Down Expand Up @@ -169,6 +178,7 @@ def bulk_import_vms(
api=api,
use_sysname=use_sysname_opt,
strip_domain=strip_domain_opt,
server_key=api.server_key,
)

# Check if VM already exists
Expand Down Expand Up @@ -213,7 +223,9 @@ def bulk_import_vms(
libre_device["_computed_name"] = vm_name

# Create VM
vm = create_vm_from_librenms(libre_device, validation, use_sysname=use_sysname, role=role)
vm = create_vm_from_librenms(
libre_device, validation, use_sysname=use_sysname, role=role, server_key=api.server_key
)

result["success"].append(
{
Expand Down
19 changes: 8 additions & 11 deletions netbox_librenms_plugin/librenms_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,16 +190,11 @@ def get_librenms_id(self, obj):
If found via API, stores ID in custom field if available,
otherwise caches the value.
"""
librenms_id = obj.cf.get("librenms_id")
if librenms_id is not None:
if isinstance(librenms_id, str):
try:
librenms_id = int(librenms_id)
self._store_librenms_id(obj, librenms_id)
except (ValueError, TypeError):
librenms_id = None # empty or invalid string — fall through to discovery
if librenms_id:
return librenms_id
from netbox_librenms_plugin.utils import get_librenms_device_id

librenms_id = get_librenms_device_id(obj, self.server_key)
if librenms_id:
return librenms_id

# Check cache
cache_key = self._get_cache_key(obj)
Expand Down Expand Up @@ -261,7 +256,9 @@ def _store_librenms_id(self, obj, librenms_id):
None
"""
if "librenms_id" in obj.cf:
obj.custom_field_data["librenms_id"] = librenms_id
from netbox_librenms_plugin.utils import set_librenms_device_id

set_librenms_device_id(obj, librenms_id, self.server_key)
obj.save()
else:
# Use cache as fallback
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,8 @@ function initializeVlanModalSave() {
},
body: JSON.stringify({
device_id: deviceId,
vid_group_map: vidGroupMap
vid_group_map: vidGroupMap,
server_key: document.getElementById('current-server-key')?.value || null
})
}).then(response => {
if (!response.ok) {
Expand Down Expand Up @@ -774,7 +775,8 @@ function handleVRFChange(select, value) {
body: JSON.stringify({
device_id: deviceId,
ip_address: fullIpAddress, // Use full IP address with prefix
vrf_id: value
vrf_id: value,
server_key: document.getElementById('current-server-key')?.value || null
})
})
.then(response => {
Expand Down Expand Up @@ -813,7 +815,8 @@ function handleInterfaceChange(select, value) {
body: JSON.stringify({
device_id: value,
interface_name: select.dataset.interface,
interface_name_field: document.querySelector('input[name="interface_name_field"]:checked')?.value || null
interface_name_field: document.querySelector('input[name="interface_name_field"]:checked')?.value || null,
server_key: document.getElementById('current-server-key')?.value || null
})
})
.then(response => {
Expand Down Expand Up @@ -859,7 +862,8 @@ function handleCableChange(select, value) {
},
body: JSON.stringify({
device_id: value,
local_port_id: select.dataset.interface
local_port_id: select.dataset.interface,
server_key: document.getElementById('current-server-key')?.value || null
})
})
.then(response => {
Expand Down
6 changes: 4 additions & 2 deletions netbox_librenms_plugin/tables/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
convert_speed_to_kbps,
format_mac_address,
get_interface_name_field,
get_librenms_device_id,
get_missing_vlan_warning,
get_table_paginate_count,
get_tagged_vlan_css_class,
Expand Down Expand Up @@ -46,11 +47,12 @@ class Meta:
"id": "librenms-interface-table",
}

def __init__(self, *args, device=None, interface_name_field=None, vlan_groups=None, **kwargs):
def __init__(self, *args, device=None, interface_name_field=None, vlan_groups=None, server_key="default", **kwargs):
"""Initialize table with device context and interface name field."""
self.device = device
self.interface_name_field = interface_name_field or get_interface_name_field()
self.vlan_groups = vlan_groups or []
self.server_key = server_key
Comment on lines +50 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Make server_key mandatory for this table.

render_librenms_id() now depends on self.server_key for the per-server lookup. Keeping a "default" fallback means any missed constructor update silently compares against the wrong LibreNMS server and renders false match/mismatch state instead of failing loudly.

Proposed hardening
-    def __init__(self, *args, device=None, interface_name_field=None, vlan_groups=None, server_key="default", **kwargs):
+    def __init__(self, *args, device=None, interface_name_field=None, vlan_groups=None, server_key=None, **kwargs):
         """Initialize table with device context and interface name field."""
         self.device = device
         self.interface_name_field = interface_name_field or get_interface_name_field()
         self.vlan_groups = vlan_groups or []
-        self.server_key = server_key
+        if server_key is None:
+            raise ValueError("server_key is required for LibreNMSInterfaceTable")
+        self.server_key = server_key
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@netbox_librenms_plugin/tables/interfaces.py` around lines 50 - 55,
Constructor for the Interfaces table currently defaults server_key to "default"
which hides missing caller updates; change the __init__ signature in
Interfaces.__init__ to require server_key (remove the default value), update the
docstring to document server_key as mandatory, and add an explicit ValueError
guard (e.g., if not server_key: raise ValueError("server_key is required")) to
fail fast; update any callers that instantiate this class to pass the
appropriate server_key so render_librenms_id will use the correct per-server
lookup.


# Update column accessors after initialization
for column in ["selection", "name"]:
Expand Down Expand Up @@ -360,7 +362,7 @@ def render_librenms_id(self, value, record):
if not netbox_interface:
return mark_safe(f'<span class="text-danger">{value}</span>')

netbox_librenms_id = netbox_interface.custom_field_data.get("librenms_id")
netbox_librenms_id = get_librenms_device_id(netbox_interface, self.server_key, auto_save=False)

if netbox_librenms_id is None:
return mark_safe(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
{% if cable_sync.table %}
<form method="post" action="{% url 'plugins:netbox_librenms_plugin:sync_device_cables' cable_sync.object.pk %}">
{% csrf_token %}
{% if cable_sync.server_key %}<input type="hidden" name="server_key" value="{{ cable_sync.server_key }}">{% endif %}
<input type="hidden" id="selected_port" name="select" value="">
<div class="noprint d-flex justify-content-between align-items-center mt-3 mb-3">
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
action="{% url 'plugins:netbox_librenms_plugin:sync_selected_interfaces' object_type=model_name object_id=interface_sync.object.pk %}?interface_name_field={{ interface_name_field }}">
{% endwith %}
{% csrf_token %}
{% if interface_sync.server_key %}<input type="hidden" id="current-server-key" name="server_key" value="{{ interface_sync.server_key }}">{% endif %}
{% block table_actions %}
<div class="noprint d-flex justify-content-between align-items-center mt-3 mb-3">
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<form method="post" action="{% url 'plugins:netbox_librenms_plugin:sync_device_ip_addresses' object_type=model_name pk=ip_sync.object.pk %}">
{% endwith %}
{% csrf_token %}
{% if ip_sync.server_key %}<input type="hidden" id="current-server-key" name="server_key" value="{{ ip_sync.server_key }}">{% endif %}
<input type="hidden" id="selected_ip" name="select" value="">
<div class="noprint d-flex justify-content-between align-items-center mt-3 mb-3">
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
action="{% url 'plugins:netbox_librenms_plugin:sync_selected_vlans' object_type=model_name object_id=vlan_sync.object.pk %}">
{% endwith %}
{% csrf_token %}
{% if vlan_sync.server_key %}<input type="hidden" name="server_key" value="{{ vlan_sync.server_key }}">{% endif %}
<input type="hidden" name="action" value="create_vlans">

<div class="noprint d-flex justify-content-between align-items-center mt-3 mb-3">
Expand Down
Loading