commit 24e59ee9c0278496a4363c27db7e91e7c5f3cbe5 Author: Louis Date: Thu Jul 23 11:06:50 2026 +0200 feat: automatische Prefix-Zuordnung und IP-Netzübersicht hinzufügen - fehlende Prefixe beim Speichern von IP-Adressen automatisch erstellen - VRF, Mandant, Standort und Lokation übernehmen - bestehende IP-Adressen per Management-Command abgleichen - filterbare IP-Netzübersicht für Regionen, Standorte und Mandanten ergänzen - NetBox 4.6.5 unterstützen diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8a479f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.build-wheel/ +build/ +dist/ +.pytest_cache/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..655ca55 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +Apache License 2.0 + +Copyright 2026 NetBox Better IPs contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..741c80e --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# NetBox Better IPs + +Plugin für **NetBox 4.6.5** mit zwei Funktionen: + +- Beim Speichern einer IP-Adresse wird das durch deren CIDR-Maske beschriebene Prefix gesucht und, falls es fehlt, automatisch erstellt. NetBox ordnet die IP danach nativ anhand von Prefix und VRF ein. +- Eine zusätzliche IP-Netzübersicht kann nach Organisation/Region, Standortgruppe, Standort, Lokation, Mandantengruppe und Mandant gefiltert werden. + +## Installation + +```bash +/opt/netbox/venv/bin/pip install /pfad/zu/Netbox-Better-IPs +``` + +In `configuration.py`: + +```python +PLUGINS = ["netbox_better_ips"] + +PLUGINS_CONFIG = { + "netbox_better_ips": { + "auto_create_prefix": True, + "prefix_status": "active", + "inherit_scope": True, + "inherit_tenant": True, + "ignore_host_prefixes": True, + } +} +``` + +Danach NetBox neu starten. Das Plugin hat keine eigenen Datenbankmodelle und benötigt daher keine Migration. + +## Verhalten + +Aus `192.0.2.17/24` wird bei Bedarf `192.0.2.0/24` in derselben VRF erstellt. Bei einer Interface-Zuweisung wird bevorzugt die Lokation, sonst der Standort als Prefix-Scope übernommen. Der Mandant wird von der IP oder dem zugewiesenen Gerät/der VM übernommen. Vorhandene Prefix-Metadaten werden nie überschrieben. + +Hostmasken (`/32`, `/128`) werden standardmäßig nicht als Prefix erstellt. Dies kann mit `ignore_host_prefixes=False` geändert werden. + +Bereits vorhandene IP-Adressen lassen sich abgleichen: + +```bash +python /opt/netbox/netbox/manage.py reconcile_ip_prefixes --dry-run +python /opt/netbox/netbox/manage.py reconcile_ip_prefixes +``` + +## Berechtigungen + +Für die Übersicht ist die NetBox-Berechtigung `ipam.view_prefix` erforderlich. Die automatische Erstellung läuft serverseitig; stellen Sie sicher, dass dies zu Ihrem Berechtigungs- und Change-Control-Konzept passt. + +## Hinweis zu „Organisation" + +NetBox besitzt kein separates Core-Modell namens Organisation. Die Übersicht bildet Organisation auf NetBox-`Region` ab und bezieht untergeordnete Regionen sowie deren Standorte und Lokationen ein. + diff --git a/netbox_better_ips/__init__.py b/netbox_better_ips/__init__.py new file mode 100644 index 0000000..6e36b60 --- /dev/null +++ b/netbox_better_ips/__init__.py @@ -0,0 +1,5 @@ +from .config import BetterIPsConfig + +__version__ = "1.0.0" +config = BetterIPsConfig + diff --git a/netbox_better_ips/config.py b/netbox_better_ips/config.py new file mode 100644 index 0000000..c523773 --- /dev/null +++ b/netbox_better_ips/config.py @@ -0,0 +1,24 @@ +from netbox.plugins import PluginConfig + + +class BetterIPsConfig(PluginConfig): + name = "netbox_better_ips" + verbose_name = "NetBox Better IPs" + description = "Automatische Prefix-Zuordnung und erweiterte IP-Netzübersicht" + author = "LKE" + version = "1.0.0" + base_url = "better-ips" + min_version = "4.6.5" + max_version = "4.6.99" + default_settings = { + "auto_create_prefix": True, + "prefix_status": "active", + "inherit_scope": True, + "inherit_tenant": True, + "ignore_host_prefixes": True, + } + + def ready(self): + super().ready() + from . import signals # noqa: F401 + diff --git a/netbox_better_ips/forms.py b/netbox_better_ips/forms.py new file mode 100644 index 0000000..8a9bbbc --- /dev/null +++ b/netbox_better_ips/forms.py @@ -0,0 +1,29 @@ +from django import forms +from dcim.models import Location, Region, Site, SiteGroup +from tenancy.models import Tenant, TenantGroup + + +class NetworkOverviewFilterForm(forms.Form): + q = forms.CharField(required=False, label="Suche") + organization = forms.ModelChoiceField( + queryset=Region.objects.all(), required=False, label="Organisation / Region" + ) + site_group = forms.ModelChoiceField( + queryset=SiteGroup.objects.all(), required=False, label="Standortgruppe" + ) + site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label="Standort") + location = forms.ModelChoiceField( + queryset=Location.objects.all(), required=False, label="Lokation" + ) + tenant_group = forms.ModelChoiceField( + queryset=TenantGroup.objects.all(), required=False, label="Mandantengruppe" + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), required=False, label="Mandant" + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + css_class = "form-select" if isinstance(field.widget, forms.Select) else "form-control" + field.widget.attrs["class"] = css_class diff --git a/netbox_better_ips/management/__init__.py b/netbox_better_ips/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/netbox_better_ips/management/__init__.py @@ -0,0 +1 @@ + diff --git a/netbox_better_ips/management/commands/__init__.py b/netbox_better_ips/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/netbox_better_ips/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/netbox_better_ips/management/commands/reconcile_ip_prefixes.py b/netbox_better_ips/management/commands/reconcile_ip_prefixes.py new file mode 100644 index 0000000..d62ab36 --- /dev/null +++ b/netbox_better_ips/management/commands/reconcile_ip_prefixes.py @@ -0,0 +1,23 @@ +from django.core.management.base import BaseCommand +from ipam.models import IPAddress + +from netbox_better_ips.services import ensure_prefix_for_ip + + +class Command(BaseCommand): + help = "Create missing exact prefixes for existing IP addresses" + + def add_arguments(self, parser): + parser.add_argument("--dry-run", action="store_true") + + def handle(self, *args, **options): + created = skipped = 0 + for address in IPAddress.objects.select_related("tenant", "assigned_object_type").iterator(): + prefix, was_created = ensure_prefix_for_ip(address) + if options["dry_run"] and was_created: + prefix.delete() + created += int(was_created) + skipped += int(prefix is None) + verb = "würden erstellt" if options["dry_run"] else "erstellt" + self.stdout.write(self.style.SUCCESS(f"{created} Prefixe {verb}; {skipped} Host-Präfixe ignoriert.")) + diff --git a/netbox_better_ips/navigation.py b/netbox_better_ips/navigation.py new file mode 100644 index 0000000..27b1775 --- /dev/null +++ b/netbox_better_ips/navigation.py @@ -0,0 +1,10 @@ +from netbox.plugins import PluginMenuItem + +menu_items = ( + PluginMenuItem( + link="plugins:netbox_better_ips:network_overview", + link_text="IP-Netzübersicht", + permissions=["ipam.view_prefix"], + ), +) + diff --git a/netbox_better_ips/services.py b/netbox_better_ips/services.py new file mode 100644 index 0000000..840d236 --- /dev/null +++ b/netbox_better_ips/services.py @@ -0,0 +1,91 @@ +import ipaddress +import logging + +from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from ipam.models import Prefix +from netbox.plugins import get_plugin_config + +logger = logging.getLogger(__name__) + + +def network_for_address(address): + """Return the canonical network encoded by an IPAddress.address value.""" + return ipaddress.ip_interface(str(address)).network + + +def _scope_from_ip(ip): + """Derive the most specific supported Prefix scope from an IP assignment.""" + assigned = getattr(ip, "assigned_object", None) + if assigned is None: + return None + + parent = ( + getattr(assigned, "parent_object", None) + or getattr(assigned, "device", None) + or getattr(assigned, "virtual_machine", None) + ) + if parent is None: + return None + + location = getattr(parent, "location", None) + if location is not None: + return location + + site = getattr(parent, "site", None) + if site is not None: + return site + + cluster = getattr(parent, "cluster", None) + return getattr(cluster, "scope", None) if cluster is not None else None + + +def _tenant_from_ip(ip): + tenant = getattr(ip, "tenant", None) + if tenant is not None: + return tenant + assigned = getattr(ip, "assigned_object", None) + parent = ( + getattr(assigned, "parent_object", None) + or getattr(assigned, "device", None) + or getattr(assigned, "virtual_machine", None) + or assigned + ) + return getattr(parent, "tenant", None) + + +@transaction.atomic +def ensure_prefix_for_ip(ip, *, config=None): + """Get or create the exact Prefix represented by an IP address and its mask. + + NetBox derives hierarchy from address/prefix + VRF. There is deliberately no + parent FK to update on IPAddress; creating the missing Prefix is the assignment. + """ + settings = config or { + key: get_plugin_config("netbox_better_ips", key) + for key in ( + "prefix_status", "inherit_scope", "inherit_tenant", "ignore_host_prefixes" + ) + } + network = network_for_address(ip.address) + if settings.get("ignore_host_prefixes", True) and network.prefixlen == network.max_prefixlen: + return None, False + + lookup = {"prefix": str(network), "vrf_id": ip.vrf_id} + existing = Prefix.objects.filter(**lookup).first() + if existing: + return existing, False + + defaults = {"status": settings.get("prefix_status", "active")} + if settings.get("inherit_tenant", True): + defaults["tenant"] = _tenant_from_ip(ip) + if settings.get("inherit_scope", True): + scope = _scope_from_ip(ip) + if scope is not None: + defaults["scope_type"] = ContentType.objects.get_for_model(scope) + defaults["scope_id"] = scope.pk + + prefix, created = Prefix.objects.get_or_create(defaults=defaults, **lookup) + if created: + logger.info("Created missing prefix %s for IP address %s", prefix, ip) + return prefix, created diff --git a/netbox_better_ips/signals.py b/netbox_better_ips/signals.py new file mode 100644 index 0000000..28e676f --- /dev/null +++ b/netbox_better_ips/signals.py @@ -0,0 +1,22 @@ +import logging + +from django.db.models.signals import post_save +from django.dispatch import receiver +from ipam.models import IPAddress +from netbox.plugins import get_plugin_config + +from .services import ensure_prefix_for_ip + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender=IPAddress, dispatch_uid="netbox_better_ips.ensure_prefix") +def ensure_ip_prefix(sender, instance, raw=False, **kwargs): + if raw or not get_plugin_config("netbox_better_ips", "auto_create_prefix"): + return + try: + ensure_prefix_for_ip(instance) + except Exception: + # The IP save must not be rolled back by an optional convenience feature. + logger.exception("Could not ensure a prefix for IP address %s", instance) + diff --git a/netbox_better_ips/templates/netbox_better_ips/network_overview.html b/netbox_better_ips/templates/netbox_better_ips/network_overview.html new file mode 100644 index 0000000..e18a4b6 --- /dev/null +++ b/netbox_better_ips/templates/netbox_better_ips/network_overview.html @@ -0,0 +1,58 @@ +{% extends 'base/layout.html' %} +{% load helpers %} + +{% block title %}IP-Netzübersicht{% endblock %} + +{% block header %} +
+

