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:
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.build-wheel/
|
||||
build/
|
||||
dist/
|
||||
.pytest_cache/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from .config import BetterIPsConfig
|
||||
|
||||
__version__ = "1.0.0"
|
||||
config = BetterIPsConfig
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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."))
|
||||
|
||||
@@ -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"],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user