diff --git a/docs/models/dcim/device.md b/docs/models/dcim/device.md index 2216e351c75..c9f05cd93b1 100644 --- a/docs/models/dcim/device.md +++ b/docs/models/dcim/device.md @@ -87,6 +87,10 @@ Each device may designate one primary IPv4 address and/or one primary IPv6 addre !!! tip NetBox will prefer IPv6 addresses over IPv4 addresses by default. This can be changed by setting the `PREFER_IPV4` configuration parameter. +### Out-of-band (OOB) IP Address + +Each device may designate its out-of-band IP address. Out-of-band IPs are typically used to access network infrastructure via a physically separate management network. + ### Cluster If this device will serve as a host for a virtualization [cluster](../virtualization/cluster.md), it can be assigned here. (Host devices can also be assigned by editing the cluster.) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 83559216125..edcb6401919 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -663,6 +663,7 @@ class DeviceSerializer(NetBoxModelSerializer): primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) + oob_ip = NestedIPAddressSerializer(required=False, allow_null=True) parent_device = serializers.SerializerMethodField() cluster = NestedClusterSerializer(required=False, allow_null=True) virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None) @@ -686,11 +687,11 @@ class Meta: fields = [ 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', - 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', - 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', - 'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'power_port_count', - 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count', - 'module_bay_count', 'inventory_item_count', + 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', + 'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', + 'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count', + 'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', + 'device_bay_count', 'module_bay_count', 'inventory_item_count', ] @extend_schema_field(NestedDeviceSerializer) @@ -712,11 +713,11 @@ class Meta(DeviceSerializer.Meta): fields = [ 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip', - 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', - 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template', - 'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'power_port_count', - 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count', - 'module_bay_count', 'inventory_item_count', + 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', + 'description', 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', + 'config_template', 'created', 'last_updated', 'console_port_count', 'console_server_port_count', + 'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', + 'device_bay_count', 'module_bay_count', 'inventory_item_count', ] @extend_schema_field(serializers.JSONField(allow_null=True)) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 7245676668a..e88fc120dc2 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -941,6 +941,10 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter method='_has_primary_ip', label=_('Has a primary IP'), ) + has_oob_ip = django_filters.BooleanFilter( + method='_has_oob_ip', + label=_('Has an out-of-band IP'), + ) virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( field_name='virtual_chassis', queryset=VirtualChassis.objects.all(), @@ -996,6 +1000,11 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter queryset=IPAddress.objects.all(), label=_('Primary IPv6 (ID)'), ) + oob_ip_id = django_filters.ModelMultipleChoiceFilter( + field_name='oob_ip', + queryset=IPAddress.objects.all(), + label=_('OOB IP (ID)'), + ) class Meta: model = Device @@ -1020,6 +1029,12 @@ def _has_primary_ip(self, queryset, name, value): return queryset.filter(params) return queryset.exclude(params) + def _has_oob_ip(self, queryset, name, value): + params = Q(oob_ip__isnull=False) + if value: + return queryset.filter(params) + return queryset.exclude(params) + def _virtual_chassis_member(self, queryset, name, value): return queryset.exclude(virtual_chassis__isnull=value) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 0a4a22a7044..06d38627d64 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -629,7 +629,7 @@ class DeviceFilterForm( ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', )), - ('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data')) + ('Miscellaneous', ('has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data')) ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -723,6 +723,13 @@ class DeviceFilterForm( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + has_oob_ip = forms.NullBooleanField( + required=False, + label='Has an OOB IP', + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) virtual_chassis_member = forms.NullBooleanField( required=False, label='Virtual chassis member', diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 04f976d9479..067cf2bda6f 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -449,9 +449,9 @@ class Meta: model = Device fields = [ 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face', - 'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', + 'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', - 'comments', 'tags', 'local_context_data' + 'comments', 'tags', 'local_context_data', ] def __init__(self, *args, **kwargs): @@ -460,6 +460,7 @@ def __init__(self, *args, **kwargs): if self.instance.pk: # Compile list of choices for primary IPv4 and IPv6 addresses + oob_ip_choices = [(None, '---------')] for family in [4, 6]: ip_choices = [(None, '---------')] @@ -475,6 +476,7 @@ def __init__(self, *args, **kwargs): if interface_ips: ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips] ip_choices.append(('Interface IPs', ip_list)) + oob_ip_choices.extend(ip_list) # Collect NAT IPs nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( address__family=family, @@ -485,6 +487,7 @@ def __init__(self, *args, **kwargs): ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips] ip_choices.append(('NAT IPs', ip_list)) self.fields['primary_ip{}'.format(family)].choices = ip_choices + self.fields['oob_ip'].choices = oob_ip_choices # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device # can be flipped from one face to another. @@ -504,6 +507,8 @@ def __init__(self, *args, **kwargs): self.fields['primary_ip4'].widget.attrs['readonly'] = True self.fields['primary_ip6'].choices = [] self.fields['primary_ip6'].widget.attrs['readonly'] = True + self.fields['oob_ip'].choices = [] + self.fields['oob_ip'].widget.attrs['readonly'] = True # Rack position position = self.data.get('position') or self.initial.get('position') diff --git a/netbox/dcim/migrations/0175_device_oob_ip.py b/netbox/dcim/migrations/0175_device_oob_ip.py new file mode 100644 index 00000000000..bf6a88ba87a --- /dev/null +++ b/netbox/dcim/migrations/0175_device_oob_ip.py @@ -0,0 +1,25 @@ +# Generated by Django 4.1.9 on 2023-07-24 20:29 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ('ipam', '0066_iprange_mark_utilized'), + ('dcim', '0174_rack_starting_unit'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='oob_ip', + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='ipam.ipaddress', + ), + ), + ] diff --git a/netbox/dcim/migrations/0175_device_component_counters.py b/netbox/dcim/migrations/0176_device_component_counters.py similarity index 98% rename from netbox/dcim/migrations/0175_device_component_counters.py rename to netbox/dcim/migrations/0176_device_component_counters.py index 9d033c103cf..fc22de81b62 100644 --- a/netbox/dcim/migrations/0175_device_component_counters.py +++ b/netbox/dcim/migrations/0176_device_component_counters.py @@ -39,7 +39,7 @@ def recalculate_device_counts(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('dcim', '0174_rack_starting_unit'), + ('dcim', '0175_device_oob_ip'), ] operations = [ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 48b916a31ca..76100197b7b 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -591,6 +591,14 @@ class Device(PrimaryModel, ConfigContextModel): null=True, verbose_name='Primary IPv6' ) + oob_ip = models.OneToOneField( + to='ipam.IPAddress', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + verbose_name='Out-of-band IP' + ) cluster = models.ForeignKey( to='virtualization.Cluster', on_delete=models.SET_NULL, @@ -816,7 +824,7 @@ def clean(self): except DeviceType.DoesNotExist: pass - # Validate primary IP addresses + # Validate primary & OOB IP addresses vc_interfaces = self.vc_interfaces(if_master=False) if self.primary_ip4: if self.primary_ip4.family != 4: @@ -844,6 +852,15 @@ def clean(self): raise ValidationError({ 'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device." }) + if self.oob_ip: + if self.oob_ip.assigned_object in vc_interfaces: + pass + elif self.oob_ip.nat_inside is not None and self.oob_ip.nat_inside.assigned_object in vc_interfaces: + pass + else: + raise ValidationError({ + 'oob_ip': f"The specified IP address ({self.oob_ip}) is not assigned to this device." + }) # Validate manufacturer/platform if hasattr(self, 'device_type') and self.platform: diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 77d53f0ec3f..c2651e4da10 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -201,6 +201,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): linkify=True, verbose_name='IPv6 Address' ) + oob_ip = tables.Column( + linkify=True, + verbose_name='OOB IP' + ) cluster = tables.Column( linkify=True ) @@ -267,8 +271,8 @@ class Meta(NetBoxTable.Meta): 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device', 'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', - 'comments', 'contacts', 'tags', 'created', 'last_updated', + 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', + 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d5b24b3b9e5..1d46de23172 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2452,11 +2452,13 @@ class InterfaceView(generic.ObjectView): queryset = Interface.objects.all() def get_extra_context(self, request, instance): - # Get assigned VDC's + # Get assigned VDCs vdc_table = tables.VirtualDeviceContextTable( data=instance.vdcs.restrict(request.user, 'view').prefetch_related('device'), - exclude=('tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags', - 'created', 'last_updated', 'actions', ), + exclude=( + 'tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'comments', 'tags', + 'created', 'last_updated', 'actions', + ), orderable=False ) diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 00dcf8422e2..a5d6eb08441 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -849,6 +849,24 @@ def family(self): return self.address.version return None + @property + def is_oob_ip(self): + if self.assigned_object: + parent = getattr(self.assigned_object, 'parent_object', None) + if parent.oob_ip_id == self.pk: + return True + return False + + @property + def is_primary_ip(self): + if self.assigned_object: + parent = getattr(self.assigned_object, 'parent_object', None) + if self.family == 4 and parent.primary_ip4_id == self.pk: + return True + if self.family == 6 and parent.primary_ip6_id == self.pk: + return True + return False + def _set_mask_length(self, value): """ Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly, diff --git a/netbox/ipam/signals.py b/netbox/ipam/signals.py index 8555f5e67ad..2a985c2940a 100644 --- a/netbox/ipam/signals.py +++ b/netbox/ipam/signals.py @@ -52,13 +52,19 @@ def handle_prefix_deleted(instance, **kwargs): @receiver(pre_delete, sender=IPAddress) def clear_primary_ip(instance, **kwargs): """ - When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it - was a primary IP. + When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it was a primary IP. """ field_name = f'primary_ip{instance.family}' - device = Device.objects.filter(**{field_name: instance}).first() - if device: + if device := Device.objects.filter(**{field_name: instance}).first(): device.save() - virtualmachine = VirtualMachine.objects.filter(**{field_name: instance}).first() - if virtualmachine: + if virtualmachine := VirtualMachine.objects.filter(**{field_name: instance}).first(): virtualmachine.save() + + +@receiver(pre_delete, sender=IPAddress) +def clear_oob_ip(instance, **kwargs): + """ + When an IPAddress is deleted, trigger save() on any Devices for which it was a OOB IP. + """ + if device := Device.objects.filter(oob_ip=instance).first(): + device.save() diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index c81bb5a3cd4..4d1e3dc0817 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -239,6 +239,17 @@
Management
{% endif %} + + Out-of-band IP + + {% if object.oob_ip %} + {{ object.oob_ip.address.ip }} + {% copy_content "oob_ip" %} + {% else %} + {{ ''|placeholder }} + {% endif %} + + {% if object.cluster %} Cluster diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 2dbe1e3c5b9..4029f502618 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -68,6 +68,7 @@
Management
{% if object.pk %} {% render_field form.primary_ip4 %} {% render_field form.primary_ip6 %} + {% render_field form.oob_ip %} {% endif %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index e58ac736ffd..a3c55d76efb 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -96,6 +96,14 @@
{% endfor %} + + Primary IP + {% checkmark object.is_primary_ip %} + + + OOB IP + {% checkmark object.is_oob_ip %} +