first commit
Some checks failed
ci / deploy (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled

This commit is contained in:
Vlastislav Svatek
2026-06-05 10:39:05 +02:00
commit 673e67106e
217 changed files with 76612 additions and 0 deletions

View File

@@ -0,0 +1,693 @@
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.http import HttpResponse
from django.shortcuts import redirect
from django.utils.http import url_has_allowed_host_and_scheme
from utilities.permissions import get_permission_for_model
from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN, PERM_VIEW_PLUGIN
from netbox_librenms_plugin.librenms_api import LibreNMSAPI
def _get_safe_redirect_url(request):
"""
Return a validated redirect URL from the HTTP Referer header.
Validates the Referer against allowed hosts and schemes to prevent
open-redirect attacks. Falls back to the current request path or "/".
"""
referrer = request.META.get("HTTP_REFERER")
if referrer and url_has_allowed_host_and_scheme(
referrer,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return referrer
return getattr(request, "path", "/")
class LibreNMSPermissionMixin(PermissionRequiredMixin):
"""
Mixin for views requiring LibreNMS plugin permissions.
All plugin views require 'view_librenmssettings' to access the page.
Write actions require 'change_librenmssettings' plus any relevant
NetBox object permissions.
"""
permission_required = PERM_VIEW_PLUGIN
def has_write_permission(self):
"""Check if user can perform write actions."""
return self.request.user.has_perm(PERM_CHANGE_PLUGIN)
def require_write_permission(self, error_message=None):
"""
Check write permission and return error response if denied.
Handles both HTMX and regular requests appropriately:
- HTMX: Returns HX-Redirect to referrer with toast message
- Regular: Returns redirect to referrer with flash message
Returns:
None if permitted, or appropriate response if denied
"""
if not self.has_write_permission():
msg = error_message or "You do not have permission to perform this action."
messages.error(self.request, msg)
referrer = _get_safe_redirect_url(self.request)
# Check if this is an HTMX request
if self.request.headers.get("HX-Request"):
return HttpResponse("", headers={"HX-Redirect": referrer})
return redirect(referrer)
return None
def require_write_permission_json(self, error_message=None):
"""
Check write permission and return JSON error response if denied.
Use this method for AJAX/HTMX endpoints that return JsonResponse.
Does not set flash messages since JSON clients handle errors differently.
Returns:
None if permitted, or JsonResponse with 403 status if denied
"""
from django.http import JsonResponse
if not self.has_write_permission():
msg = error_message or "You do not have permission to perform this action."
return JsonResponse({"error": msg}, status=403)
return None
class NetBoxObjectPermissionMixin:
"""
Mixin for views requiring specific NetBox object permissions.
Define required_object_permissions as a dict mapping HTTP methods
to lists of (action, model) tuples.
Example:
required_object_permissions = {
'POST': [
('add', Interface),
('change', Interface),
],
}
"""
required_object_permissions = {}
def check_object_permissions(self, method):
"""
Check all required object permissions for the given HTTP method.
Args:
method: HTTP method (GET, POST, etc.)
Returns:
tuple: (has_all: bool, missing: list[str])
"""
requirements = self.required_object_permissions.get(method, [])
missing = []
for action, model in requirements:
perm = get_permission_for_model(model, action)
if not self.request.user.has_perm(perm):
missing.append(perm)
return (len(missing) == 0, missing)
def require_object_permissions(self, method):
"""
Require all object permissions for the method, returning error response if denied.
Handles both HTMX and regular requests appropriately:
- HTMX: Returns HX-Redirect to referrer with flash message
- Regular: Returns redirect to referrer with flash message
Returns:
None if permitted, or appropriate response if denied
"""
has_perms, missing = self.check_object_permissions(method)
if not has_perms:
missing_str = ", ".join(missing)
msg = f"Missing permissions: {missing_str}"
messages.error(self.request, msg)
referrer = _get_safe_redirect_url(self.request)
# Check if this is an HTMX request
if self.request.headers.get("HX-Request"):
return HttpResponse("", headers={"HX-Redirect": referrer})
return redirect(referrer)
return None
def require_object_permissions_json(self, method):
"""
Require all object permissions for the method, returning JSON error if denied.
Use this method for AJAX/HTMX endpoints that return JsonResponse.
Does not set flash messages since JSON clients handle errors differently.
Returns:
None if permitted, or JsonResponse with 403 status if denied
"""
from django.http import JsonResponse
has_perms, missing = self.check_object_permissions(method)
if not has_perms:
missing_str = ", ".join(missing)
return JsonResponse({"error": f"Missing permissions: {missing_str}"}, status=403)
return None
def require_all_permissions(self, method="POST"):
"""
Check both plugin write and NetBox object permissions.
Combines require_write_permission() and require_object_permissions()
into a single call. Handles HTMX and regular requests.
Returns:
None if permitted, or appropriate error response if denied
"""
if error := self.require_write_permission():
return error
return self.require_object_permissions(method)
def require_all_permissions_json(self, method="POST"):
"""
Check both plugin write and NetBox object permissions, returning JSON errors.
Combines require_write_permission_json() and require_object_permissions_json()
into a single call for JSON/AJAX endpoints.
Returns:
None if permitted, or JsonResponse with 403 status if denied
"""
if error := self.require_write_permission_json():
return error
return self.require_object_permissions_json(method)
class LibreNMSAPIMixin:
"""
A mixin class that provides access to the LibreNMS API.
This mixin initializes a LibreNMSAPI instance and provides a property
to access it. It's designed to be used with other view classes that
need to interact with the LibreNMS API.
Attributes:
_librenms_api (LibreNMSAPI): An instance of the LibreNMSAPI class.
Properties:
librenms_api (LibreNMSAPI): A property that returns the LibreNMSAPI instance,
creating it if it doesn't exist.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._librenms_api = None
@property
def librenms_api(self):
"""
Get or create an instance of LibreNMSAPI.
This property ensures that only one instance of LibreNMSAPI is created
and reused for subsequent calls. The API instance will use the currently
selected server from settings.
Returns:
LibreNMSAPI: An instance of the LibreNMSAPI class.
"""
if self._librenms_api is None:
# The LibreNMSAPI will automatically use the selected server
self._librenms_api = LibreNMSAPI()
return self._librenms_api
def get_server_info(self):
"""
Get information about the currently active LibreNMS server.
Returns:
dict: Server information including display name and URL
"""
try:
# Get the current server key
server_key = self.librenms_api.server_key
# Try to get multi-server configuration
from netbox.plugins import get_plugin_config
servers_config = get_plugin_config("netbox_librenms_plugin", "servers")
if servers_config and isinstance(servers_config, dict) and server_key in servers_config:
# Multi-server configuration
config = servers_config[server_key]
return {
"display_name": config.get("display_name", server_key),
"url": config["librenms_url"],
"is_legacy": False,
"server_key": server_key,
}
else:
# Legacy configuration
legacy_url = get_plugin_config("netbox_librenms_plugin", "librenms_url")
return {
"display_name": "Default Server",
"url": legacy_url or "Not configured",
"is_legacy": True,
"server_key": "default",
}
except (KeyError, AttributeError, ImportError):
return {
"display_name": "Unknown Server",
"url": "Configuration error",
"is_legacy": True,
"server_key": "unknown",
}
def get_context_data(self, **kwargs):
"""Add server info to context for all views using this mixin."""
try:
context = super().get_context_data(**kwargs)
except AttributeError:
context = kwargs
context["librenms_server_info"] = self.get_server_info()
return context
class CacheMixin:
"""
A mixin class that provides caching functionality.
"""
def get_cache_key(self, obj, data_type="ports", server_key=None):
"""
Get the cache key for the object.
Args:
obj: The object to cache data for
data_type: Type of data being cached ('ports', 'links', 'inventory', etc.)
server_key: Optional LibreNMS server key for namespacing per-server data
"""
model_name = obj._meta.model_name
base = f"librenms_{data_type}_{model_name}_{obj.pk}"
if server_key:
return f"{base}_{server_key}"
return base
def get_last_fetched_key(self, obj, data_type="ports", server_key=None):
"""
Get the cache key for the last fetched time of the object.
"""
model_name = obj._meta.model_name
base = f"librenms_{data_type}_last_fetched_{model_name}_{obj.pk}"
if server_key:
return f"{base}_{server_key}"
return base
def get_vlan_overrides_key(self, obj, server_key=None):
"""
Get the cache key for user VLAN group override selections.
Stores a {vid_str: group_id_str} map so that "apply to all" VLAN
group choices persist across table pages. Including server_key scopes
overrides per-server to avoid leakage when multiple servers are configured.
"""
model_name = obj._meta.model_name
if server_key:
return f"librenms_vlan_group_overrides_{model_name}_{obj.pk}_{server_key}"
return f"librenms_vlan_group_overrides_{model_name}_{obj.pk}"
class VlanAssignmentMixin:
"""
Mixin providing VLAN assignment utilities for views.
Provides methods for:
- Getting relevant VLAN groups for a device based on scope hierarchy
- Building lookup maps for VLAN matching
- Selecting the most specific VLAN group based on device context
- Finding VLANs by VID within a specific group
- Updating interface VLAN assignments
"""
def get_vlan_groups_for_device(self, device):
"""
Get all VLAN groups relevant to this device.
Searches for VLAN groups scoped to:
- Site: The device's assigned site
- Location: The device's location and all parent locations
- Region: The device's site's region and all parent regions
- Site Group: The device's site's group and all parent site groups
- Rack: The device's rack
- Global: VLAN groups with no scope
Returns:
List of VLANGroup objects, deduplicated and sorted by name
"""
from dcim.models import Location, Rack, Region, Site, SiteGroup
from ipam.models import VLANGroup
groups = set()
# Site-scoped VLAN groups
if hasattr(device, "site") and device.site:
site_groups = self._get_vlan_groups_for_scope(Site, [device.site])
groups.update(site_groups)
# Region-scoped VLAN groups (site's region and ancestors)
if device.site.region:
region_ancestors = self._get_ancestors(device.site.region)
region_groups = self._get_vlan_groups_for_scope(Region, region_ancestors)
groups.update(region_groups)
# Site Group-scoped VLAN groups (site's group and ancestors)
if device.site.group:
site_group_ancestors = self._get_ancestors(device.site.group)
site_group_groups = self._get_vlan_groups_for_scope(SiteGroup, site_group_ancestors)
groups.update(site_group_groups)
# Location-scoped VLAN groups (device's location and ancestors)
if hasattr(device, "location") and device.location:
location_ancestors = self._get_ancestors(device.location)
location_groups = self._get_vlan_groups_for_scope(Location, location_ancestors)
groups.update(location_groups)
# Rack-scoped VLAN groups
if hasattr(device, "rack") and device.rack:
rack_groups = self._get_vlan_groups_for_scope(Rack, [device.rack])
groups.update(rack_groups)
# Global VLAN groups (no scope)
global_groups = VLANGroup.objects.filter(scope_type__isnull=True)
groups.update(global_groups)
# Return sorted by name for consistent display
return sorted(groups, key=lambda g: g.name.lower())
def _build_vlan_lookup_maps(self, vlan_groups):
"""
Build lookup dictionaries for VLAN matching.
Returns a dict with:
- vid_to_groups: {vid: [vlan_group, ...]} - VID to groups containing that VID
- vid_group_to_vlan: {(vid, group_id): vlan} - unique per group lookup
- vid_to_vlans: {vid: [vlan, ...]} - all VLANs with that VID
- vid_name_to_vlan: {(vid, name): vlan} - VID + name lookup
"""
from ipam.models import VLAN
vid_to_groups = {}
vid_group_to_vlan = {}
vid_to_vlans = {}
vid_name_to_vlan = {}
# Get all VLANs from relevant groups and global VLANs
group_pks = [g.pk for g in vlan_groups]
vlans = VLAN.objects.filter(group__pk__in=group_pks).select_related("group")
# Also get global VLANs (no group)
global_vlans = VLAN.objects.filter(group__isnull=True)
for vlan in list(vlans) + list(global_vlans):
vid = vlan.vid
group = vlan.group
group_id = group.pk if group else None
name = vlan.name
# Build VID to groups lookup for ambiguity detection (group VLANs only)
if group:
if vid not in vid_to_groups:
vid_to_groups[vid] = []
if group not in vid_to_groups[vid]:
vid_to_groups[vid].append(group)
# Build (vid, group_id) to vlan lookup
vid_group_to_vlan[(vid, group_id)] = vlan
# Build VID to all VLANs list (for dropdown options)
if vid not in vid_to_vlans:
vid_to_vlans[vid] = []
vid_to_vlans[vid].append(vlan)
# Build (vid, name) to vlan lookup
vid_name_to_vlan[(vid, name)] = vlan
return {
"vid_to_groups": vid_to_groups,
"vid_group_to_vlan": vid_group_to_vlan,
"vid_to_vlans": vid_to_vlans,
"vid_name_to_vlan": vid_name_to_vlan,
}
def _select_most_specific_group(self, groups, device):
"""
Select the most specific VLAN group based on device context.
Priority order (most specific to least specific):
1. Rack-scoped (device's rack)
2. Location-scoped (device's location, closer ancestors win)
3. Site-scoped (device's site)
4. Site Group-scoped (device's site's group, closer ancestors win)
5. Region-scoped (device's site's region, closer ancestors win)
6. Global (no scope)
Args:
groups: List of VLANGroup objects that all contain the same VID
device: NetBox Device object
Returns:
VLANGroup or None if no clear winner (e.g., multiple groups at same priority level)
"""
from dcim.models import Location, Rack, Region, Site, SiteGroup
from django.contrib.contenttypes.models import ContentType
if not device or not groups:
return None
# Build scope priority lookup for this device
# Lower number = higher priority (more specific)
scope_priority = {}
priority = 0
# Priority 1: Rack (most specific)
if hasattr(device, "rack") and device.rack:
rack_ct = ContentType.objects.get_for_model(Rack)
scope_priority[(rack_ct.pk, device.rack.pk)] = priority
priority += 1
# Priority 2: Location hierarchy (device's location first, then ancestors)
if hasattr(device, "location") and device.location:
location_ct = ContentType.objects.get_for_model(Location)
for loc in self._get_ancestors(device.location):
scope_priority[(location_ct.pk, loc.pk)] = priority
priority += 1
# Priority 3: Site
if hasattr(device, "site") and device.site:
site_ct = ContentType.objects.get_for_model(Site)
scope_priority[(site_ct.pk, device.site.pk)] = priority
priority += 1
# Priority 4: Site Group hierarchy
if device.site.group:
site_group_ct = ContentType.objects.get_for_model(SiteGroup)
for sg in self._get_ancestors(device.site.group):
scope_priority[(site_group_ct.pk, sg.pk)] = priority
priority += 1
# Priority 5: Region hierarchy
if device.site.region:
region_ct = ContentType.objects.get_for_model(Region)
for reg in self._get_ancestors(device.site.region):
scope_priority[(region_ct.pk, reg.pk)] = priority
priority += 1
# Priority 6: Global (no scope) - lowest priority
global_priority = priority
# Find the group with the highest priority (lowest number)
best_group = None
best_priority = float("inf")
same_priority_count = 0
for group in groups:
if group.scope_type is None:
# Global scope
group_priority = global_priority
else:
scope_key = (group.scope_type.pk, group.scope_id)
group_priority = scope_priority.get(scope_key, float("inf"))
if group_priority < best_priority:
best_priority = group_priority
best_group = group
same_priority_count = 1
elif group_priority == best_priority:
same_priority_count += 1
# Only return a group if there's a single winner at the best priority level
if same_priority_count == 1 and best_group is not None:
return best_group
return None
def _get_ancestors(self, obj):
"""
Get all ancestors of a hierarchical object (location, region, site group).
Returns list including the object itself and all parents up to root.
"""
ancestors = []
current = obj
while current is not None:
ancestors.append(current)
current = getattr(current, "parent", None)
return ancestors
def _get_vlan_groups_for_scope(self, model_class, objects):
"""
Get VLAN groups scoped to any of the given objects.
Args:
model_class: The Django model class (Site, Location, Region, etc.)
objects: List of model instances to check
Returns:
QuerySet of VLANGroup objects
"""
from django.contrib.contenttypes.models import ContentType
from ipam.models import VLANGroup
if not objects:
return VLANGroup.objects.none()
content_type = ContentType.objects.get_for_model(model_class)
object_ids = [obj.pk for obj in objects if obj is not None and obj.pk is not None]
if not object_ids:
return VLANGroup.objects.none()
return VLANGroup.objects.filter(scope_type=content_type, scope_id__in=object_ids)
def _find_vlan_in_group(self, vid, vlan_group_id, lookup_maps):
"""
Find a VLAN by VID, preferring the specified group.
Args:
vid: VLAN ID (integer)
vlan_group_id: Optional VLAN group ID to prefer
lookup_maps: Dict from _build_vlan_lookup_maps()
Returns:
VLAN object or None
"""
vid_group_to_vlan = lookup_maps.get("vid_group_to_vlan", {})
vid_to_vlans = lookup_maps.get("vid_to_vlans", {})
# Try specific group first
if vlan_group_id:
try:
vlan = vid_group_to_vlan.get((vid, int(vlan_group_id)))
if vlan:
return vlan
except (ValueError, TypeError):
pass
# Try global (no group)
vlan = vid_group_to_vlan.get((vid, None))
if vlan:
return vlan
# Fallback: first matching VLAN
vlans = vid_to_vlans.get(vid, [])
return vlans[0] if vlans else None
def _update_interface_vlan_assignment(self, interface, vlan_data, vlan_group_map, lookup_maps):
"""
Update interface VLAN assignments in NetBox (mode, untagged_vlan, tagged_vlans).
Args:
interface: NetBox Interface or VMInterface object
vlan_data: Dict with 'untagged_vlan' (int or None) and 'tagged_vlans' (list of ints)
vlan_group_map: Dict mapping VID (str) to VLAN group ID for per-VLAN group lookups.
Can also be a single group ID string for backward compat.
lookup_maps: Dict from _build_vlan_lookup_maps()
Returns:
Dict with sync results:
- mode_set: str or None
- untagged_set: VLAN object or None
- tagged_set: list of VLAN objects
- missing_vlans: list of VIDs not found in NetBox
"""
# Support both dict (per-VLAN) and string/int/None (single group) for backward compat
if not isinstance(vlan_group_map, dict):
single_group_id = vlan_group_map
vlan_group_map = None
else:
single_group_id = None
untagged_vid = vlan_data.get("untagged_vlan")
tagged_vids = vlan_data.get("tagged_vlans", [])
missing_vlans = []
def _get_group_id_for_vid(vid):
"""Resolve the VLAN group ID for a specific VID."""
if vlan_group_map is not None:
return vlan_group_map.get(str(vid), "")
return single_group_id or ""
# Determine mode
if tagged_vids:
interface.mode = "tagged"
elif untagged_vid:
interface.mode = "access"
else:
# No VLANs - clear mode
interface.mode = ""
# Set untagged VLAN
untagged_set = None
if untagged_vid:
vlan = self._find_vlan_in_group(untagged_vid, _get_group_id_for_vid(untagged_vid), lookup_maps)
if vlan:
interface.untagged_vlan = vlan
untagged_set = vlan
else:
missing_vlans.append(untagged_vid)
interface.untagged_vlan = None
else:
interface.untagged_vlan = None
# Save mode + untagged_vlan before M2M operations.
# tagged_vlans.set() triggers a DB refresh that wipes unsaved
# in-memory attributes, so we must persist first.
interface.save()
# Set tagged VLANs (M2M - requires the instance to be saved first)
tagged_set = []
if tagged_vids:
for vid in tagged_vids:
vlan = self._find_vlan_in_group(vid, _get_group_id_for_vid(vid), lookup_maps)
if vlan:
tagged_set.append(vlan)
else:
missing_vlans.append(vid)
interface.tagged_vlans.set(tagged_set)
else:
interface.tagged_vlans.clear()
return {
"mode_set": interface.mode,
"untagged_set": untagged_set,
"tagged_set": tagged_set,
"missing_vlans": missing_vlans,
}