Browse Source

Closes #539: Implemented L4 services for devices

Jeremy Stretch 8 years ago
parent
commit
f02c222d4f

+ 6 - 0
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.)

+ 2 - 0
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<pk>\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'),
     url(r'^devices/(?P<pk>\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
     url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
+    url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'),
 
     # Console ports
     url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.consoleport_add, name='consoleport_add'),

+ 5 - 5
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,

+ 21 - 2
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']

+ 4 - 0
netbox/ipam/api/urls.py

@@ -37,4 +37,8 @@ urlpatterns = [
     url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'),
     url(r'^vlans/(?P<pk>\d+)/$', VLANDetailView.as_view(), name='vlan_detail'),
 
+    # Services
+    url(r'^services/$', ServiceListView.as_view(), name='service_list'),
+    url(r'^services/(?P<pk>\d+)/$', ServiceDetailView.as_view(), name='service_detail'),
+
 ]

+ 22 - 1
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

+ 8 - 1
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']

+ 24 - 2
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)

+ 39 - 0
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')]),
+        ),
+    ]

+ 33 - 0
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()

+ 4 - 0
netbox/ipam/urls.py

@@ -76,4 +76,8 @@ urlpatterns = [
     url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
     url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),
 
+    # Services
+    url(r'^services/(?P<pk>\d+)/edit/$', views.ServiceEditView.as_view(), name='service_edit'),
+    url(r'^services/(?P<pk>\d+)/delete/$', views.ServiceDeleteView.as_view(), name='service_delete'),
+
 ]

+ 25 - 1
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

+ 23 - 0
netbox/templates/dcim/device.html

@@ -207,6 +207,29 @@
         </div>
         <div class="panel panel-default">
             <div class="panel-heading">
+                <strong>Services</strong>
+            </div>
+            {% if services %}
+                <table class="table table-hover panel-body">
+                    {% for service in services %}
+                        {% include 'dcim/inc/_service.html' %}
+                    {% endfor %}
+                </table>
+            {% else %}
+                <div class="panel-body text-muted">
+                    None
+                </div>
+            {% endif %}
+            {% if perms.dcim.add_service %}
+                <div class="panel-footer text-right">
+                    <a href="{% url 'dcim:service_assign' device=device.pk %}" class="btn btn-xs btn-primary">
+                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
+                    </a>
+                </div>
+            {% endif %}
+        </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
                 <strong>Critical Connections</strong>
             </div>
             <table class="table table-hover panel-body">

+ 26 - 0
netbox/templates/dcim/inc/_service.html

@@ -0,0 +1,26 @@
+<tr>
+    <td>{{ service.name }}</td>
+    <td>
+        {{ service.get_protocol_display }}/{{ service.port }}
+    </td>
+    <td>
+        {% for ip in service.ipaddresses.all %}
+            <span>{{ ip.address.ip }}</span><br />
+        {% empty %}
+            <span class="text-muted">All IPs</span>
+        {% endfor %}
+    </td>
+    <td>{{ service.description }}</td>
+    <td class="text-right">
+        {% if perms.ipam.change_service %}
+            <a href="{% url 'ipam:service_edit' pk=service.pk %}" class="btn btn-info btn-xs" title="Edit service">
+                <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
+            </a>
+        {% endif %}
+        {% if perms.ipam.delete_service %}
+            <a href="{% url 'ipam:service_delete' pk=service.pk %}" class="btn btn-danger btn-xs">
+                <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete service"></i>
+            </a>
+        {% endif %}
+    </td>
+</tr>

+ 26 - 0
netbox/templates/ipam/service_edit.html

@@ -0,0 +1,26 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Service</strong></div>
+        <div class="panel-body">
+            <div class="form-group">
+                <label class="col-md-3 control-label">Device</label>
+                <div class="col-md-9">
+                    <p class="form-control-static">{{ obj.device }}</p>
+                </div>
+            </div>
+            {% render_field form.name %}
+            <div class="form-group form-inline">
+                <label class="col-md-3 control-label required">Port</label>
+                <div class="col-md-9">
+                    {{ form.protocol }}
+                    {{ form.port }}
+                </div>
+            </div>
+            {% render_field form.ipaddresses %}
+            {% render_field form.description %}
+        </div>
+    </div>
+{% endblock %}