Closes #539: Implemented L4 services for devices

This commit is contained in:
Jeremy Stretch 2016-12-15 15:32:58 -05:00
parent 66fa877198
commit f02c222d4f
15 changed files with 268 additions and 12 deletions

View File

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

View File

@ -1,5 +1,6 @@
from django.conf.urls import url from django.conf.urls import url
from ipam.views import ServiceEditView
from secrets.views import secret_add from secrets.views import secret_add
from . import views 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+)/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+)/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<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 # Console ports
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.consoleport_add, name='consoleport_add'), url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.consoleport_add, name='consoleport_add'),

View File

@ -14,7 +14,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.utils.http import urlencode from django.utils.http import urlencode
from django.views.generic import View 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 circuits.models import Circuit
from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
@ -569,12 +569,11 @@ def device(request, pk):
key=attrgetter('name') key=attrgetter('name')
) )
# Gather any secrets which belong to this device # Gather relevant device objects
secrets = device.secrets.all()
# Find all IP addresses assigned to this device
ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface', 'vrf')\ ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface', 'vrf')\
.order_by('address') .order_by('address')
services = Service.objects.filter(device=device)
secrets = device.secrets.all()
# Find any related devices for convenient linking in the UI # Find any related devices for convenient linking in the UI
related_devices = [] related_devices = []
@ -604,6 +603,7 @@ def device(request, pk):
'mgmt_interfaces': mgmt_interfaces, 'mgmt_interfaces': mgmt_interfaces,
'device_bays': device_bays, 'device_bays': device_bays,
'ip_addresses': ip_addresses, 'ip_addresses': ip_addresses,
'services': services,
'secrets': secrets, 'secrets': secrets,
'related_devices': related_devices, 'related_devices': related_devices,
'show_graphs': show_graphs, 'show_graphs': show_graphs,

View File

@ -1,8 +1,8 @@
from rest_framework import serializers 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 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 from tenancy.api.serializers import TenantNestedSerializer
@ -170,3 +170,22 @@ class IPAddressNestedSerializer(IPAddressSerializer):
IPAddressSerializer._declared_fields['nat_inside'] = IPAddressNestedSerializer() IPAddressSerializer._declared_fields['nat_inside'] = IPAddressNestedSerializer()
IPAddressSerializer._declared_fields['nat_outside'] = 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']

View File

@ -37,4 +37,8 @@ urlpatterns = [
url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'), url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'),
url(r'^vlans/(?P<pk>\d+)/$', VLANDetailView.as_view(), name='vlan_detail'), 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'),
] ]

View File

@ -1,6 +1,6 @@
from rest_framework import generics 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 ipam import filters
from extras.api.views import CustomFieldModelAPIView from extras.api.views import CustomFieldModelAPIView
@ -177,3 +177,24 @@ class VLANDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\ queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\
.prefetch_related('custom_field_values__field') .prefetch_related('custom_field_values__field')
serializer_class = serializers.VLANSerializer 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

View File

@ -9,7 +9,7 @@ from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter 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): class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
@ -349,3 +349,10 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
except ValueError: except ValueError:
pass pass
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
class ServiceFilter(django_filters.FilterSet):
class Meta:
model = Service
fields = ['device', 'name', 'protocol', 'port']

View File

@ -9,8 +9,8 @@ from utilities.forms import (
) )
from .models import ( from .models import (
Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
VLAN_STATUS_CHOICES, VRF, VLANGroup, VLAN_STATUS_CHOICES, VRF,
) )
@ -563,3 +563,25 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False) status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
null_option=(0, 'None')) 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)

View File

@ -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')]),
),
]

View File

@ -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): class VRF(CreatedUpdatedModel, CustomFieldModel):
""" """
A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing 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): def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status] 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()

View File

@ -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+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'), 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'),
] ]

View File

@ -16,7 +16,10 @@ from utilities.views import (
) )
from . import filters, forms, tables 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): def add_available_prefixes(parent, prefix_list):
@ -733,3 +736,24 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlan' permission_required = 'ipam.delete_vlan'
cls = VLAN cls = VLAN
default_redirect_url = 'ipam:vlan_list' 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

View File

@ -205,6 +205,29 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </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 panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Critical Connections</strong> <strong>Critical Connections</strong>

View File

@ -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>

View File

@ -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 %}