Skip to content

Commit 154b823

Browse files
Oob ip (devices) (#13013)
* initial oob_ip support for devices * add primary ip and oob ip checkmark to ip address view * add oob ip to device view and device edit view * pep8 * make is_oob_ip and is_primary_ip generic for other models * refactor oob_ip * fix oob ip signal * string capitalisation * Misc cleanup --------- Co-authored-by: Jeremy Stretch <[email protected]>
1 parent 7600d7b commit 154b823

File tree

15 files changed

+150
-26
lines changed

15 files changed

+150
-26
lines changed

docs/models/dcim/device.md

+4
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ Each device may designate one primary IPv4 address and/or one primary IPv6 addre
8787
!!! tip
8888
NetBox will prefer IPv6 addresses over IPv4 addresses by default. This can be changed by setting the `PREFER_IPV4` configuration parameter.
8989

90+
### Out-of-band (OOB) IP Address
91+
92+
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.
93+
9094
### Cluster
9195

9296
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.)

netbox/dcim/api/serializers.py

+11-10
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,7 @@ class DeviceSerializer(NetBoxModelSerializer):
663663
primary_ip = NestedIPAddressSerializer(read_only=True)
664664
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
665665
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
666+
oob_ip = NestedIPAddressSerializer(required=False, allow_null=True)
666667
parent_device = serializers.SerializerMethodField()
667668
cluster = NestedClusterSerializer(required=False, allow_null=True)
668669
virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None)
@@ -686,11 +687,11 @@ class Meta:
686687
fields = [
687688
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
688689
'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status',
689-
'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
690-
'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields',
691-
'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'power_port_count',
692-
'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count',
693-
'module_bay_count', 'inventory_item_count',
690+
'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
691+
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags',
692+
'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
693+
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
694+
'device_bay_count', 'module_bay_count', 'inventory_item_count',
694695
]
695696

696697
@extend_schema_field(NestedDeviceSerializer)
@@ -712,11 +713,11 @@ class Meta(DeviceSerializer.Meta):
712713
fields = [
713714
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
714715
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
715-
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
716-
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template',
717-
'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'power_port_count',
718-
'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count',
719-
'module_bay_count', 'inventory_item_count',
716+
'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority',
717+
'description', 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context',
718+
'config_template', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
719+
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
720+
'device_bay_count', 'module_bay_count', 'inventory_item_count',
720721
]
721722

722723
@extend_schema_field(serializers.JSONField(allow_null=True))

netbox/dcim/filtersets.py

+15
Original file line numberDiff line numberDiff line change
@@ -941,6 +941,10 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
941941
method='_has_primary_ip',
942942
label=_('Has a primary IP'),
943943
)
944+
has_oob_ip = django_filters.BooleanFilter(
945+
method='_has_oob_ip',
946+
label=_('Has an out-of-band IP'),
947+
)
944948
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
945949
field_name='virtual_chassis',
946950
queryset=VirtualChassis.objects.all(),
@@ -996,6 +1000,11 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
9961000
queryset=IPAddress.objects.all(),
9971001
label=_('Primary IPv6 (ID)'),
9981002
)
1003+
oob_ip_id = django_filters.ModelMultipleChoiceFilter(
1004+
field_name='oob_ip',
1005+
queryset=IPAddress.objects.all(),
1006+
label=_('OOB IP (ID)'),
1007+
)
9991008

10001009
class Meta:
10011010
model = Device
@@ -1020,6 +1029,12 @@ def _has_primary_ip(self, queryset, name, value):
10201029
return queryset.filter(params)
10211030
return queryset.exclude(params)
10221031

1032+
def _has_oob_ip(self, queryset, name, value):
1033+
params = Q(oob_ip__isnull=False)
1034+
if value:
1035+
return queryset.filter(params)
1036+
return queryset.exclude(params)
1037+
10231038
def _virtual_chassis_member(self, queryset, name, value):
10241039
return queryset.exclude(virtual_chassis__isnull=value)
10251040