IP-Netzübersicht

+
{{ total_count }} Netze
+
+{% endblock %} + +{% block content %} +
+
+
+
+ + + + {% for prefix in page_obj %} + + + + + + + + + {% empty %} + + {% endfor %} + +
NetzVRFScopeMandantStatusBeschreibung
{{ prefix.prefix }}{{ prefix.vrf|placeholder }}{{ prefix.scope|placeholder }}{{ prefix.tenant|placeholder }}{{ prefix.get_status_display }}{{ prefix.description|placeholder }}
Keine passenden Netze gefunden.
+
+
+ {% if page_obj.has_other_pages %} + + {% endif %} +
+
+
+

Filter

+
+
+ {% for field in filter_form %}
{{ field.label_tag }}{{ field }}
{% endfor %} + +
+
+
+
+
+{% endblock %} + diff --git a/netbox_better_ips/urls.py b/netbox_better_ips/urls.py new file mode 100644 index 0000000..f7481e8 --- /dev/null +++ b/netbox_better_ips/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import NetworkOverviewView + +app_name = "netbox_better_ips" + +urlpatterns = [ + path("networks/", NetworkOverviewView.as_view(), name="network_overview"), +] + diff --git a/netbox_better_ips/views.py b/netbox_better_ips/views.py new file mode 100644 index 0000000..fe028a8 --- /dev/null +++ b/netbox_better_ips/views.py @@ -0,0 +1,89 @@ +from django.contrib.contenttypes.models import ContentType +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.core.paginator import Paginator +from django.db.models import Q +from django.views.generic import TemplateView +from dcim.models import Location, Region, Site, SiteGroup +from ipam.models import Prefix +import netaddr + +from .forms import NetworkOverviewFilterForm + + +def _descendant_ids(obj): + """Return an object's ID plus nested-tree descendants across NetBox versions.""" + try: + return list(obj.get_descendants(include_self=True).values_list("pk", flat=True)) + except (AttributeError, TypeError): + ids = list(obj.get_descendants().values_list("pk", flat=True)) + return [obj.pk, *ids] + + +def _scope_query(model, ids): + content_type = ContentType.objects.get_for_model(model) + return Q(scope_type=content_type, scope_id__in=ids) + + +def apply_overview_filters(queryset, data): + if q := data.get("q"): + search = Q(description__icontains=q) | Q(comments__icontains=q) + try: + search |= Q(prefix__net_contains_or_equals=str(netaddr.IPNetwork(q))) + except (netaddr.AddrFormatError, ValueError): + pass + queryset = queryset.filter(search) + if tenant := data.get("tenant"): + queryset = queryset.filter(tenant=tenant) + if group := data.get("tenant_group"): + queryset = queryset.filter(tenant__group_id__in=_descendant_ids(group)) + if location := data.get("location"): + queryset = queryset.filter(_scope_query(Location, _descendant_ids(location))) + if site := data.get("site"): + location_ids = Location.objects.filter(site=site).values_list("pk", flat=True) + queryset = queryset.filter( + _scope_query(Site, [site.pk]) | _scope_query(Location, location_ids) + ) + if site_group := data.get("site_group"): + site_ids = Site.objects.filter(group_id__in=_descendant_ids(site_group)).values_list( + "pk", flat=True + ) + location_ids = Location.objects.filter(site_id__in=site_ids).values_list("pk", flat=True) + queryset = queryset.filter( + _scope_query(SiteGroup, _descendant_ids(site_group)) + | _scope_query(Site, site_ids) + | _scope_query(Location, location_ids) + ) + if region := data.get("organization"): + region_ids = _descendant_ids(region) + site_ids = Site.objects.filter(region_id__in=region_ids).values_list("pk", flat=True) + location_ids = Location.objects.filter(site_id__in=site_ids).values_list("pk", flat=True) + queryset = queryset.filter( + _scope_query(Region, region_ids) + | _scope_query(Site, site_ids) + | _scope_query(Location, location_ids) + ) + return queryset + + +class NetworkOverviewView(PermissionRequiredMixin, TemplateView): + template_name = "netbox_better_ips/network_overview.html" + permission_required = "ipam.view_prefix" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + form = NetworkOverviewFilterForm(self.request.GET or None) + prefixes = Prefix.objects.restrict(self.request.user, "view").select_related( + "vrf", "tenant", "scope_type" + ) + if form.is_valid(): + prefixes = apply_overview_filters(prefixes, form.cleaned_data) + prefixes = prefixes.order_by("prefix", "vrf") + paginator = Paginator(prefixes, 50) + context.update( + { + "filter_form": form, + "page_obj": paginator.get_page(self.request.GET.get("page")), + "total_count": prefixes.count(), + } + ) + return context diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..34d82f3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "netbox-better-ips" +version = "1.0.0" +description = "Automatic prefix creation and an organization-aware IP network overview for NetBox 4.6" +readme = "README.md" +requires-python = ">=3.12" +license = {text = "Apache-2.0"} +authors = [{name = "NetBox Better IPs contributors"}] +dependencies = [] +classifiers = [ + "Framework :: Django", + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", +] + +[tool.setuptools.packages.find] +include = ["netbox_better_ips*"] + +[tool.setuptools.package-data] +netbox_better_ips = ["templates/**/*.html"]