Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Oob ip (devices) #13013

Merged
merged 11 commits into from
Jul 25, 2023
4 changes: 4 additions & 0 deletions docs/models/dcim/device.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
21 changes: 11 additions & 10 deletions netbox/dcim/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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))
Expand Down
15 changes: 15 additions & 0 deletions netbox/dcim/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down
9 changes: 8 additions & 1 deletion netbox/dcim/forms/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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',
Expand Down
9 changes: 7 additions & 2 deletions netbox/dcim/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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, '---------')]

Expand All @@ -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,
Expand All @@ -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.
Expand All @@ -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')
Expand Down
25 changes: 25 additions & 0 deletions netbox/dcim/migrations/0175_device_oob_ip.py
Original file line number Diff line number Diff line change
@@ -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',
),
),
]
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
19 changes: 18 additions & 1 deletion netbox/dcim/models/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions netbox/dcim/tables/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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',
Expand Down
8 changes: 5 additions & 3 deletions netbox/dcim/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
18 changes: 18 additions & 0 deletions netbox/ipam/models/ip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 12 additions & 6 deletions netbox/ipam/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
11 changes: 11 additions & 0 deletions netbox/templates/dcim/device.html
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,17 @@ <h5 class="card-header">Management</h5>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Out-of-band IP</th>
<td>
{% if object.oob_ip %}
<a href="{{ object.oob_ip.get_absolute_url }}" id="oob_ip">{{ object.oob_ip.address.ip }}</a>
{% copy_content "oob_ip" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
{% if object.cluster %}
<tr>
<th>Cluster</th>
Expand Down
1 change: 1 addition & 0 deletions netbox/templates/dcim/device_edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ <h5 class="offset-sm-3">Management</h5>
{% if object.pk %}
{% render_field form.primary_ip4 %}
{% render_field form.primary_ip6 %}
{% render_field form.oob_ip %}
{% endif %}
</div>

Expand Down
8 changes: 8 additions & 0 deletions netbox/templates/ipam/ipaddress.html
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ <h5 class="card-header">
{% endfor %}
</td>
</tr>
<tr>
<td>Primary IP</td>
<td>{% checkmark object.is_primary_ip %}</td>
</tr>
<tr>
<td>OOB IP</td>
<td>{% checkmark object.is_oob_ip %}</td>
</tr>
</table>
</div>
</div>
Expand Down