netbox/dcim/forms/filtersets.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -629,7 +629,7 @@ class DeviceFilterForm(
629629
('Components', (
630630
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
631631
)),
632-
('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data'))
632+
('Miscellaneous', ('has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data'))
633633
)
634634
region_id = DynamicModelMultipleChoiceField(
635635
queryset=Region.objects.all(),
@@ -723,6 +723,13 @@ class DeviceFilterForm(
723723
choices=BOOLEAN_WITH_BLANK_CHOICES
724724
)
725725
)
726+
has_oob_ip = forms.NullBooleanField(
727+
required=False,
728+
label='Has an OOB IP',
729+
widget=forms.Select(
730+
choices=BOOLEAN_WITH_BLANK_CHOICES
731+
)
732+
)
726733
virtual_chassis_member = forms.NullBooleanField(
727734
required=False,
728735
label='Virtual chassis member',

netbox/dcim/forms/model_forms.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -449,9 +449,9 @@ class Meta:
449449
model = Device
450450
fields = [
451451
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face',
452-
'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster',
452+
'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster',
453453
'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
454-
'comments', 'tags', 'local_context_data'
454+
'comments', 'tags', 'local_context_data',
455455
]
456456

457457
def __init__(self, *args, **kwargs):
@@ -460,6 +460,7 @@ def __init__(self, *args, **kwargs):
460460
if self.instance.pk:
461461

462462
# Compile list of choices for primary IPv4 and IPv6 addresses
463+
oob_ip_choices = [(None, '---------')]
463464
for family in [4, 6]:
464465
ip_choices = [(None, '---------')]
465466

@@ -475,6 +476,7 @@ def __init__(self, *args, **kwargs):
475476
if interface_ips:
476477
ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips]
477478
ip_choices.append(('Interface IPs', ip_list))
479+
oob_ip_choices.extend(ip_list)
478480
# Collect NAT IPs
479481
nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
480482
address__family=family,
@@ -485,6 +487,7 @@ def __init__(self, *args, **kwargs):
485487
ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips]
486488
ip_choices.append(('NAT IPs', ip_list))
487489
self.fields['primary_ip{}'.format(family)].choices = ip_choices
490+
self.fields['oob_ip'].choices = oob_ip_choices
488491

489492
# If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
490493
# can be flipped from one face to another.
@@ -504,6 +507,8 @@ def __init__(self, *args, **kwargs):
504507
self.fields['primary_ip4'].widget.attrs['readonly'] = True
505508
self.fields['primary_ip6'].choices = []
506509
self.fields['primary_ip6'].widget.attrs['readonly'] = True
510+
self.fields['oob_ip'].choices = []
511+
self.fields['oob_ip'].widget.attrs['readonly'] = True
507512

508513
# Rack position
509514
position = self.data.get('position') or self.initial.get('position')
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Generated by Django 4.1.9 on 2023-07-24 20:29
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
('ipam', '0066_iprange_mark_utilized'),
10+
('dcim', '0174_rack_starting_unit'),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='device',
16+
name='oob_ip',
17+
field=models.OneToOneField(
18+
blank=True,
19+
null=True,
20+
on_delete=django.db.models.deletion.SET_NULL,
21+
related_name='+',
22+
to='ipam.ipaddress',
23+
),
24+
),
25+
]

netbox/dcim/migrations/0175_device_component_counters.py renamed to netbox/dcim/migrations/0176_device_component_counters.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def recalculate_device_counts(apps, schema_editor):
3939

4040
class Migration(migrations.Migration):
4141
dependencies = [
42-
('dcim', '0174_rack_starting_unit'),
42+
('dcim', '0175_device_oob_ip'),
4343
]
4444

