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
This commit is contained in:
2026-07-23 11:06:50 +02:00
commit 24e59ee9c0
16 changed files with 461 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
*.egg-info/
.build-wheel/
build/
dist/
.pytest_cache/
+15
View File
@@ -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.
+52
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
from .config import BetterIPsConfig
__version__ = "1.0.0"
config = BetterIPsConfig
+24
View File
@@ -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
+29
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -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."))
+10
View File
@@ -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"],
),
)
+91
View File
@@ -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
+22
View File
@@ -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)
@@ -0,0 +1,58 @@
{% extends 'base/layout.html' %}
{% load helpers %}
{% block title %}IP-Netzübersicht{% endblock %}
{% block header %}
<div class="row align-items-center">
<div class="col"><h1>IP-Netzübersicht</h1></div>
<div class="col-auto text-secondary">{{ total_count }} Netze</div>
</div>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-xl-9">
<div class="card">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead><tr><th>Netz</th><th>VRF</th><th>Scope</th><th>Mandant</th><th>Status</th><th>Beschreibung</th></tr></thead>
<tbody>
{% for prefix in page_obj %}
<tr>
<td><a href="{{ prefix.get_absolute_url }}"><code>{{ prefix.prefix }}</code></a></td>
<td>{{ prefix.vrf|placeholder }}</td>
<td>{{ prefix.scope|placeholder }}</td>
<td>{{ prefix.tenant|placeholder }}</td>
<td><span class="badge text-bg-{{ prefix.get_status_color }}">{{ prefix.get_status_display }}</span></td>
<td>{{ prefix.description|placeholder }}</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-center text-secondary py-4">Keine passenden Netze gefunden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if page_obj.has_other_pages %}
<nav class="mt-3"><ul class="pagination">
{% if page_obj.has_previous %}<li class="page-item"><a class="page-link" href="?{% querystring request page=page_obj.previous_page_number %}">Zurück</a></li>{% endif %}
<li class="page-item disabled"><span class="page-link">Seite {{ page_obj.number }} / {{ page_obj.paginator.num_pages }}</span></li>
{% if page_obj.has_next %}<li class="page-item"><a class="page-link" href="?{% querystring request page=page_obj.next_page_number %}">Weiter</a></li>{% endif %}
</ul></nav>
{% endif %}
</div>
<div class="col-xl-3">
<div class="card">
<h2 class="card-header">Filter</h2>
<div class="card-body">
<form method="get">
{% for field in filter_form %}<div class="mb-3">{{ field.label_tag }}{{ field }}</div>{% endfor %}
<div class="d-flex gap-2"><button class="btn btn-primary" type="submit">Filtern</button><a class="btn btn-outline-secondary" href=".">Zurücksetzen</a></div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+10
View File
@@ -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"),
]
+89
View File
@@ -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
+24
View File
@@ -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"]