From f02c222d4f44ca29f469ba602217cb619edeebac Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 15 Dec 2016 15:32:58 -0500 Subject: [PATCH] Closes #539: Implemented L4 services for devices --- docs/data-model/ipam.md | 6 ++++ netbox/dcim/urls.py | 2 ++ netbox/dcim/views.py | 10 +++---- netbox/ipam/api/serializers.py | 23 +++++++++++++-- netbox/ipam/api/urls.py | 4 +++ netbox/ipam/api/views.py | 23 ++++++++++++++- netbox/ipam/filters.py | 9 +++++- netbox/ipam/forms.py | 26 +++++++++++++++-- netbox/ipam/migrations/0012_services.py | 39 +++++++++++++++++++++++++ netbox/ipam/models.py | 33 +++++++++++++++++++++ netbox/ipam/urls.py | 4 +++ netbox/ipam/views.py | 26 ++++++++++++++++- netbox/templates/dcim/device.html | 23 +++++++++++++++ netbox/templates/dcim/inc/_service.html | 26 +++++++++++++++++ netbox/templates/ipam/service_edit.html | 26 +++++++++++++++++ 15 files changed, 268 insertions(+), 12 deletions(-) create mode 100644 netbox/ipam/migrations/0012_services.py create mode 100644 netbox/templates/dcim/inc/_service.html create mode 100644 netbox/templates/ipam/service_edit.html diff --git a/docs/data-model/ipam.md b/docs/data-model/ipam.md index c6da1d657..ee54e74d2 100644 --- a/docs/data-model/ipam.md +++ b/docs/data-model/ipam.md @@ -86,3 +86,9 @@ One IP address can be designated as the network address translation (NAT) IP add A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094). Note that while it is good practice, neither VLAN names nor IDs must be unique within a site. This is to accommodate the fact that many real-world network use less-than-optimal VLAN allocations and may have overlapping VLAN ID assignments in practice. Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role. + +--- + +# Services + +A service represents a TCP or UDP service available on a device. Each service must be defined with a name, protocol, and port number; for example, SSH (TCP/22). A service may optionally be bound to one or more specific IP addresses belonging to a device. (If no IP addresses are bound, the service is assumed to be reachable via any IP address.) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 3ec018116..13038710a 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -1,5 +1,6 @@ from django.conf.urls import url +from ipam.views import ServiceEditView from secrets.views import secret_add from . import views @@ -104,6 +105,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'), url(r'^devices/(?P\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'), url(r'^devices/(?P\d+)/add-secret/$', secret_add, name='device_addsecret'), + url(r'^devices/(?P\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'), # Console ports url(r'^devices/(?P\d+)/console-ports/add/$', views.consoleport_add, name='consoleport_add'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index de4fe4228..b586604a4 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -14,7 +14,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils.http import urlencode from django.views.generic import View -from ipam.models import Prefix, IPAddress, VLAN +from ipam.models import Prefix, IPAddress, Service, VLAN from circuits.models import Circuit from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from utilities.forms import ConfirmationForm @@ -569,12 +569,11 @@ def device(request, pk): key=attrgetter('name') ) - # Gather any secrets which belong to this device - secrets = device.secrets.all() - - # Find all IP addresses assigned to this device + # Gather relevant device objects ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface', 'vrf')\ .order_by('address') + services = Service.objects.filter(device=device) + secrets = device.secrets.all() # Find any related devices for convenient linking in the UI related_devices = [] @@ -604,6 +603,7 @@ def device(request, pk): 'mgmt_interfaces': mgmt_interfaces, 'device_bays': device_bays, 'ip_addresses': ip_addresses, + 'services': services, 'secrets': secrets, 'related_devices': related_devices, 'show_graphs': show_graphs, diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 742eba9ea..d6bb87c5c 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,8 +1,8 @@ from rest_framework import serializers -from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer +from dcim.api.serializers import DeviceNestedSerializer, InterfaceNestedSerializer, SiteNestedSerializer from extras.api.serializers import CustomFieldSerializer -from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from tenancy.api.serializers import TenantNestedSerializer @@ -170,3 +170,22 @@ class IPAddressNestedSerializer(IPAddressSerializer): IPAddressSerializer._declared_fields['nat_inside'] = IPAddressNestedSerializer() IPAddressSerializer._declared_fields['nat_outside'] = IPAddressNestedSerializer() + + +# +# Services +# + +class ServiceSerializer(serializers.ModelSerializer): + device = DeviceNestedSerializer() + ipaddresses = IPAddressNestedSerializer(many=True) + + class Meta: + model = Service + fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] + + +class ServiceNestedSerializer(ServiceSerializer): + + class Meta(ServiceSerializer.Meta): + fields = ['id', 'name', 'port', 'protocol'] diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 0c0ac9495..598545ddf 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -37,4 +37,8 @@ urlpatterns = [ url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'), url(r'^vlans/(?P\d+)/$', VLANDetailView.as_view(), name='vlan_detail'), + # Services + url(r'^services/$', ServiceListView.as_view(), name='service_list'), + url(r'^services/(?P\d+)/$', ServiceDetailView.as_view(), name='service_detail'), + ] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 21ab9335c..10b9c46e4 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,6 +1,6 @@ from rest_framework import generics -from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from ipam import filters from extras.api.views import CustomFieldModelAPIView @@ -177,3 +177,24 @@ class VLANDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\ .prefetch_related('custom_field_values__field') serializer_class = serializers.VLANSerializer + + +# +# Services +# + +class ServiceListView(generics.ListAPIView): + """ + List services (filterable) + """ + queryset = Service.objects.select_related('device').prefetch_related('ipaddresses') + serializer_class = serializers.ServiceSerializer + filter_class = filters.ServiceFilter + + +class ServiceDetailView(generics.RetrieveAPIView): + """ + Retrieve a single service + """ + queryset = Service.objects.select_related('device').prefetch_related('ipaddresses') + serializer_class = serializers.ServiceSerializer diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index bb04ca78e..7193cb188 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -9,7 +9,7 @@ from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.filters import NullableModelMultipleChoiceFilter -from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role +from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): @@ -349,3 +349,10 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): except ValueError: pass return queryset.filter(qs_filter) + + +class ServiceFilter(django_filters.FilterSet): + + class Meta: + model = Service + fields = ['device', 'name', 'protocol', 'port'] diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 08bb5db04..9afb37092 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -9,8 +9,8 @@ from utilities.forms import ( ) from .models import ( - Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, - VLAN_STATUS_CHOICES, VRF, + Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, + VLANGroup, VLAN_STATUS_CHOICES, VRF, ) @@ -563,3 +563,25 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False) role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', null_option=(0, 'None')) + + +# +# Services +# + +class ServiceForm(forms.ModelForm, BootstrapMixin): + + class Meta: + model = Service + fields = ['name', 'protocol', 'port', 'ipaddresses', 'description'] + help_texts = { + 'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be " + "reachable via all IPs assigned to the device.", + } + + def __init__(self, *args, **kwargs): + + super(ServiceForm, self).__init__(*args, **kwargs) + + # Limit IP address choices to those assigned to interfaces of the parent device + self.fields['ipaddresses'].queryset = IPAddress.objects.filter(interface__device=self.instance.device) diff --git a/netbox/ipam/migrations/0012_services.py b/netbox/ipam/migrations/0012_services.py new file mode 100644 index 000000000..bb6274408 --- /dev/null +++ b/netbox/ipam/migrations/0012_services.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-12-15 20:22 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0022_color_names_to_rgb'), + ('ipam', '0011_rir_add_is_private'), + ] + + operations = [ + migrations.CreateModel( + name='Service', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateField(auto_now_add=True)), + ('last_updated', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=30)), + ('protocol', models.PositiveSmallIntegerField(choices=[(6, b'TCP'), (17, b'UDP')])), + ('port', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)], verbose_name=b'Port number')), + ('description', models.CharField(blank=True, max_length=100)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name=b'device')), + ('ipaddresses', models.ManyToManyField(blank=True, related_name='services', to='ipam.IPAddress', verbose_name=b'IP addresses')), + ], + options={ + 'ordering': ['device', 'protocol', 'port'], + }, + ), + migrations.AlterUniqueTogether( + name='service', + unique_together=set([('device', 'protocol', 'port')]), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 5f28acaed..854a6afa0 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -61,6 +61,14 @@ STATUS_CHOICE_CLASSES = { } +IP_PROTOCOL_TCP = 6 +IP_PROTOCOL_UDP = 17 +IP_PROTOCOL_CHOICES = ( + (IP_PROTOCOL_TCP, 'TCP'), + (IP_PROTOCOL_UDP, 'UDP'), +) + + class VRF(CreatedUpdatedModel, CustomFieldModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing @@ -525,3 +533,28 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): def get_status_class(self): return STATUS_CHOICE_CLASSES[self.status] + + +class Service(CreatedUpdatedModel): + """ + A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device. A Service may optionally be tied + to one or more specific IPAddresses belonging to the Device. + """ + device = models.ForeignKey('dcim.Device', related_name='services', on_delete=models.CASCADE, verbose_name='device') + name = models.CharField(max_length=30) + protocol = models.PositiveSmallIntegerField(choices=IP_PROTOCOL_CHOICES) + port = models.PositiveIntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)], + verbose_name='Port number') + ipaddresses = models.ManyToManyField('ipam.IPAddress', related_name='services', blank=True, + verbose_name='IP addresses') + description = models.CharField(max_length=100, blank=True) + + class Meta: + ordering = ['device', 'protocol', 'port'] + unique_together = ['device', 'protocol', 'port'] + + def __unicode__(self): + return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display()) + + def get_parent_url(self): + return self.device.get_absolute_url() diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index dc5fcc964..51bd8f849 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -76,4 +76,8 @@ urlpatterns = [ url(r'^vlans/(?P\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'), url(r'^vlans/(?P\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'), + # Services + url(r'^services/(?P\d+)/edit/$', views.ServiceEditView.as_view(), name='service_edit'), + url(r'^services/(?P\d+)/delete/$', views.ServiceDeleteView.as_view(), name='service_delete'), + ] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 0ad11a38c..a419d2cd8 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -16,7 +16,10 @@ from utilities.views import ( ) from . import filters, forms, tables -from .models import Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role, VLAN, VLANGroup, VRF +from .models import ( + Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role, + Service, VLAN, VLANGroup, VRF, +) def add_available_prefixes(parent, prefix_list): @@ -733,3 +736,24 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vlan' cls = VLAN default_redirect_url = 'ipam:vlan_list' + + +# +# Services +# + +class ServiceEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'ipam.change_service' + model = Service + form_class = forms.ServiceForm + template_name = 'ipam/service_edit.html' + + def alter_obj(self, obj, args, kwargs): + if 'device' in kwargs: + obj.device = get_object_or_404(Device, pk=kwargs['device']) + return obj + + +class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'ipam.delete_service' + model = Service diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 71b9b175b..5a7b98312 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -205,6 +205,29 @@ {% endif %} {% endif %} +
+
+ Services +
+ {% if services %} + + {% for service in services %} + {% include 'dcim/inc/_service.html' %} + {% endfor %} +
+ {% else %} +
+ None +
+ {% endif %} + {% if perms.dcim.add_service %} + + {% endif %} +
Critical Connections diff --git a/netbox/templates/dcim/inc/_service.html b/netbox/templates/dcim/inc/_service.html new file mode 100644 index 000000000..28cd64094 --- /dev/null +++ b/netbox/templates/dcim/inc/_service.html @@ -0,0 +1,26 @@ + + {{ service.name }} + + {{ service.get_protocol_display }}/{{ service.port }} + + + {% for ip in service.ipaddresses.all %} + {{ ip.address.ip }}
+ {% empty %} + All IPs + {% endfor %} + + {{ service.description }} + + {% if perms.ipam.change_service %} + + + + {% endif %} + {% if perms.ipam.delete_service %} + + + + {% endif %} + + diff --git a/netbox/templates/ipam/service_edit.html b/netbox/templates/ipam/service_edit.html new file mode 100644 index 000000000..b5593de0d --- /dev/null +++ b/netbox/templates/ipam/service_edit.html @@ -0,0 +1,26 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
Service
+
+
+ +
+

{{ obj.device }}

+
+
+ {% render_field form.name %} +
+ +
+ {{ form.protocol }} + {{ form.port }} +
+
+ {% render_field form.ipaddresses %} + {% render_field form.description %} +
+
+{% endblock %}