4545
operations = [

netbox/dcim/models/devices.py

+18-1
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,14 @@ class Device(PrimaryModel, ConfigContextModel):
591591
null=True,
592592
verbose_name='Primary IPv6'
593593
)
594+
oob_ip = models.OneToOneField(
595+
to='ipam.IPAddress',
596+
on_delete=models.SET_NULL,
597+
related_name='+',
598+
blank=True,
599+
null=True,
600+
verbose_name='Out-of-band IP'
601+
)
594602
cluster = models.ForeignKey(
595603
to='virtualization.Cluster',
596604
on_delete=models.SET_NULL,
@@ -816,7 +824,7 @@ def clean(self):
816824
except DeviceType.DoesNotExist:
817825
pass
818826

819-
# Validate primary IP addresses
827+
# Validate primary & OOB IP addresses
820828
vc_interfaces = self.vc_interfaces(if_master=False)
821829
if self.primary_ip4:
822830
if self.primary_ip4.family != 4:
@@ -844,6 +852,15 @@ def clean(self):
844852
raise ValidationError({
845853
'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device."
846854
})
855+
if self.oob_ip:
856+
if self.oob_ip.assigned_object in vc_interfaces:
857+
pass
858+
elif self.oob_ip.nat_inside is not None and self.oob_ip.nat_inside.assigned_object in vc_interfaces:
859+
pass
860+
else:
861+
raise ValidationError({
862+
'oob_ip': f"The specified IP address ({self.oob_ip}) is not assigned to this device."
863+
})
847864

848865
# Validate manufacturer/platform
849866
if hasattr(self, 'device_type') and self.platform:

netbox/dcim/tables/devices.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
201201
linkify=True,
202202
verbose_name='IPv6 Address'
203203
)
204+
oob_ip = tables.Column(
205+
linkify=True,
206+
verbose_name='OOB IP'
207+
)
204208
cluster = tables.Column(
205209
linkify=True
206210
)
@@ -267,8 +271,8 @@ class Meta(NetBoxTable.Meta):
267271
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
268272
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
269273
'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4',
270-
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
271-
'comments', 'contacts', 'tags', 'created', 'last_updated',
274+
'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
275+
'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
272276
)
273277
default_columns = (
274278
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

netbox/dcim/views.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -2452,11 +2452,13 @@ class InterfaceView(generic.ObjectView):
24522452
queryset = Interface.objects.all()
24532453

24542454
def get_extra_context(self, request, instance):
2455-
# Get assigned VDC's
2455+
# Get assigned VDCs
24562456
vdc_table = tables.VirtualDeviceContextTable(
24572457
data=instance.vdcs.restrict(request.user, 'view').prefetch_related('device'),
2458-
exclude=('tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags',
2459-
'created', 'last_updated', 'actions', ),
2458+
exclude=(
2459+
'tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'comments', 'tags',
2460+
'created', 'last_updated', 'actions',
2461+
),
24602462
orderable=False
24612463
)
24622464

netbox/ipam/models/ip.py

+18
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,24 @@ def family(self):
849849
return self.address.version
850850
return None
851851

852+
@property
853+
def is_oob_ip(self):
854+
if self.assigned_object:
855+
parent = getattr(self.assigned_object, 'parent_object', None)
856+
if parent.oob_ip_id == self.pk:
857+
return True
858+
return False
859+
860+
@property
861+
def is_primary_ip(self):
862+
if self.assigned_object:
863+
parent = getattr(self.assigned_object, 'parent_object', None)
864+
if self.family == 4 and parent.primary_ip4_id == self.pk:
865+
return True
866+
if self.family == 6 and parent.primary_ip6_id == self.pk:
867+
return True
868+
return False
869+
852870
def _set_mask_length(self, value):
853871
"""
854872
Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly,

netbox/ipam/signals.py

+12-6
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,19 @@ def handle_prefix_deleted(instance, **kwargs):
5252
@receiver(pre_delete, sender=IPAddress)
5353
def clear_primary_ip(instance, **kwargs):
5454
"""
55-
When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it
56-
was a primary IP.
55+
When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it was a primary IP.
5756
"""
5857
field_name = f'primary_ip{instance.family}'
59-
device = Device.objects.filter(**{field_name: instance}).first()
60-
if device:
58+
if device := Device.objects.filter(**{field_name: instance}).first():
6159
device.save()
62-
virtualmachine = VirtualMachine.objects.filter(**{field_name: instance}).first()
63-
if virtualmachine:
60+
if virtualmachine := VirtualMachine.objects.filter(**{field_name: instance}).first():
6461
virtualmachine.save()
62+
63+
64+
@receiver(pre_delete, sender=IPAddress)
65+
def clear_oob_ip(instance, **kwargs):
66+
"""
67+
When an IPAddress is deleted, trigger save() on any Devices for which it was a OOB IP.
68+
"""
69+
if device := Device.objects.filter(oob_ip=instance).first():
70+
device.save()

netbox/templates/dcim/device.html

+11
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,17 @@ <h5 class="card-header">Management</h5>
239239
{% endif %}
240240
</td>
241241
</tr>
242+
<tr>
243+
<th scope="row">Out-of-band IP</th>
244+
<td>
245+
{% if object.oob_ip %}
246+
<a href="{{ object.oob_ip.get_absolute_url }}" id="oob_ip">{{ object.oob_ip.address.ip }}</a>
247+
{% copy_content "oob_ip" %}
248+
{% else %}
249+
{{ ''|placeholder }}
250+
{% endif %}
251+
</td>
252+
</tr>
242253
{% if object.cluster %}
243254
<tr>
244255
<th>Cluster</th>

netbox/templates/dcim/device_edit.html

+1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ <h5 class="offset-sm-3">Management</h5>
6868
{% if object.pk %}
6969
{% render_field form.primary_ip4 %}
7070
{% render_field form.primary_ip6 %}
71+
{% render_field form.oob_ip %}
7172
{% endif %}
7273
</div>
7374

netbox/templates/ipam/ipaddress.html

+8
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ <h5 class="card-header">
9696
{% endfor %}
9797
</td>
9898
</tr>
99+
<tr>
100+
<td>Primary IP</td>
101+
<td>{% checkmark object.is_primary_ip %}</td>
102+
</tr>
103+
<tr>
104+
<td>OOB IP</td>
105+
<td>{% checkmark object.is_oob_ip %}</td>
106+
</tr>
99107
</table>
100108
</div>
101109
</div>

0 commit comments

Comments
 (0)