fix: IP-Bereiche statt Prefixe automatisch verwalten

- nutzbare Hostbereiche aus der CIDR-Maske erzeugen
- Netzwerk- und Broadcastadressen bei IPv4 ausschließen
- Abgleich-Aktion in die IP-Bereichsliste verschieben
- Gerät oder VM in der IP-Adressansicht anzeigen
- Bestandsabgleich für IP-Bereiche ergänzen
- Plugin-Version auf 1.2.0 erhöhen
This commit is contained in:
2026-07-23 12:08:59 +02:00
parent acb4e449bc
commit 8be6978d81
14 changed files with 88 additions and 107 deletions
+10 -11
View File
@@ -2,10 +2,10 @@
Plugin für **NetBox 4.6.5** mit zwei Funktionen: 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. - Beim Speichern einer IP-Adresse wird der durch deren CIDR-Maske beschriebene nutzbare IP-Bereich gesucht und, falls er fehlt, automatisch erstellt. Bei normalen IPv4-Subnetzen werden Netzwerk- und Broadcastadresse nicht in den Bereich aufgenommen.
- Eine zusätzliche IP-Netzübersicht kann nach Organisation/Region, Standortgruppe, Standort, Lokation, Mandantengruppe und Mandant gefiltert werden. - Eine zusätzliche IP-Netzübersicht kann nach Organisation/Region, Standortgruppe, Standort, Lokation, Mandantengruppe und Mandant gefiltert werden.
- Unter **IPAM → Prefixes/IP-Bereiche** legt die Aktion **Alle fehlenden Bereiche anlegen** die Netze für sämtliche bereits vorhandenen, sichtbaren IP-Adressen an. - Unter **IPAM → IP-Bereiche** legt die Aktion **Alle fehlenden Bereiche anlegen** die Bereiche für sämtliche bereits vorhandenen, sichtbaren IP-Adressen an.
- Unter **Prefix/IP-Bereich → IP-Adressen** wird das zugehörige Gerät beziehungsweise die VM standardmäßig als eigene Spalte angezeigt. - Unter **IP-Bereich → IP-Adressen** wird das zugehörige Gerät beziehungsweise die VM standardmäßig als eigene Spalte angezeigt.
## Installation ## Installation
@@ -24,9 +24,8 @@ PLUGINS = ["netbox_better_ips"]
PLUGINS_CONFIG = { PLUGINS_CONFIG = {
"netbox_better_ips": { "netbox_better_ips": {
"auto_create_prefix": True, "auto_create_range": True,
"prefix_status": "active", "range_status": "active",
"inherit_scope": True,
"inherit_tenant": True, "inherit_tenant": True,
"ignore_host_prefixes": True, "ignore_host_prefixes": True,
} }
@@ -37,22 +36,22 @@ Danach NetBox neu starten. Das Plugin hat keine eigenen Datenbankmodelle und ben
## Verhalten ## 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. Aus `192.0.2.17/24` wird bei Bedarf der IP-Bereich `192.0.2.1/24` bis `192.0.2.254/24` in derselben VRF erstellt. Der Mandant wird von der IP oder dem zugewiesenen Gerät/der VM übernommen. Vorhandene Bereichsmetadaten werden nie überschrieben.
Hostmasken (`/32`, `/128`) werden standardmäßig nicht als Prefix erstellt. Dies kann mit `ignore_host_prefixes=False` geändert werden. Hostmasken (`/32`, `/128`) werden standardmäßig nicht als eigener Bereich erstellt. Dies kann mit `ignore_host_prefixes=False` geändert werden.
Bereits vorhandene IP-Adressen lassen sich abgleichen: Bereits vorhandene IP-Adressen lassen sich abgleichen:
```bash ```bash
python /opt/netbox/netbox/manage.py reconcile_ip_prefixes --dry-run python /opt/netbox/netbox/manage.py reconcile_ip_ranges --dry-run
python /opt/netbox/netbox/manage.py reconcile_ip_prefixes python /opt/netbox/netbox/manage.py reconcile_ip_ranges
``` ```
## Berechtigungen ## 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. 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.
Die Aktion für Bestandsdaten benötigt `ipam.view_ipaddress` und `ipam.add_prefix`. Sie verarbeitet nur IP-Adressen, die der ausführende Benutzer sehen darf. Die Aktion für Bestandsdaten benötigt `ipam.view_ipaddress` und `ipam.add_iprange`. Sie verarbeitet nur IP-Adressen, die der ausführende Benutzer sehen darf.
## Hinweis zu „Organisation" ## Hinweis zu „Organisation"
+1 -1
View File
@@ -1,4 +1,4 @@
from .config import BetterIPsConfig from .config import BetterIPsConfig
__version__ = "1.1.0" __version__ = "1.2.0"
config = BetterIPsConfig config = BetterIPsConfig
+3 -4
View File
@@ -6,14 +6,13 @@ class BetterIPsConfig(PluginConfig):
verbose_name = "NetBox Better IPs" verbose_name = "NetBox Better IPs"
description = "Automatische Prefix-Zuordnung und erweiterte IP-Netzübersicht" description = "Automatische Prefix-Zuordnung und erweiterte IP-Netzübersicht"
author = "LKE" author = "LKE"
version = "1.1.0" version = "1.2.0"
base_url = "better-ips" base_url = "better-ips"
min_version = "4.6.5" min_version = "4.6.5"
max_version = "4.6.99" max_version = "4.6.99"
default_settings = { default_settings = {
"auto_create_prefix": True, "auto_create_range": True,
"prefix_status": "active", "range_status": "active",
"inherit_scope": True,
"inherit_tenant": True, "inherit_tenant": True,
"ignore_host_prefixes": True, "ignore_host_prefixes": True,
} }
@@ -1,11 +1,11 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from ipam.models import IPAddress from ipam.models import IPAddress
from netbox_better_ips.services import ensure_prefix_for_ip from netbox_better_ips.services import ensure_range_for_ip
class Command(BaseCommand): class Command(BaseCommand):
help = "Create missing exact prefixes for existing IP addresses" help = "Create missing usable-host IP ranges for existing IP addresses"
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument("--dry-run", action="store_true") parser.add_argument("--dry-run", action="store_true")
@@ -13,11 +13,10 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
created = skipped = 0 created = skipped = 0
for address in IPAddress.objects.select_related("tenant", "assigned_object_type").iterator(): for address in IPAddress.objects.select_related("tenant", "assigned_object_type").iterator():
prefix, was_created = ensure_prefix_for_ip(address) ip_range, was_created = ensure_range_for_ip(address)
if options["dry_run"] and was_created: if options["dry_run"] and was_created:
prefix.delete() ip_range.delete()
created += int(was_created) created += int(was_created)
skipped += int(prefix is None) skipped += int(ip_range is None)
verb = "würden erstellt" if options["dry_run"] else "erstellt" 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.")) self.stdout.write(self.style.SUCCESS(f"{created} IP-Bereiche {verb}; {skipped} Hostmasken ignoriert."))
@@ -0,0 +1 @@
from .reconcile_ip_prefixes import Command
+24 -48
View File
@@ -1,9 +1,8 @@
import ipaddress import ipaddress
import logging import logging
from django.contrib.contenttypes.models import ContentType
from django.db import transaction from django.db import transaction
from ipam.models import Prefix from ipam.models import IPRange
from netbox.plugins import get_plugin_config from netbox.plugins import get_plugin_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -14,32 +13,6 @@ def network_for_address(address):
return ipaddress.ip_interface(str(address)).network 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): def _tenant_from_ip(ip):
tenant = getattr(ip, "tenant", None) tenant = getattr(ip, "tenant", None)
if tenant is not None: if tenant is not None:
@@ -55,37 +28,40 @@ def _tenant_from_ip(ip):
@transaction.atomic @transaction.atomic
def ensure_prefix_for_ip(ip, *, config=None): def ensure_range_for_ip(ip, *, config=None):
"""Get or create the exact Prefix represented by an IP address and its mask. """Get or create the usable-host IPRange represented by an IP 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 { settings = config or {
key: get_plugin_config("netbox_better_ips", key) key: get_plugin_config("netbox_better_ips", key)
for key in ( for key in ("range_status", "inherit_tenant", "ignore_host_prefixes")
"prefix_status", "inherit_scope", "inherit_tenant", "ignore_host_prefixes"
)
} }
network = network_for_address(ip.address) network = network_for_address(ip.address)
if settings.get("ignore_host_prefixes", True) and network.prefixlen == network.max_prefixlen: if settings.get("ignore_host_prefixes", True) and network.prefixlen == network.max_prefixlen:
return None, False return None, False
lookup = {"prefix": str(network), "vrf_id": ip.vrf_id} first = int(network.network_address)
existing = Prefix.objects.filter(**lookup).first() last = int(network.broadcast_address)
if network.version == 4 and network.prefixlen < 31:
first += 1
last -= 1
elif network.version == 6 and network.prefixlen < 127:
first += 1
start_address = f"{ipaddress.ip_address(first)}/{network.prefixlen}"
end_address = f"{ipaddress.ip_address(last)}/{network.prefixlen}"
lookup = {
"start_address": start_address,
"end_address": end_address,
"vrf_id": ip.vrf_id,
}
existing = IPRange.objects.filter(**lookup).first()
if existing: if existing:
return existing, False return existing, False
defaults = {"status": settings.get("prefix_status", "active")} defaults = {"status": settings.get("range_status", "active")}
if settings.get("inherit_tenant", True): if settings.get("inherit_tenant", True):
defaults["tenant"] = _tenant_from_ip(ip) 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) ip_range, created = IPRange.objects.get_or_create(defaults=defaults, **lookup)
if created: if created:
logger.info("Created missing prefix %s for IP address %s", prefix, ip) logger.info("Created missing IP range %s for IP address %s", ip_range, ip)
return prefix, created return ip_range, created
+6 -7
View File
@@ -5,18 +5,17 @@ from django.dispatch import receiver
from ipam.models import IPAddress from ipam.models import IPAddress
from netbox.plugins import get_plugin_config from netbox.plugins import get_plugin_config
from .services import ensure_prefix_for_ip from .services import ensure_range_for_ip
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@receiver(post_save, sender=IPAddress, dispatch_uid="netbox_better_ips.ensure_prefix") @receiver(post_save, sender=IPAddress, dispatch_uid="netbox_better_ips.ensure_range")
def ensure_ip_prefix(sender, instance, raw=False, **kwargs): def ensure_ip_range(sender, instance, raw=False, **kwargs):
if raw or not get_plugin_config("netbox_better_ips", "auto_create_prefix"): if raw or not get_plugin_config("netbox_better_ips", "auto_create_range"):
return return
try: try:
ensure_prefix_for_ip(instance) ensure_range_for_ip(instance)
except Exception: except Exception:
# The IP save must not be rolled back by an optional convenience feature. # 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) logger.exception("Could not ensure an IP range for IP address %s", instance)
+9 -7
View File
@@ -1,5 +1,5 @@
import django_tables2 as tables import django_tables2 as tables
from ipam.tables import AnnotatedIPAddressTable from ipam.tables import IPAddressTable
from utilities.tables import register_table_column from utilities.tables import register_table_column
@@ -11,13 +11,15 @@ device_or_vm = tables.Column(
empty_values=(), empty_values=(),
) )
register_table_column(device_or_vm, "device_or_vm", AnnotatedIPAddressTable) register_table_column(device_or_vm, "device_or_vm", IPAddressTable)
# Registered columns are selectable but not displayed by default. The annotated # Registered columns are selectable but not displayed by default. The annotated
# table is used specifically by Prefix -> IP Addresses, where this information # table is used by IP Range -> IP Addresses, where this information should be
# should be visible immediately. # visible immediately.
if "device_or_vm" not in AnnotatedIPAddressTable.Meta.default_columns: if "device_or_vm" not in IPAddressTable.Meta.default_columns:
AnnotatedIPAddressTable.Meta.default_columns = ( IPAddressTable.Meta.default_columns = (
*AnnotatedIPAddressTable.Meta.default_columns, *IPAddressTable.Meta.default_columns,
"device_or_vm", "device_or_vm",
) )
if "device_or_vm" not in IPAddressTable.exempt_columns:
IPAddressTable.exempt_columns = (*IPAddressTable.exempt_columns, "device_or_vm")
+4 -4
View File
@@ -1,11 +1,11 @@
from netbox.plugins import PluginTemplateExtension from netbox.plugins import PluginTemplateExtension
class PrefixListActions(PluginTemplateExtension): class IPRangeListActions(PluginTemplateExtension):
model = "ipam.prefix" model = "ipam.iprange"
def list_buttons(self): def list_buttons(self):
return self.render("netbox_better_ips/inc/prefix_list_buttons.html") return self.render("netbox_better_ips/inc/iprange_list_buttons.html")
template_extensions = (PrefixListActions,) template_extensions = (IPRangeListActions,)
@@ -0,0 +1,15 @@
{% if perms.ipam.view_prefix %}
<a href="{% url 'plugins:netbox_better_ips:network_overview' %}" class="btn btn-outline-primary">
<i class="mdi mdi-view-list" aria-hidden="true"></i>
Erweiterte Netzübersicht
</a>
{% endif %}
{% if perms.ipam.view_ipaddress and perms.ipam.add_iprange %}
<form method="post" action="{% url 'plugins:netbox_better_ips:reconcile_missing_ranges' %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-primary">
<i class="mdi mdi-auto-fix" aria-hidden="true"></i>
Alle fehlenden Bereiche anlegen
</button>
</form>
{% endif %}
@@ -1,9 +0,0 @@
{% if perms.ipam.view_ipaddress and perms.ipam.add_prefix %}
<form method="post" action="{% url 'plugins:netbox_better_ips:reconcile_missing_prefixes' %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-primary">
<i class="mdi mdi-auto-fix" aria-hidden="true"></i>
Alle fehlenden Bereiche anlegen
</button>
</form>
{% endif %}
+2 -2
View File
@@ -1,10 +1,10 @@
from django.urls import path from django.urls import path
from .views import NetworkOverviewView, reconcile_missing_prefixes from .views import NetworkOverviewView, reconcile_missing_ranges
app_name = "netbox_better_ips" app_name = "netbox_better_ips"
urlpatterns = [ urlpatterns = [
path("networks/", NetworkOverviewView.as_view(), name="network_overview"), path("networks/", NetworkOverviewView.as_view(), name="network_overview"),
path("reconcile/", reconcile_missing_prefixes, name="reconcile_missing_prefixes"), path("reconcile/", reconcile_missing_ranges, name="reconcile_missing_ranges"),
] ]
+6 -6
View File
@@ -12,7 +12,7 @@ from ipam.models import IPAddress, Prefix
import netaddr import netaddr
from .forms import NetworkOverviewFilterForm from .forms import NetworkOverviewFilterForm
from .services import ensure_prefix_for_ip from .services import ensure_range_for_ip
def _descendant_ids(obj): def _descendant_ids(obj):
@@ -95,17 +95,17 @@ class NetworkOverviewView(PermissionRequiredMixin, TemplateView):
@require_POST @require_POST
@permission_required(("ipam.view_ipaddress", "ipam.add_prefix"), raise_exception=True) @permission_required(("ipam.view_ipaddress", "ipam.add_iprange"), raise_exception=True)
def reconcile_missing_prefixes(request): def reconcile_missing_ranges(request):
created = skipped = failed = 0 created = skipped = failed = 0
addresses = IPAddress.objects.restrict(request.user, "view").select_related( addresses = IPAddress.objects.restrict(request.user, "view").select_related(
"tenant", "assigned_object_type" "tenant", "assigned_object_type"
) )
for address in addresses.iterator(): for address in addresses.iterator():
try: try:
prefix, was_created = ensure_prefix_for_ip(address) ip_range, was_created = ensure_range_for_ip(address)
created += int(was_created) created += int(was_created)
skipped += int(prefix is None) skipped += int(ip_range is None)
except Exception: except Exception:
failed += 1 failed += 1
@@ -119,4 +119,4 @@ def reconcile_missing_prefixes(request):
request, request,
f"{created} fehlende Bereiche wurden angelegt. {skipped} Host-Adressen wurden ignoriert.", f"{created} fehlende Bereiche wurden angelegt. {skipped} Host-Adressen wurden ignoriert.",
) )
return redirect("ipam:prefix_list") return redirect("ipam:iprange_list")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-better-ips" name = "netbox-better-ips"
version = "1.1.0" version = "1.2.0"
description = "Automatic prefix creation and an organization-aware IP network overview for NetBox 4.6" description = "Automatic prefix creation and an organization-aware IP network overview for NetBox 4.6"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"