From 5ea721a6aa37e14a4bf6e36b9ba619ec4ede7d64 Mon Sep 17 00:00:00 2001 From: rdujardin Date: Wed, 20 Jul 2016 15:24:42 +0200 Subject: [PATCH 01/42] Fixes #166: Full DNS support --- docs/data-model/dns.md | 19 +++ docs/index.md | 1 + netbox/dns/__init__.py | 1 + netbox/dns/admin.py | 17 +++ netbox/dns/api/__init__.py | 0 netbox/dns/api/serializers.py | 38 +++++ netbox/dns/api/urls.py | 15 ++ netbox/dns/api/views.py | 44 ++++++ netbox/dns/apps.py | 6 + netbox/dns/filters.py | 36 +++++ netbox/dns/fixtures/dns.json | 37 +++++ netbox/dns/forms.py | 109 ++++++++++++++ netbox/dns/migrations/0001_initial.py | 59 ++++++++ .../dns/migrations/0002_auto_20160719_1058.py | 20 +++ netbox/dns/migrations/__init__.py | 0 netbox/dns/models.py | 83 ++++++++++ netbox/dns/tables.py | 52 +++++++ netbox/dns/tests.py | 3 + netbox/dns/urls.py | 27 ++++ netbox/dns/views.py | 140 +++++++++++++++++ netbox/ipam/api/serializers.py | 2 +- netbox/ipam/fixtures/ipam.json | 18 +++ netbox/ipam/forms.py | 6 +- .../migrations/0005_ipaddress_hostname.py | 20 +++ .../migrations/0006_auto_20160720_0941.py | 20 +++ netbox/ipam/models.py | 2 + netbox/ipam/tables.py | 3 +- netbox/ipam/views.py | 8 + netbox/netbox/settings.py | 1 + netbox/netbox/urls.py | 2 + netbox/netbox/views.py | 5 + netbox/templates/_base.html | 16 ++ netbox/templates/dns/record.html | 105 +++++++++++++ netbox/templates/dns/record_bulk_edit.html | 17 +++ netbox/templates/dns/record_import.html | 67 +++++++++ netbox/templates/dns/record_list.html | 47 ++++++ netbox/templates/dns/zone.html | 142 ++++++++++++++++++ netbox/templates/dns/zone_bulk_edit.html | 19 +++ netbox/templates/dns/zone_import.html | 82 ++++++++++ netbox/templates/dns/zone_list.html | 46 ++++++ netbox/templates/home.html | 17 +++ netbox/templates/ipam/ipaddress.html | 13 ++ .../templates/ipam/ipaddress_bulk_edit.html | 1 + netbox/templates/ipam/ipaddress_edit.html | 1 + netbox/templates/ipam/ipaddress_import.html | 7 +- 45 files changed, 1369 insertions(+), 5 deletions(-) create mode 100644 docs/data-model/dns.md create mode 100644 netbox/dns/__init__.py create mode 100644 netbox/dns/admin.py create mode 100644 netbox/dns/api/__init__.py create mode 100644 netbox/dns/api/serializers.py create mode 100644 netbox/dns/api/urls.py create mode 100644 netbox/dns/api/views.py create mode 100644 netbox/dns/apps.py create mode 100644 netbox/dns/filters.py create mode 100644 netbox/dns/fixtures/dns.json create mode 100644 netbox/dns/forms.py create mode 100644 netbox/dns/migrations/0001_initial.py create mode 100644 netbox/dns/migrations/0002_auto_20160719_1058.py create mode 100644 netbox/dns/migrations/__init__.py create mode 100644 netbox/dns/models.py create mode 100644 netbox/dns/tables.py create mode 100644 netbox/dns/tests.py create mode 100644 netbox/dns/urls.py create mode 100644 netbox/dns/views.py create mode 100644 netbox/ipam/migrations/0005_ipaddress_hostname.py create mode 100644 netbox/ipam/migrations/0006_auto_20160720_0941.py create mode 100644 netbox/templates/dns/record.html create mode 100644 netbox/templates/dns/record_bulk_edit.html create mode 100644 netbox/templates/dns/record_import.html create mode 100644 netbox/templates/dns/record_list.html create mode 100644 netbox/templates/dns/zone.html create mode 100644 netbox/templates/dns/zone_bulk_edit.html create mode 100644 netbox/templates/dns/zone_import.html create mode 100644 netbox/templates/dns/zone_list.html diff --git a/docs/data-model/dns.md b/docs/data-model/dns.md new file mode 100644 index 00000000000..3951e9c41a6 --- /dev/null +++ b/docs/data-model/dns.md @@ -0,0 +1,19 @@ +The DNS component of NetBox deals with the management of DNS zones. + +# Zones + +A zone corresponds to a zone file in a DNS server, it stores the SOA (Start Of Authority) record and other records that are stored as Record objects. + +As zones are readable through the REST API, it is possible to write some external script which automatically generates zone files for a DNS server, +this feature is not directly provided by NetBox though. + +--- + +# Record + +Each Record object represents a DNS record, i.e. a link between a hostname and a resource, which can be either an IP address or a text value, +for instance another hostname if the record is of CNAME type. + +Records must be linked to an existing zone, and hold either an existing IP address link or a text value. + +Reverse DNS is not supported by Record objects, but by the "Host Name" field in IP addresses. diff --git a/docs/index.md b/docs/index.md index e9f57253df3..c1bd12e95eb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,6 +3,7 @@ NetBox is an open source web application designed to help manage and document computer networks. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. It encompasses the following aspects of network management: * **IP address management (IPAM)** - IP networks and addresses, VRFs, and VLANs +* **DNS management** - DNS zones and records * **Equipment racks** - Organized by group and site * **Devices** - Types of devices and where they are installed * **Connections** - Network, console, and power connections among devices diff --git a/netbox/dns/__init__.py b/netbox/dns/__init__.py new file mode 100644 index 00000000000..b6efa77a11f --- /dev/null +++ b/netbox/dns/__init__.py @@ -0,0 +1 @@ +default_app_config = 'dns.apps.DNSConfig' diff --git a/netbox/dns/admin.py b/netbox/dns/admin.py new file mode 100644 index 00000000000..d5956c62472 --- /dev/null +++ b/netbox/dns/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from .models import ( + Zone, Record, +) + + +@admin.register(Zone) +class ZoneAdmin(admin.ModelAdmin): + list_display = ['name', 'ttl', 'soa_name', 'soa_contact', 'soa_serial'] + prepopulated_fields = { + 'soa_name': ['name'], + } + +@admin.register(Record) +class RecordAdmin(admin.ModelAdmin): + list_display = ['name', 'zone', 'record_type', 'priority', 'address', 'value'] diff --git a/netbox/dns/api/__init__.py b/netbox/dns/api/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/netbox/dns/api/serializers.py b/netbox/dns/api/serializers.py new file mode 100644 index 00000000000..f62285bd65b --- /dev/null +++ b/netbox/dns/api/serializers.py @@ -0,0 +1,38 @@ +from rest_framework import serializers + +from ipam.api.serializers import IPAddressNestedSerializer +from dns.models import Zone, Record + +# +# Zones +# + +class ZoneSerializer(serializers.ModelSerializer): + + class Meta: + model=Zone + fields = ['id', 'name', 'ttl', 'soa_name', 'soa_contact', 'soa_serial', 'soa_refresh', 'soa_retry', 'soa_expire', 'soa_minimum'] + +class ZoneNestedSerializer(ZoneSerializer): + + class Meta(ZoneSerializer.Meta): + fields = ['id', 'name'] + + +# +# Records +# + +class RecordSerializer(serializers.ModelSerializer): + + zone = ZoneNestedSerializer() + address = IPAddressNestedSerializer() + + class Meta: + model=Record + fields = ['id', 'name', 'record_type', 'priority', 'zone', 'address', 'value'] + +class RecordNestedSerializer(RecordSerializer): + + class Meta(RecordSerializer.Meta): + fields = ['id', 'name', 'record_type', 'zone'] \ No newline at end of file diff --git a/netbox/dns/api/urls.py b/netbox/dns/api/urls.py new file mode 100644 index 00000000000..2d6c622a3cc --- /dev/null +++ b/netbox/dns/api/urls.py @@ -0,0 +1,15 @@ +from django.conf.urls import url + +from .views import * + +urlpatterns = [ + + # Zones + url(r'^zones/$', ZoneListView.as_view(), name='zone_list'), + url(r'^zones/(?P\d+)/$', ZoneDetailView.as_view(), name='zone_detail'), + + # Records + url(r'^records/$', RecordListView.as_view(), name='record_list'), + url(r'^records/(?P\d+)/$', RecordDetailView.as_view(), name='record_detail'), + +] diff --git a/netbox/dns/api/views.py b/netbox/dns/api/views.py new file mode 100644 index 00000000000..8b304694e68 --- /dev/null +++ b/netbox/dns/api/views.py @@ -0,0 +1,44 @@ +from rest_framework import generics + +from ipam.models import IPAddress +from dns.models import Zone, Record +from dns import filters + +from . import serializers + +# +# Zones +# + +class ZoneListView(generics.ListAPIView): + """ + List all zones + """ + queryset = Zone.objects.all() + serializer_class = serializers.ZoneSerializer + filter_class = filters.ZoneFilter + +class ZoneDetailView(generics.RetrieveAPIView): + """ + Retrieve a single zone + """ + queryset = Zone.objects.all() + serializer_class = serializers.ZoneSerializer + +# +# Records +# + +class RecordListView(generics.ListAPIView): + """ + List all records + """ + queryset = Record.objects.all() + serializer_class = serializers.RecordSerializer + +class RecordDetailView(generics.RetrieveAPIView): + """ + Retrieve a single record + """ + queryset = Record.objects.all() + serializer_class = serializers.RecordSerializer diff --git a/netbox/dns/apps.py b/netbox/dns/apps.py new file mode 100644 index 00000000000..a8e0badcb88 --- /dev/null +++ b/netbox/dns/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DNSConfig(AppConfig): + name = 'dns' + verbose_name='DNS' diff --git a/netbox/dns/filters.py b/netbox/dns/filters.py new file mode 100644 index 00000000000..dcf38b6b325 --- /dev/null +++ b/netbox/dns/filters.py @@ -0,0 +1,36 @@ +import django_filters + +from ipam.models import IPAddress +from .models import ( + Zone, + Record, +) + +class ZoneFilter(django_filters.FilterSet): + name = django_filters.CharFilter( + name='name', + lookup_type='icontains', + label='Name', + ) + + class Meta: + model = Zone + fields = ['name'] + +class RecordFilter(django_filters.FilterSet): + zone__name = django_filters.ModelMultipleChoiceFilter( + name='zone__name', + to_field_name='name', + lookup_type='icontains', + queryset=Zone.objects.all(), + label='Zone (name)', + ) + name = django_filters.CharFilter( + name='name', + lookup_type='icontains', + label='Name', + ) + + class Meta: + model=Record + field = ['name','record_type','value'] diff --git a/netbox/dns/fixtures/dns.json b/netbox/dns/fixtures/dns.json new file mode 100644 index 00000000000..a093b51e38e --- /dev/null +++ b/netbox/dns/fixtures/dns.json @@ -0,0 +1,37 @@ +[ +{ + "model": "dns.zone", + "pk": 1, + "fields": { + "name": "foo.net", + "ttl": 10800, + "soa_name": "@", + "soa_contact": "ns@foo.net. noc@foo.net.", + "soa_serial": "2016070401", + "soa_refresh": 3600, + "soa_retry": 3600, + "soa_expire": 604800, + "soa_minimum": 1800 + } +}, +{ + "model": "dns.record", + "pk": 1, + "fields": { + "name": "@", + "record_type": "NS", + "zone": 1, + "value": "ns.foo.net." + } +}, +{ + "model": "dns.record", + "pk": 2, + "fields": { + "name": "www", + "record_type": "A", + "zone": 1, + "address": 1 + } +} +] \ No newline at end of file diff --git a/netbox/dns/forms.py b/netbox/dns/forms.py new file mode 100644 index 00000000000..2b2ded7c972 --- /dev/null +++ b/netbox/dns/forms.py @@ -0,0 +1,109 @@ +from django import forms +from django.db.models import Count + +from ipam.models import IPAddress +from utilities.forms import ( + BootstrapMixin, ConfirmationForm, APISelect, Livesearch, CSVDataField, BulkImportForm, +) + +from .models import ( + Zone, + Record, +) + +# +# Zones +# + +class ZoneForm(forms.ModelForm, BootstrapMixin): + + class Meta: + model=Zone + fields = ['name', 'ttl', 'soa_name', 'soa_contact', 'soa_serial', 'soa_refresh', 'soa_retry', 'soa_expire', 'soa_minimum'] + labels = { + 'soa_name': 'SOA Name', + 'soa_contact': 'SOA Contact', + 'soa_serial': 'SOA Serial', + 'soa_refresh': 'SOA Refresh', + 'soa_retry': 'SOA Retry', + 'soa_expire': 'SOA Expire', + 'soa_minimum': 'SOA Minimum', + } + +class ZoneFromCSVForm(forms.ModelForm): + + class Meta: + model=Zone + fields = ['name', 'ttl', 'soa_name', 'soa_contact', 'soa_serial', 'soa_refresh', 'soa_retry', 'soa_expire', 'soa_minimum'] + +class ZoneImportForm(BulkImportForm, BootstrapMixin): + csv = CSVDataField(csv_form=ZoneFromCSVForm) + +class ZoneBulkEditForm(forms.Form, BootstrapMixin): + pk = forms.ModelMultipleChoiceField(queryset=Zone.objects.all(), widget=forms.MultipleHiddenInput) + name = forms.CharField(max_length=100, required=False, label='Name') + ttl = forms.IntegerField(required=False, label='TTL') + soa_name = forms.CharField(max_length=100, required=False, label='SOA Name') + soa_contact = forms.CharField(max_length=100, required=False, label='SOA Contact') + soa_refresh = forms.IntegerField(required=False, label='SOA Refresh') + soa_retry = forms.IntegerField(required=False, label='SOA Retry') + soa_expire = forms.IntegerField(required=False, label='SOA Expire') + soa_minimum = forms.IntegerField(required=False, label='SOA Minimum') + + +class ZoneBulkDeleteForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField(queryset=Zone.objects.all(), widget=forms.MultipleHiddenInput) + +class ZoneFilterForm(forms.Form, BootstrapMixin): + pass + +# +# Records +# + +class RecordForm(forms.ModelForm, BootstrapMixin): + + class Meta: + model=Record + fields = ['name', 'record_type', 'priority', 'zone', 'address', 'value'] + labels = { + 'record_type': 'Type', + } + +class RecordFromCSVForm(forms.ModelForm): + + zone = forms.ModelChoiceField(queryset=Zone.objects.all(), to_field_name='name', error_messages={'invalid_choice': 'Zone not found.'}) + address = forms.ModelChoiceField(queryset=IPAddress.objects.all(), to_field_name='address', error_messages={'invalid_choice': 'IP Address not found.'}, required=False) + + class Meta: + model=Record + fields = ['zone', 'name', 'record_type', 'priority', 'address', 'value'] + +class RecordImportForm(BulkImportForm, BootstrapMixin): + csv = CSVDataField(csv_form=RecordFromCSVForm) + +class RecordBulkEditForm(forms.Form, BootstrapMixin): + pk = forms.ModelMultipleChoiceField(queryset=Record.objects.all(), widget=forms.MultipleHiddenInput) + name = forms.CharField(max_length=100, required=False, label='Name') + record_type = forms.CharField(max_length=100, required=False, label='Type') + priority = forms.IntegerField(required=False) + zone = forms.ModelChoiceField(queryset=Zone.objects.all(), required=False) + address = forms.ModelChoiceField(queryset=IPAddress.objects.all(), required=False) + value = forms.CharField(max_length=100, required=False) + +class RecordBulkDeleteForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField(queryset=Record.objects.all(), widget=forms.MultipleHiddenInput) + +def record_zone_choices(): + zone_choices = Zone.objects.annotate(record_count=Count('records')) + return [(z.name, '{} ({})'.format(z.name, z.record_count)) for z in zone_choices] + +#def record_name_choices(): + #name_choices = + +class RecordFilterForm(forms.Form, BootstrapMixin): + zone__name = forms.MultipleChoiceField(required=False, choices=record_zone_choices, label='Zone', + widget=forms.SelectMultiple(attrs={'size': 8})) + #name = forms.MultipleChoiceField(required=False, choices=record_name_choices, label='Name', widget=forms.SelectMultiple(attrs={'size': 8})) + record_type = forms.CharField(max_length=100, required=False, label='Type') + diff --git a/netbox/dns/migrations/0001_initial.py b/netbox/dns/migrations/0001_initial.py new file mode 100644 index 00000000000..c32618198c2 --- /dev/null +++ b/netbox/dns/migrations/0001_initial.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-19 10:44 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('ipam', '0004_ipam_vlangroup_uniqueness'), + ] + + operations = [ + migrations.CreateModel( + name='Record', + 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=100)), + ('record_type', models.CharField(max_length=10)), + ('priority', models.PositiveIntegerField(blank=True)), + ('value', models.CharField(blank=True, max_length=100)), + ('address', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='records', to='ipam.IPAddress')), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Zone', + 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=100)), + ('ttl', models.PositiveIntegerField()), + ('soa_name', models.CharField(max_length=100)), + ('soa_contact', models.CharField(max_length=100)), + ('soa_serial', models.CharField(max_length=100)), + ('soa_refresh', models.PositiveIntegerField()), + ('soa_retry', models.PositiveIntegerField()), + ('soa_expire', models.PositiveIntegerField()), + ('soa_minimum', models.PositiveIntegerField()), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='record', + name='zone', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='dns.Zone'), + ), + ] diff --git a/netbox/dns/migrations/0002_auto_20160719_1058.py b/netbox/dns/migrations/0002_auto_20160719_1058.py new file mode 100644 index 00000000000..4d6118ff215 --- /dev/null +++ b/netbox/dns/migrations/0002_auto_20160719_1058.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-19 10:58 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dns', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='record', + name='priority', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dns/migrations/__init__.py b/netbox/dns/migrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/netbox/dns/models.py b/netbox/dns/models.py new file mode 100644 index 00000000000..5e2528ee97c --- /dev/null +++ b/netbox/dns/models.py @@ -0,0 +1,83 @@ + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.urlresolvers import reverse +from django.db import models + +from ipam.models import IPAddress +from utilities.models import CreatedUpdatedModel + +class Zone(CreatedUpdatedModel): + """ + A Zone represents a DNS zone. It contains SOA data but no records, records are represented as Record objects. + """ + name=models.CharField(max_length=100) + ttl=models.PositiveIntegerField() + soa_name=models.CharField(max_length=100) + soa_contact=models.CharField(max_length=100) + soa_serial=models.CharField(max_length=100) + soa_refresh=models.PositiveIntegerField() + soa_retry=models.PositiveIntegerField() + soa_expire=models.PositiveIntegerField() + soa_minimum=models.PositiveIntegerField() + + class Meta: + ordering = ['name'] + + def __unicode__(self): + return self.name + + def get_absolute_url(self): + return reverse('dns:zone', args=[self.pk]) + + def to_csv(self): + return ','.join([ + self.name, + str(self.ttl), + self.soa_name, + self.soa_contact, + self.soa_serial, + str(self.soa_refresh), + str(self.soa_retry), + str(self.soa_expire), + str(self.soa_minimum), + ]) + + + +class Record(CreatedUpdatedModel): + """ + A Record represents a DNS record, i.e. a row in a DNS zone. + """ + name=models.CharField(max_length=100) + record_type=models.CharField(max_length=10) + priority=models.PositiveIntegerField(blank=True, null=True) + zone=models.ForeignKey('Zone', related_name='records', on_delete=models.CASCADE) + address=models.ForeignKey('ipam.IPAddress', related_name='records', on_delete=models.SET_NULL, blank=True, null=True) + value=models.CharField(max_length=100, blank=True) + + class Meta: + ordering = ['name'] + + def __unicode__(self): + return self.name + + def get_absolute_url(self): + return reverse('dns:record', args=[self.pk]) + + def clean(self): + if not self.address and not self.value: + raise ValidationError("DNS records must have either an IP address or a text value") + + def to_csv(self): + return ','.join([ + self.zone.name, + self.name, + self.record_type, + str(self.priority) if self.priority else '', + str(self.address) if self.address else '', + str(self.value) if self.value else '', + ]) + + #def to_json(self): + # return JSON diff --git a/netbox/dns/tables.py b/netbox/dns/tables.py new file mode 100644 index 00000000000..8d01775845a --- /dev/null +++ b/netbox/dns/tables.py @@ -0,0 +1,52 @@ +import django_tables2 as tables +from django_tables2.utils import Accessor + +from utilities.tables import BaseTable, ToggleColumn + +from ipam.models import IPAddress +from .models import Zone, Record + +# +# Zones +# + +class ZoneTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn('dns:zone', args=[Accessor('pk')], verbose_name='Name') + record_count = tables.Column(verbose_name='Records') + ttl = tables.Column(verbose_name='TTL') + soa_name = tables.Column(verbose_name='SOA Name') + soa_contact = tables.Column(verbose_name='SOA Contact') + soa_serial = tables.Column(verbose_name='SOA Serial') + + class Meta(BaseTable.Meta): + model = Zone + fields = ('pk', 'name', 'ttl', 'soa_name', 'soa_contact', 'soa_serial') + +# +# Records +# + +class RecordTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn('dns:record', args=[Accessor('pk')], verbose_name='Name') + record_type = tables.Column(verbose_name='Type') + priority = tables.Column(verbose_name='Priority') + address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('address.pk')], verbose_name='IP Address') + value = tables.Column(verbose_name='Text Value') + zone = tables.LinkColumn('dns:zone', args=[Accessor('zone.pk')], verbose_name='Zone') + + class Meta(BaseTable.Meta): + model=Record + fields = ('pk', 'name', 'record_type', 'priority', 'address', 'value') + +class RecordBriefTable(BaseTable): + name = tables.LinkColumn('dns:record', args=[Accessor('pk')], verbose_name='Name') + record_type = tables.Column(verbose_name='Type') + priority = tables.Column(verbose_name='Priority') + zone = tables.LinkColumn('dns:zone', args=[Accessor('zone.pk')], verbose_name='Zone') + + class Meta(BaseTable.Meta): + model=Record + fields = ('name', 'record_type', 'priority', 'zone') + \ No newline at end of file diff --git a/netbox/dns/tests.py b/netbox/dns/tests.py new file mode 100644 index 00000000000..7ce503c2dd9 --- /dev/null +++ b/netbox/dns/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/netbox/dns/urls.py b/netbox/dns/urls.py new file mode 100644 index 00000000000..633cbe99189 --- /dev/null +++ b/netbox/dns/urls.py @@ -0,0 +1,27 @@ +from django.conf.urls import url + +from . import views + +urlpatterns = [ + + # Zones + url(r'^zones/$', views.ZoneListView.as_view(), name='zone_list'), + url(r'^zones/add/$', views.ZoneEditView.as_view(), name='zone_add'), + url(r'^zones/import/$', views.ZoneBulkImportView.as_view(), name='zone_import'), + url(r'^zones/edit/$', views.ZoneBulkEditView.as_view(), name='zone_bulk_edit'), + url(r'^zones/delete/$', views.ZoneBulkDeleteView.as_view(), name='zone_bulk_delete'), + url(r'^zones/(?P\d+)/$', views.zone, name='zone'), + url(r'^zones/(?P\d+)/edit/$', views.ZoneEditView.as_view(), name='zone_edit'), + url(r'^zones/(?P\d+)/delete/$', views.ZoneDeleteView.as_view(), name='zone_delete'), + + # Records + url(r'^records/$', views.RecordListView.as_view(), name='record_list'), + url(r'^records/add/$', views.RecordEditView.as_view(), name='record_add'), + url(r'^records/import/$', views.RecordBulkImportView.as_view(), name='record_import'), + url(r'^records/edit/$', views.RecordBulkEditView.as_view(), name='record_bulk_edit'), + url(r'^records/delete/$', views.RecordBulkDeleteView.as_view(), name='record_bulk_delete'), + url(r'^records/(?P\d+)/$', views.record, name='record'), + url(r'^records/(?P\d+)/edit/$', views.RecordEditView.as_view(), name='record_edit'), + url(r'^records/(?P\d+)/delete/$', views.RecordDeleteView.as_view(), name='record_delete'), + +] diff --git a/netbox/dns/views.py b/netbox/dns/views.py new file mode 100644 index 00000000000..da914fb2939 --- /dev/null +++ b/netbox/dns/views.py @@ -0,0 +1,140 @@ +from django_tables2 import RequestConfig + +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.db.models import Count +from django.shortcuts import get_object_or_404, render + +from ipam.models import IPAddress +from utilities.paginator import EnhancedPaginator +from utilities.views import ( + BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, +) + +from . import filters, forms, tables +from .models import Zone, Record + +# +# Zones +# + +class ZoneListView(ObjectListView): + queryset = Zone.objects.annotate(record_count=Count('records')) + filter = filters.ZoneFilter + filter_form = forms.ZoneFilterForm + table = tables.ZoneTable + edit_permissions = ['dns.change_zone', 'dns.delete_zone'] + template_name = 'dns/zone_list.html' + +def zone(request, pk): + + zone = get_object_or_404(Zone.objects.all(), pk=pk) + records = Record.objects.filter(zone=zone) + record_count = len(records) + + return render(request, 'dns/zone.html', { + 'zone': zone, + 'records': records, + 'record_count': record_count, + }) + +class ZoneEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dns.change_zone' + model = Zone + form_class = forms.ZoneForm + cancel_url = 'dns:zone_list' + +class ZoneDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dns.delete_zone' + model = Zone + redirect_url = 'dns:zone_list' + + +class ZoneBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dns.add_zone' + form = forms.ZoneImportForm + table = tables.ZoneTable + template_name = 'dns/zone_import.html' + obj_list_url = 'dns:zone_list' + +class ZoneBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dns.change_zone' + cls = Zone + form = forms.ZoneBulkEditForm + template_name = 'dns/zone_bulk_edit.html' + default_redirect_url = 'dns:zone_list' + + def update_objects(self, pk_list, form): + + fields_to_update = {} + for field in ['name', 'ttl', 'soa_name', 'soa_contact', 'soa_refresh', 'soa_retry', 'soa_expire', 'soa_minimum']: + if form.cleaned_data[field]: + fields_to_update[field] = form.cleaned_data[field] + + return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) + + +class ZoneBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dns.delete_zone' + cls = Zone + form = forms.ZoneBulkDeleteForm + default_redirect_url = 'dns:zone_list' + +# +# Records +# + +class RecordListView(ObjectListView): + queryset = Record.objects.all() + filter = filters.RecordFilter + filter_form = forms.RecordFilterForm + table = tables.RecordTable + edit_permissions = ['dns.change_record', 'dns.delete_record'] + template_name = 'dns/record_list.html' + +def record(request, pk): + + record = get_object_or_404(Record.objects.all(), pk=pk) + + return render(request, 'dns/record.html', { + 'record': record, + }) + +class RecordEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dns.change_record' + model = Record + form_class = forms.RecordForm + cancel_url = 'dns:record_list' + +class RecordDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dns.delete_record' + model = Record + redirect_url = 'dns:record_list' + +class RecordBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dns.add_record' + form = forms.RecordImportForm + table = tables.RecordTable + template_name = 'dns/record_import.html' + obj_list_url = 'dns:record_list' + +class RecordBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dns.change_record' + cls = Record + form = forms.RecordBulkEditForm + template_name = 'dns/record_bulk_edit.html' + default_redirect_url = 'dns:record_list' + + def update_objects(self, pk_list, form): + + fields_to_update = {} + for field in ['name', 'record_type', 'priority', 'zone', 'address', 'value']: + if form.cleaned_data[field]: + fields_to_update[field] = form.cleaned_data[field] + + return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) + +class RecordBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dns.delete_record' + cls = Record + form = forms.RecordBulkEditForm + default_redirect_url = 'dns:record_list' diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index c3d442fdf9b..bd7754c1d3f 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -142,7 +142,7 @@ class IPAddressSerializer(serializers.ModelSerializer): class Meta: model = IPAddress - fields = ['id', 'family', 'address', 'vrf', 'interface', 'description', 'nat_inside', 'nat_outside'] + fields = ['id', 'family', 'address', 'vrf', 'hostname', 'interface', 'description', 'nat_inside', 'nat_outside'] class IPAddressNestedSerializer(IPAddressSerializer): diff --git a/netbox/ipam/fixtures/ipam.json b/netbox/ipam/fixtures/ipam.json index 1a981a941e1..6a3190a3b31 100644 --- a/netbox/ipam/fixtures/ipam.json +++ b/netbox/ipam/fixtures/ipam.json @@ -70,6 +70,7 @@ "family": 4, "address": "10.0.255.1/32", "vrf": null, + "hostname": "foo.net", "interface": 3, "nat_inside": null, "description": "" @@ -84,6 +85,7 @@ "family": 4, "address": "169.254.254.1/31", "vrf": null, + "hostname": "", "interface": 4, "nat_inside": null, "description": "" @@ -98,6 +100,7 @@ "family": 4, "address": "10.0.255.2/32", "vrf": null, + "hostname": "", "interface": 185, "nat_inside": null, "description": "" @@ -112,6 +115,7 @@ "family": 4, "address": "169.254.1.1/31", "vrf": null, + "hostname": "", "interface": 213, "nat_inside": null, "description": "" @@ -126,6 +130,7 @@ "family": 4, "address": "10.0.254.1/24", "vrf": null, + "hostname": "", "interface": 12, "nat_inside": null, "description": "" @@ -140,6 +145,7 @@ "family": 4, "address": "10.15.21.1/31", "vrf": null, + "hostname": "", "interface": 218, "nat_inside": null, "description": "" @@ -154,6 +160,7 @@ "family": 4, "address": "10.15.21.2/31", "vrf": null, + "hostname": "", "interface": 9, "nat_inside": null, "description": "" @@ -168,6 +175,7 @@ "family": 4, "address": "10.15.22.1/31", "vrf": null, + "hostname": "", "interface": 8, "nat_inside": null, "description": "" @@ -182,6 +190,7 @@ "family": 4, "address": "10.15.20.1/31", "vrf": null, + "hostname": "", "interface": 7, "nat_inside": null, "description": "" @@ -196,6 +205,7 @@ "family": 4, "address": "10.16.20.1/31", "vrf": null, + "hostname": "", "interface": 216, "nat_inside": null, "description": "" @@ -210,6 +220,7 @@ "family": 4, "address": "10.15.22.2/31", "vrf": null, + "hostname": "", "interface": 206, "nat_inside": null, "description": "" @@ -224,6 +235,7 @@ "family": 4, "address": "10.16.22.1/31", "vrf": null, + "hostname": "", "interface": 217, "nat_inside": null, "description": "" @@ -238,6 +250,7 @@ "family": 4, "address": "10.16.22.2/31", "vrf": null, + "hostname": "", "interface": 205, "nat_inside": null, "description": "" @@ -252,6 +265,7 @@ "family": 4, "address": "10.16.20.2/31", "vrf": null, + "hostname": "", "interface": 211, "nat_inside": null, "description": "" @@ -266,6 +280,7 @@ "family": 4, "address": "10.15.22.2/31", "vrf": null, + "hostname": "", "interface": 212, "nat_inside": null, "description": "" @@ -280,6 +295,7 @@ "family": 4, "address": "10.0.254.2/32", "vrf": null, + "hostname": "", "interface": 188, "nat_inside": null, "description": "" @@ -294,6 +310,7 @@ "family": 4, "address": "169.254.1.1/31", "vrf": null, + "hostname": "", "interface": 200, "nat_inside": null, "description": "" @@ -308,6 +325,7 @@ "family": 4, "address": "169.254.1.2/31", "vrf": null, + "hostname": "", "interface": 194, "nat_inside": null, "description": "" diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 5c0c7805f28..b39dd0175a0 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -311,10 +311,11 @@ class IPAddressForm(forms.ModelForm, BootstrapMixin): class Meta: model = IPAddress - fields = ['address', 'vrf', 'nat_device', 'nat_inside', 'description'] + fields = ['address', 'vrf', 'hostname', 'nat_device', 'nat_inside', 'description'] help_texts = { 'address': "IPv4 or IPv6 address and mask", 'vrf': "VRF (if applicable)", + 'hostname': "Reverse DNS host name", } def __init__(self, *args, **kwargs): @@ -367,7 +368,7 @@ class IPAddressFromCSVForm(forms.ModelForm): class Meta: model = IPAddress - fields = ['address', 'vrf', 'device', 'interface_name', 'is_primary', 'description'] + fields = ['address', 'vrf', 'hostname', 'device', 'interface_name', 'is_primary', 'description'] def clean(self): @@ -414,6 +415,7 @@ class IPAddressBulkEditForm(forms.Form, BootstrapMixin): pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput) vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', help_text="Select the VRF to assign, or check below to remove VRF assignment") + hostname = forms.CharField(max_length=100, required=False) vrf_global = forms.BooleanField(required=False, label='Set VRF to global') description = forms.CharField(max_length=50, required=False) diff --git a/netbox/ipam/migrations/0005_ipaddress_hostname.py b/netbox/ipam/migrations/0005_ipaddress_hostname.py new file mode 100644 index 00000000000..8f771d9dc9c --- /dev/null +++ b/netbox/ipam/migrations/0005_ipaddress_hostname.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-19 15:37 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0004_ipam_vlangroup_uniqueness'), + ] + + operations = [ + migrations.AddField( + model_name='ipaddress', + name='hostname', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/netbox/ipam/migrations/0006_auto_20160720_0941.py b/netbox/ipam/migrations/0006_auto_20160720_0941.py new file mode 100644 index 00000000000..4b22af0b5fc --- /dev/null +++ b/netbox/ipam/migrations/0006_auto_20160720_0941.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-20 09:41 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0005_ipaddress_hostname'), + ] + + operations = [ + migrations.AlterField( + model_name='ipaddress', + name='hostname', + field=models.CharField(blank=True, max_length=100, verbose_name=b'Host Name'), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 0aa971ae3b2..87ebfb70579 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -304,6 +304,7 @@ class IPAddress(CreatedUpdatedModel): address = IPAddressField() vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True, verbose_name='VRF') + hostname = models.CharField(max_length=100, blank=True, verbose_name='Host Name') interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True, null=True) nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True, @@ -354,6 +355,7 @@ def to_csv(self): return ','.join([ str(self.address), self.vrf.rd if self.vrf else '', + self.hostname if self.hostname else '', self.device.identifier if self.device else '', self.interface.name if self.interface else '', 'True' if is_primary else '', diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index f3090625553..83d062ec98b 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -160,6 +160,7 @@ class IPAddressTable(BaseTable): pk = ToggleColumn() address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address') vrf = tables.Column(orderable=False, default='Global', verbose_name='VRF') + hostname = tables.Column(verbose_name='Host Name') device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False, verbose_name='Device') interface = tables.Column(orderable=False, verbose_name='Interface') @@ -167,7 +168,7 @@ class IPAddressTable(BaseTable): class Meta(BaseTable.Meta): model = IPAddress - fields = ('pk', 'address', 'vrf', 'device', 'interface', 'description') + fields = ('pk', 'address', 'vrf', 'hostname', 'device', 'interface', 'description') class IPAddressBriefTable(BaseTable): diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index ea980c2c6db..5496d2c4af4 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -6,6 +6,8 @@ from django.shortcuts import get_object_or_404, render from dcim.models import Device +from dns.models import Zone, Record +from dns.tables import RecordBriefTable from utilities.paginator import EnhancedPaginator from utilities.views import ( BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, @@ -409,11 +411,17 @@ def ipaddress(request, pk): .filter(vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)) related_ips_table = tables.IPAddressBriefTable(related_ips) + # Related DNS records + dns_records = Record.objects.filter(address=ipaddress) + dns_records_table = RecordBriefTable(dns_records) + + return render(request, 'ipam/ipaddress.html', { 'ipaddress': ipaddress, 'parent_prefixes_table': parent_prefixes_table, 'duplicate_ips_table': duplicate_ips_table, 'related_ips_table': related_ips_table, + 'dns_records_table': dns_records_table, }) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 01f26619488..cfd7c12f3b1 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -106,6 +106,7 @@ 'circuits', 'dcim', 'ipam', + 'dns', 'extras', 'secrets', 'users', diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 3a9d6b00a21..b6a15ac0461 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -21,6 +21,7 @@ url(r'^circuits/', include('circuits.urls', namespace='circuits')), url(r'^dcim/', include('dcim.urls', namespace='dcim')), url(r'^ipam/', include('ipam.urls', namespace='ipam')), + url(r'^dns/', include('dns.urls', namespace='dns')), url(r'^secrets/', include('secrets.urls', namespace='secrets')), url(r'^profile/', include('users.urls', namespace='users')), @@ -28,6 +29,7 @@ url(r'^api/circuits/', include('circuits.api.urls', namespace='circuits-api')), url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')), url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')), + url(r'^api/dns/', include('dns.api.urls', namespace='dns-api')), url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')), url(r'^api/docs/', include('rest_framework_swagger.urls')), url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 9fda7f92c55..a1a177a18a4 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -6,6 +6,7 @@ from dcim.models import Site, Rack, Device, ConsolePort, PowerPort, InterfaceConnection from extras.models import UserAction from ipam.models import Aggregate, Prefix, IPAddress, VLAN +from dns.models import Zone, Record from secrets.models import Secret @@ -27,6 +28,10 @@ def home(request): 'ipaddress_count': IPAddress.objects.count(), 'vlan_count': VLAN.objects.count(), + # DNS + 'zone_count': Zone.objects.count(), + 'record_count': Record.objects.count(), + # Circuits 'provider_count': Provider.objects.count(), 'circuit_count': Circuit.objects.count(), diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index fedbd43bffb..313843f0f4c 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -156,6 +156,22 @@ {% endif %} +