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
+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