diff --git a/docs/data-model/dns.md b/docs/data-model/dns.md new file mode 100644 index 000000000..88ff535c8 --- /dev/null +++ b/docs/data-model/dns.md @@ -0,0 +1,41 @@ +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. + +Zone objects handle only forward DNS, reverse DNS is handled by Prefixes (in IPAM section), which also store a SOA record. + +Netbox provides two views in the DNS menu to get the exports in BIND format, which is compatible with every DNS server, directly or by import. Those +exports are also accessible as JSON through the REST API. One of these views is the export of all the forward zones in the database, +the second is the export of all the reverse zones. + +The reverse zones are correctly merged and/or divided to meet the requirements of a DNS server (for instance, IPv4 reverse zones must be /16 or /24), and +not to duplicate records (for instance if you have in database the prefixes 192.168.0.0/16 and 192.168.1.0/24, only the biggest will be exported) ; however, +only IP addresses which are in an active prefix will be taken into account. Obviously, reverse DNS is supported for both IPv4 and IPv6. + +The SOA Serial field is not editable : it's automatically created and managed by Netbox. Each time a zone (forward or reverse) is exported, +if there are changes since the last export or if it's the first export, the serial will be incremented. It's in the following format : +YYYYMMDDNN with Y the year, M the month, D the day and N a two-digit counter. + +As zones and their BIND exports are readable through the REST API, it is possible to write some external script to automatically update +your DNS server configuration from Netbox's database. + +--- + +# 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 name if the record is of CNAME type. + +Records must be linked to an existing zone, and hold either an IP address link or a text value. The "Address" field points to an IP address +in database, but if you want to put an IP in your record but not in your database (if you don't own the IP for instance), it's possible +by putting the IP as text value instead. + +You can create, edit or import records with IPs not existing yet in the database. They will be automatically created (but not the prefixes !). +However, the zones must be created first, they won't be so automatically. + +Reverse DNS is not supported by Record objects, but by the "PTR" field in IP addresses. If this field is modified and not empty, a corresponding +A/AAAA record is automatically created if the corresponding zone is found in the database. Be careful, if there was A/AAAA records +for the old PTR value, they are not deleted. + diff --git a/docs/index.md b/docs/index.md index a661b5e0e..f19b2a6df 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 000000000..b6efa77a1 --- /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 000000000..d5956c624 --- /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 000000000..e69de29bb diff --git a/netbox/dns/api/serializers.py b/netbox/dns/api/serializers.py new file mode 100644 index 000000000..ba7286a1c --- /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', 'description'] + +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', 'description'] + +class RecordNestedSerializer(RecordSerializer): + + class Meta(RecordSerializer.Meta): + fields = ['id', 'name', 'record_type', 'zone'] diff --git a/netbox/dns/api/urls.py b/netbox/dns/api/urls.py new file mode 100644 index 000000000..75a9c184d --- /dev/null +++ b/netbox/dns/api/urls.py @@ -0,0 +1,19 @@ +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'), + + # BIND Exports + url(r'^bind/forward/$', bind_forward, name='bind_forward'), + url(r'^bind/reverse/$', bind_reverse, name='bind_reverse'), + +] diff --git a/netbox/dns/api/views.py b/netbox/dns/api/views.py new file mode 100644 index 000000000..480e57729 --- /dev/null +++ b/netbox/dns/api/views.py @@ -0,0 +1,69 @@ +from rest_framework import generics +from django.http import HttpResponse + +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from ipam.models import IPAddress +from dns.models import Zone, Record, export_bind_forward, export_bind_reverse +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 + +# +# BIND Exports +# + +@api_view(['GET']) +def bind_forward(request): + """ + Full export of forward zones in BIND format + """ + zones_list = export_bind_forward() + return Response(zones_list) + +@api_view(['GET']) +def bind_reverse(request): + """ + Full export of reverse zones in BIND format + """ + zones_list = export_bind_reverse() + return Response(zones_list) + diff --git a/netbox/dns/apps.py b/netbox/dns/apps.py new file mode 100644 index 000000000..a8e0badcb --- /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 000000000..1c0c73389 --- /dev/null +++ b/netbox/dns/filters.py @@ -0,0 +1,51 @@ +import django_filters +from django.db.models import Q + +from ipam.models import IPAddress +from .models import ( + Zone, + Record, +) +from .forms import record_type_choices + +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)', + ) + record_type = django_filters.MultipleChoiceFilter( + name = 'record_type', + label = 'Type', + choices = record_type_choices + ) + name = django_filters.CharFilter( + name = 'name', + lookup_type = 'icontains', + label = 'Name', + ) + name_or_value_or_ip = django_filters.MethodFilter( + name = 'name_or_value_or_ip', + ) + + class Meta: + model=Record + field = ['name', 'record_type', 'value'] + + def filter_name_or_value_or_ip(self, queryset, value): + if not value: + return queryset + return queryset.filter(Q(name__icontains=value) | Q(value__icontains=value) | Q(address__address__icontains=value)) diff --git a/netbox/dns/fixtures/dns.json b/netbox/dns/fixtures/dns.json new file mode 100644 index 000000000..a093b51e3 --- /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/formfields.py b/netbox/dns/formfields.py new file mode 100644 index 000000000..6c1049f65 --- /dev/null +++ b/netbox/dns/formfields.py @@ -0,0 +1,41 @@ +from netaddr import IPNetwork, AddrFormatError +import netaddr + +from django import forms +from django.core.exceptions import ValidationError + +from ipam.models import IPAddress, Prefix + + +# +# Form fields +# + +class AddressFormField(forms.Field): + default_error_messages = { + 'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).", + } + + def to_python(self, value): + if not value: + return None + + # Ensure that a subnet mask has been specified. This prevents IPs from defaulting to a /32 or /128. + if len(value.split('/')) != 2: + raise ValidationError('CIDR mask (e.g. /24) is required.') + + try: + net = IPNetwork(value) + except AddrFormatError: + raise ValidationError("Please specify a valid IPv4 or IPv6 address.") + + ip = IPAddress.objects.filter(address=value) + if not ip: + net = IPNetwork(value) + obj = IPAddress(address=net) + obj.save() + return obj + else: + return ip[0] + + diff --git a/netbox/dns/forms.py b/netbox/dns/forms.py new file mode 100644 index 000000000..94f3d3ffe --- /dev/null +++ b/netbox/dns/forms.py @@ -0,0 +1,147 @@ +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, +) + +from .formfields import AddressFormField + +# +# Zones +# + +class ZoneForm(forms.ModelForm, BootstrapMixin): + + class Meta: + model=Zone + fields = ['name', 'ttl', 'soa_name', 'soa_contact', 'soa_refresh', 'soa_retry', 'soa_expire', 'soa_minimum', 'description'] + labels = { + 'soa_name': 'SOA Name', + 'soa_contact': 'SOA Contact', + 'soa_refresh': 'SOA Refresh', + 'soa_retry': 'SOA Retry', + 'soa_expire': 'SOA Expire', + 'soa_minimum': 'SOA Minimum', + 'description': 'Description', + } + help_texts = { + 'ttl': "Time to live, in seconds", + 'soa_name': "The primary name server for the domain, @ for origin", + 'soa_contact': "The responsible party for the domain (e.g. ns.foo.net. noc.foo.net.)", + 'soa_refresh': "Refresh time, in seconds", + 'soa_retry': "Retry time, in seconds", + 'soa_expire': "Expire time, in seconds", + 'soa_minimum': "Negative result TTL, in seconds", + } + +class ZoneFromCSVForm(forms.ModelForm): + + class Meta: + model=Zone + fields = ['name', 'ttl', 'soa_name', 'soa_contact', 'soa_refresh', 'soa_retry', 'soa_expire', 'soa_minimum', 'description'] + +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') + description = forms.CharField(max_length=100, required=False, label='Description') + + +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): + + address = AddressFormField(required=False) + + class Meta: + model=Record + fields = ['name', 'record_type', 'priority', 'zone', 'address', 'value', 'description'] + labels = { + 'record_type': 'Type', + } + help_texts = { + 'name': 'Host name, @ for origin (e.g. www)', + 'record_type': 'Record type (e.g. MX or AAAA)', + 'priority': 'Priority level (e.g. 10)', + 'zone': 'Zone the record belongs to', + 'address': 'IP address if value is an IP address, in AAAA records for instance', + 'value': 'Text value else, in CNAME records for instance' + } + + +class RecordFromCSVForm(forms.ModelForm): + + zone = forms.ModelChoiceField(queryset=Zone.objects.all(), to_field_name='name', error_messages={'invalid_choice': 'Zone not found.'}) + address = AddressFormField(required=False) + + class Meta: + model=Record + fields = ['zone', 'name', 'record_type', 'priority', 'address', 'value', 'description'] + +# class RecordBINDImportForm(forms.Form, BootstrapMixin): +# bind = BINDDataField() +# zone_name = CharField(max_length=100, label='Zone') +# slash_v4 = IntegerField() + +# def clean(self): +# self.cleaned_data + +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 = AddressFormField(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_type_choices(): + type_choices = {} + records = Record.objects.all() + for r in records: + if not r.record_type in type_choices: + type_choices[r.record_type]=1 + else: + type_choices[r.record_type]+=1 + return [(t, '{} ({})'.format(t, count)) for t,count in type_choices.items()] + +class RecordFilterForm(forms.Form, BootstrapMixin): + zone__name = forms.MultipleChoiceField(required=False, choices=record_zone_choices, label='Zone', + widget=forms.SelectMultiple(attrs={'size': 8})) + record_type = forms.MultipleChoiceField(required=False, choices=record_type_choices, label='Type', + widget=forms.SelectMultiple(attrs={'size': 8})) + diff --git a/netbox/dns/migrations/0001_initial.py b/netbox/dns/migrations/0001_initial.py new file mode 100644 index 000000000..c32618198 --- /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 000000000..4d6118ff2 --- /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/0003_auto_20160721_1059.py b/netbox/dns/migrations/0003_auto_20160721_1059.py new file mode 100644 index 000000000..c060e6404 --- /dev/null +++ b/netbox/dns/migrations/0003_auto_20160721_1059.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-21 10:59 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dns', '0002_auto_20160719_1058'), + ] + + operations = [ + migrations.AlterModelOptions( + name='record', + options={'ordering': ['category']}, + ), + migrations.AddField( + model_name='record', + name='category', + field=models.CharField(blank=True, max_length=20), + ), + migrations.AddField( + model_name='record', + name='description', + field=models.CharField(blank=True, max_length=20), + ), + migrations.AddField( + model_name='zone', + name='description', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/netbox/dns/migrations/0004_auto_20160722_0820.py b/netbox/dns/migrations/0004_auto_20160722_0820.py new file mode 100644 index 000000000..5087660aa --- /dev/null +++ b/netbox/dns/migrations/0004_auto_20160722_0820.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-22 08:20 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dns', '0003_auto_20160721_1059'), + ] + + operations = [ + migrations.AlterModelOptions( + name='record', + options={'ordering': ['name']}, + ), + migrations.RemoveField( + model_name='record', + name='category', + ), + migrations.AlterField( + model_name='record', + name='address', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='records', to='ipam.IPAddress'), + ), + ] diff --git a/netbox/dns/migrations/0005_auto_20160728_0854.py b/netbox/dns/migrations/0005_auto_20160728_0854.py new file mode 100644 index 000000000..2138a6b38 --- /dev/null +++ b/netbox/dns/migrations/0005_auto_20160728_0854.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-28 08:54 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dns', '0004_auto_20160722_0820'), + ] + + operations = [ + migrations.AddField( + model_name='zone', + name='bind_changed', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='zone', + name='soa_serial', + field=models.CharField(max_length=10), + ), + ] diff --git a/netbox/dns/migrations/__init__.py b/netbox/dns/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/dns/models.py b/netbox/dns/models.py new file mode 100644 index 000000000..d4b73003b --- /dev/null +++ b/netbox/dns/models.py @@ -0,0 +1,214 @@ + +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 + +import time + +from django.db.models.signals import pre_delete +from django.dispatch import receiver + +import ipam.models + +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=10) + bind_changed = models.BooleanField(default=True) + + soa_refresh = models.PositiveIntegerField() + soa_retry = models.PositiveIntegerField() + soa_expire = models.PositiveIntegerField() + soa_minimum = models.PositiveIntegerField() + description = 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:zone', args=[self.pk]) + + def save(self, *args, **kwargs): + self.bind_changed = True + super(Zone, self).save(*args, **kwargs) + + def set_bind_changed(self, value): + self.bind_changed = value + super(Zone, self).save() + + def update_serial(self): + """ + Each time a record or the zone is modified, the serial is incremented. + """ + current_date = time.strftime('%Y%m%d',time.localtime()) + if not self.soa_serial: + self.soa_serial = current_date+'01' + else: + serial_date = self.soa_serial[:8] + serial_num = self.soa_serial[8:] + + if serial_date!=current_date: + self.soa_serial = current_date+'01' + else: + serial_num = int(serial_num) + serial_num += 1 + if serial_num<10: + self.soa_serial = current_date + '0' + str(serial_num) + else: + self.soa_serial = current_date + str(serial_num) + self.set_bind_changed(False) + + + 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), + self.description, + ]) + + def to_bind(self,records): + if self.bind_changed: + self.update_serial() + bind_records = '' + for r in records: + bind_records += r.to_bind()+'\n' + bind_export = '\n'.join([ + '; '+self.name+((' ('+self.description+')') if self.description else ''), + '; gen by netbox ( '+time.strftime('%A %B %d %Y %H:%M:%S',time.localtime())+' ) ', + '', + '$TTL '+str(self.ttl), + self.soa_name.ljust(30)+' IN '+'SOA '+self.soa_contact+' (', + ' '+self.soa_serial.ljust(30)+' ; serial', + ' '+str(self.soa_refresh).ljust(30)+' ; refresh', + ' '+str(self.soa_retry).ljust(30)+' ; retry', + ' '+str(self.soa_expire).ljust(30)+' ; expire', + ' '+str(self.soa_minimum).ljust(29)+') ; minimum', + '', + '', + '', + ]) + bind_export += '\n'+bind_records + bind_export += '\n'+'; end ' + return bind_export + + + +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.PROTECT, blank=True, null=True) + value = models.CharField(max_length=100, blank=True) + description = models.CharField(max_length=20, 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): + self.record_type = self.record_type.upper() + if not self.address and not self.value: + raise ValidationError("DNS records must have either an IP address or a text value") + + def save(self, *args, **kwargs): + self.zone.save() # in order to update serial. + super(Record, self).save(*args, **kwargs) + + 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 '', + self.value, + self.description, + ]) + + def to_bind(self): + return ''.join([ + (self.name if self.name!='@' else '').ljust(30), + ' IN ', + self.record_type.upper().ljust(10), + ' ', + (str(self.priority) if self.priority else '').ljust(4), + ' ', + (str(self.address).split('/')[0] if self.address else self.value).ljust(25), + ' ', + ' ; '+self.description+' ; gen by netbox ( '+time.strftime('%A %B %d %Y %H:%M:%S',time.localtime())+' ) ' + ]) + +@receiver(pre_delete, sender=Record) +def on_record_delete(sender, **kwargs): + kwargs['instance'].zone.save() + +# +# BIND Exports +# + +def export_bind_forward(): + zones = Zone.objects.all() + + zones_list = [] + for z in zones: + records = Record.objects.filter(zone=z) + zones_list.append({ + 'num': len(zones_list), + 'id': z.name, + 'content': z.to_bind(records) + }) + + return zones_list + +def export_bind_reverse(): + zones = {} + + prefixes = ipam.models.Prefix.objects.all() + + for p in prefixes: + child_ip = ipam.models.IPAddress.objects.filter(address__net_contained_or_equal=str(p.prefix)) + z = p.to_bind(child_ip) + for zz in z: + if not zz['id'] in zones: + zones[zz['id']] = zz['content'] + + zones_list = [] + for zid,zc in zones.items(): + zones_list.append({ + 'num': len(zones_list), + 'id': zid, + 'content': zc, + }) + + return zones_list + + diff --git a/netbox/dns/tables.py b/netbox/dns/tables.py new file mode 100644 index 000000000..3444bc84c --- /dev/null +++ b/netbox/dns/tables.py @@ -0,0 +1,63 @@ +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') + +class RecordZoneTable(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') + address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('address.pk')], verbose_name='IP Address') + value = tables.Column(verbose_name='Value') + + class Meta(BaseTable.Meta): + model=Record + fields = ('name', 'record_type', 'priority', 'address', 'value') + \ No newline at end of file diff --git a/netbox/dns/tests.py b/netbox/dns/tests.py new file mode 100644 index 000000000..7ce503c2d --- /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 000000000..98e38fcc8 --- /dev/null +++ b/netbox/dns/urls.py @@ -0,0 +1,31 @@ +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'), + + # BIND Exports + url(r'^bind/forward/$', views.full_forward, name='full_forward'), + url(r'^bind/reverse/$', views.full_reverse, name='full_reverse'), + +] diff --git a/netbox/dns/views.py b/netbox/dns/views.py new file mode 100644 index 000000000..b8f3ad0d9 --- /dev/null +++ b/netbox/dns/views.py @@ -0,0 +1,201 @@ +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 django.http import HttpResponse + +from ipam.models import IPAddress, Prefix +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, export_bind_forward, export_bind_reverse +from .tables import RecordZoneTable + +import StringIO, zipfile, time + +# +# 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) + + # DNS records + dns_records = Record.objects.filter(zone=zone) + dns_records_table = RecordZoneTable(dns_records) + + return render(request, 'dns/zone.html', { + 'zone': zone, + 'records': records, + 'record_count': record_count, + 'dns_records_table': dns_records_table, + }) + +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] + + zlist = self.cls.objects.filter(pk__in=pk_list) + for z in zlist: + z.set_bind_changed(True) + return zlist.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) + bind_export = record.to_bind() + + return render(request, 'dns/record.html', { + 'record': record, + 'bind_export': bind_export, + }) + +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] + + rlist = self.cls.objects.filter(pk__in=pk_list) + if rlist: + rlist[0].save() + return rlist.update(**fields_to_update) + +class RecordBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dns.delete_record' + cls = Record + form = forms.RecordBulkEditForm + default_redirect_url = 'dns:record_list' + +# +# BIND Exports +# + +def bind_export(request, zones_list, context): + download = request.GET.get('download') + if download: + if download == 'all': + zbuf = StringIO.StringIO() + zfile = zipfile.ZipFile(zbuf, mode='w') + temp = [] + for z in zones_list: + temp.append(StringIO.StringIO()) + temp[len(temp)-1].write(z['content']) + zfile.writestr(z['id'],str(temp[len(temp)-1].getvalue())) + zfile.close() + response = HttpResponse( + zbuf.getvalue(), + content_type = 'application/zip' + ) + response['Content-Disposition'] = 'attachment; filename="netbox_dns_{}_{}.zip"'.format(context, str(int(time.time()))) + return response + else: + response = HttpResponse( + zones_list[int(download)]['content'], + content_type='text/plain' + ) + response['Content-Disposition'] = 'attachment; filename="{}"'.format(zones_list[int(download)]['id']) + return response + + else: + return render(request, 'dns/bind_export.html', { + 'context': context[0].upper() + context[1:], + 'zones': zones_list, + 'bind_export_count': len(zones_list), + }) + +def full_forward(request): + return bind_export(request, export_bind_forward(), 'forward') + +def full_reverse(request): + return bind_export(request, export_bind_reverse(), 'reverse') + diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index a24e1454c..e5b089b1e 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -136,7 +136,7 @@ class PrefixSerializer(serializers.ModelSerializer): class Meta: model = Prefix - fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description'] + fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description', 'ttl', 'soa_name', 'soa_contact', 'soa_serial', 'soa_refresh', 'soa_retry', 'soa_expire', 'soa_minimum'] class PrefixNestedSerializer(PrefixSerializer): @@ -156,7 +156,7 @@ class IPAddressSerializer(serializers.ModelSerializer): class Meta: model = IPAddress - fields = ['id', 'family', 'address', 'vrf', 'tenant', 'interface', 'description', 'nat_inside', 'nat_outside'] + fields = ['id', 'family', 'address', 'vrf', 'tenant', 'ptr', 'interface', 'description', 'nat_inside', 'nat_outside'] class IPAddressNestedSerializer(IPAddressSerializer): diff --git a/netbox/ipam/fixtures/ipam.json b/netbox/ipam/fixtures/ipam.json index 1a981a941..db92ee9c9 100644 --- a/netbox/ipam/fixtures/ipam.json +++ b/netbox/ipam/fixtures/ipam.json @@ -42,7 +42,15 @@ "vlan": null, "status": 1, "role": 1, - "description": "" + "description": "", + "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 } }, { @@ -58,7 +66,15 @@ "vlan": null, "status": 1, "role": 1, - "description": "" + "description": "", + "ttl": 10800, + "soa_name": "", + "soa_contact": "", + "soa_serial": "", + "soa_refresh": "", + "soa_retry": "", + "soa_expire": "", + "soa_minimum": "" } }, { @@ -70,6 +86,7 @@ "family": 4, "address": "10.0.255.1/32", "vrf": null, + "ptr": "www.foo.net", "interface": 3, "nat_inside": null, "description": "" @@ -84,6 +101,7 @@ "family": 4, "address": "169.254.254.1/31", "vrf": null, + "ptr": "", "interface": 4, "nat_inside": null, "description": "" @@ -98,6 +116,7 @@ "family": 4, "address": "10.0.255.2/32", "vrf": null, + "ptr": "", "interface": 185, "nat_inside": null, "description": "" @@ -112,6 +131,7 @@ "family": 4, "address": "169.254.1.1/31", "vrf": null, + "ptr": "", "interface": 213, "nat_inside": null, "description": "" @@ -126,6 +146,7 @@ "family": 4, "address": "10.0.254.1/24", "vrf": null, + "ptr": "", "interface": 12, "nat_inside": null, "description": "" @@ -140,6 +161,7 @@ "family": 4, "address": "10.15.21.1/31", "vrf": null, + "ptr": "", "interface": 218, "nat_inside": null, "description": "" @@ -154,6 +176,7 @@ "family": 4, "address": "10.15.21.2/31", "vrf": null, + "ptr": "", "interface": 9, "nat_inside": null, "description": "" @@ -168,6 +191,7 @@ "family": 4, "address": "10.15.22.1/31", "vrf": null, + "ptr": "", "interface": 8, "nat_inside": null, "description": "" @@ -182,6 +206,7 @@ "family": 4, "address": "10.15.20.1/31", "vrf": null, + "ptr": "", "interface": 7, "nat_inside": null, "description": "" @@ -196,6 +221,7 @@ "family": 4, "address": "10.16.20.1/31", "vrf": null, + "ptr": "", "interface": 216, "nat_inside": null, "description": "" @@ -210,6 +236,7 @@ "family": 4, "address": "10.15.22.2/31", "vrf": null, + "ptr": "", "interface": 206, "nat_inside": null, "description": "" @@ -224,6 +251,7 @@ "family": 4, "address": "10.16.22.1/31", "vrf": null, + "ptr": "", "interface": 217, "nat_inside": null, "description": "" @@ -238,6 +266,7 @@ "family": 4, "address": "10.16.22.2/31", "vrf": null, + "ptr": "", "interface": 205, "nat_inside": null, "description": "" @@ -252,6 +281,7 @@ "family": 4, "address": "10.16.20.2/31", "vrf": null, + "ptr": "", "interface": 211, "nat_inside": null, "description": "" @@ -266,6 +296,7 @@ "family": 4, "address": "10.15.22.2/31", "vrf": null, + "ptr": "", "interface": 212, "nat_inside": null, "description": "" @@ -280,6 +311,7 @@ "family": 4, "address": "10.0.254.2/32", "vrf": null, + "ptr": "", "interface": 188, "nat_inside": null, "description": "" @@ -294,6 +326,7 @@ "family": 4, "address": "169.254.1.1/31", "vrf": null, + "ptr": "", "interface": 200, "nat_inside": null, "description": "" @@ -308,6 +341,7 @@ "family": 4, "address": "169.254.1.2/31", "vrf": null, + "ptr": "", "interface": 194, "nat_inside": null, "description": "" diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 134c2933c..af67a5553 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -158,7 +158,16 @@ class PrefixForm(forms.ModelForm, BootstrapMixin): class Meta: model = Prefix - fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'description'] + fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'description', 'ttl', 'soa_name', 'soa_contact', 'soa_refresh', 'soa_retry', 'soa_expire', 'soa_minimum'] + labels = { + 'ttl': 'Rev. DNS - TTL', + 'soa_name': 'Rev. DNS - SOA Name', + 'soa_contact': 'Rev. DNS - SOA Contact', + 'soa_refresh': 'Rev. DNS - SOA Refresh', + 'soa_retry': 'Rev. DNS - SOA Retry', + 'soa_expire': 'Rev. DNS - SOA Expire', + 'soa_minimum': 'Rev. DNS - SOA Minimum', + } help_texts = { 'prefix': "IPv4 or IPv6 network", 'vrf': "VRF (if applicable)", @@ -166,6 +175,13 @@ class PrefixForm(forms.ModelForm, BootstrapMixin): 'vlan': "The VLAN to which this prefix is assigned (if applicable)", 'status': "Operational status of this prefix", 'role': "The primary function of this prefix", + 'ttl': "Time to live, in seconds", + 'soa_name': "The primary name server for the domain, @ for origin", + 'soa_contact': "The responsible party for the zone (e.g. ns.foo.net. noc.foo.net.)", + 'soa_refresh': "Refresh time, in seconds", + 'soa_retry': "Retry time, in seconds", + 'soa_expire': "Expire time, in seconds", + 'soa_minimum': "Negative result TTL, in seconds", } def __init__(self, *args, **kwargs): @@ -212,7 +228,7 @@ class PrefixFromCSVForm(forms.ModelForm): class Meta: model = Prefix fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role', - 'description'] + 'description', 'ttl', 'soa_name', 'soa_contact', 'soa_refresh', 'soa_retry', 'soa_expire', 'soa_minimum'] def clean(self): @@ -264,6 +280,14 @@ class PrefixBulkEditForm(forms.Form, BootstrapMixin): role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False) description = forms.CharField(max_length=100, required=False) + ttl = forms.IntegerField(required=False, label='Reverse DNS - TTL') + soa_name = forms.CharField(max_length=100, required=False, label='Reverse DNS - SOA Name') + soa_contact = forms.CharField(max_length=100, required=False, label='Reverse DNS - SOA Contact') + soa_refresh = forms.IntegerField(required=False, label='Reverse DNS - SOA Refresh') + soa_retry = forms.IntegerField(required=False, label='Reverse DNS - SOA Retry') + soa_expire = forms.IntegerField(required=False, label='Reverse DNS - SOA Expire') + soa_minimum = forms.IntegerField(required=False, label='Reverse DNS - SOA Minimum') + def prefix_vrf_choices(): vrf_choices = VRF.objects.annotate(prefix_count=Count('prefixes')) @@ -326,10 +350,11 @@ class IPAddressForm(forms.ModelForm, BootstrapMixin): class Meta: model = IPAddress - fields = ['address', 'vrf', 'tenant', 'nat_device', 'nat_inside', 'description'] + fields = ['address', 'vrf', 'tenant', 'ptr', 'nat_device', 'nat_inside', 'description'] help_texts = { 'address': "IPv4 or IPv6 address and mask", 'vrf': "VRF (if applicable)", + 'ptr': "Reverse DNS name", } def __init__(self, *args, **kwargs): @@ -384,7 +409,8 @@ class IPAddressFromCSVForm(forms.ModelForm): class Meta: model = IPAddress - fields = ['address', 'vrf', 'tenant', 'device', 'interface_name', 'is_primary', 'description'] + fields = ['address', 'vrf', 'tenant', 'ptr', 'device', 'interface_name', 'is_primary', 'description'] + def clean(self): @@ -430,10 +456,12 @@ class IPAddressImportForm(BulkImportForm, BootstrapMixin): class IPAddressBulkEditForm(forms.Form, BootstrapMixin): pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput) vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF') + ptr = forms.CharField(max_length=100, required=False, label='PTR') tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') description = forms.CharField(max_length=100, required=False) + def ipaddress_family_choices(): return [('', 'All'), (4, 'IPv4'), (6, 'IPv6')] diff --git a/netbox/ipam/migrations/0005_ipaddress_hostname.py b/netbox/ipam/migrations/0005_ipaddress_hostname.py new file mode 100644 index 000000000..8f771d9dc --- /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 000000000..4b22af0b5 --- /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/migrations/0007_auto_20160722_0958.py b/netbox/ipam/migrations/0007_auto_20160722_0958.py new file mode 100644 index 000000000..8e153e551 --- /dev/null +++ b/netbox/ipam/migrations/0007_auto_20160722_0958.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-22 09:58 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0006_auto_20160720_0941'), + ] + + operations = [ + migrations.AddField( + model_name='prefix', + name='soa_contact', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='prefix', + name='soa_expire', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='prefix', + name='soa_minimum', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='prefix', + name='soa_name', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='prefix', + name='soa_refresh', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='prefix', + name='soa_retry', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='prefix', + name='soa_serial', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='prefix', + name='ttl', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/ipam/migrations/0008_auto_20160727_1307.py b/netbox/ipam/migrations/0008_auto_20160727_1307.py new file mode 100644 index 000000000..2d3f88dbf --- /dev/null +++ b/netbox/ipam/migrations/0008_auto_20160727_1307.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-27 13:07 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0007_auto_20160722_0958'), + ] + + operations = [ + migrations.RemoveField( + model_name='ipaddress', + name='hostname', + ), + migrations.AddField( + model_name='ipaddress', + name='ptr', + field=models.CharField(blank=True, max_length=100, verbose_name=b'PTR'), + ), + ] diff --git a/netbox/ipam/migrations/0009_auto_20160728_0854.py b/netbox/ipam/migrations/0009_auto_20160728_0854.py new file mode 100644 index 000000000..218c95989 --- /dev/null +++ b/netbox/ipam/migrations/0009_auto_20160728_0854.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-28 08:54 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0008_auto_20160727_1307'), + ] + + operations = [ + migrations.AddField( + model_name='prefix', + name='bind_changed', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='prefix', + name='soa_serial', + field=models.CharField(blank=True, max_length=10), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 7c981a8cb..d146fbc2a 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -9,9 +9,13 @@ from django.db import models from dcim.models import Interface from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel +import dns.models from .fields import IPNetworkField, IPAddressField +import time, ipaddress +import netaddr + AF_CHOICES = ( (4, 'IPv4'), @@ -238,10 +242,24 @@ class Prefix(CreatedUpdatedModel): verbose_name='VLAN') status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=1) role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True) - description = models.CharField(max_length=100, blank=True) objects = PrefixQuerySet.as_manager() + #Reverse DNS + ttl = models.PositiveIntegerField(blank=True, null=True) + soa_name = models.CharField(max_length=100, blank=True) + soa_contact = models.CharField(max_length=100, blank=True) + + soa_serial = models.CharField(max_length=10, blank=True) + bind_changed = models.BooleanField(default=True) + + soa_refresh = models.PositiveIntegerField(blank=True, null=True) + soa_retry = models.PositiveIntegerField(blank=True, null=True) + soa_expire = models.PositiveIntegerField(blank=True, null=True) + soa_minimum = models.PositiveIntegerField(blank=True, null=True) + + description = models.CharField(max_length=100, blank=True) + class Meta: ordering = ['family', 'prefix'] verbose_name_plural = 'prefixes' @@ -262,6 +280,7 @@ class Prefix(CreatedUpdatedModel): "instead.") def save(self, *args, **kwargs): + self.bind_changed = True if self.prefix: # Clear host bits from prefix self.prefix = self.prefix.cidr @@ -269,6 +288,32 @@ class Prefix(CreatedUpdatedModel): self.family = self.prefix.version super(Prefix, self).save(*args, **kwargs) + def set_bind_changed(self, value): + self.bind_changed = value + super(Prefix, self).save() + + def update_serial(self): + """ + Each time a record or the zone is modified, the serial is incremented. + """ + current_date = time.strftime('%Y%m%d',time.localtime()) + if not self.soa_serial: + self.soa_serial = current_date+'01' + else: + serial_date = self.soa_serial[:8] + serial_num = self.soa_serial[8:] + + if serial_date!=current_date: + self.soa_serial = current_date+'01' + else: + serial_num = int(serial_num) + serial_num += 1 + if serial_num < 10: + self.soa_serial = current_date + '0' + str(serial_num) + else: + self.soa_serial = current_date + str(serial_num) + self.set_bind_changed(False) + def to_csv(self): return ','.join([ str(self.prefix), @@ -277,6 +322,14 @@ class Prefix(CreatedUpdatedModel): self.get_status_display(), self.role.name if self.role else '', self.description, + self.ttl if self.ttl else '', + self.soa_name if self.soa_name else '', + self.soa_contact if self.soa_contact else '', + self.soa_serial if self.soa_serial else '', + self.soa_refresh if self.soa_refresh else '', + self.soa_retry if self.soa_retry else '', + self.soa_expire if self.soa_expire else '', + self.soa_minimum if self.soa_minimum else '', ]) @property @@ -293,6 +346,121 @@ class Prefix(CreatedUpdatedModel): def get_status_class(self): return STATUS_CHOICE_CLASSES[self.status] + def to_bind(self,ipaddresses): + if self.bind_changed: + self.update_serial() + + zones = {} + + def header (zone_id): return '\n'.join([ + '; '+zone_id, + '; gen from prefix '+str(self.prefix)+' ('+(self.description if self.description else '')+') by netbox ( '+time.strftime('%A %B %d %Y %H:%M:%S',time.localtime())+' ) ', + '', + '$TTL '+str(self.ttl), + self.soa_name.ljust(30)+' IN '+'SOA '+self.soa_contact+' (', + ' '+self.soa_serial.ljust(30)+' ; serial', + ' '+str(self.soa_refresh).ljust(30)+' ; refresh', + ' '+str(self.soa_retry).ljust(30)+' ; retry', + ' '+str(self.soa_expire).ljust(30)+' ; expire', + ' '+str(self.soa_minimum).ljust(29)+') ; minimum', + '', + '', + '', + '$ORIGIN '+zone_id, + '', + '', + ]) + + if self.prefix.version == 4: + pbytes = str(self.prefix).split('/')[0].split('.') + pslash = int(str(self.prefix).split('/')[1]) + + if pslash > 16: + pbytes[3]='0' + zslash = 24 + largerPrefix = Prefix.objects.filter(family=4, prefix__net_contains_or_equals=pbytes[0]+'.'+pbytes[1]+'.0.0/16') + if largerPrefix: + pbytes[2]='0' + zslash = 16 + else: + pbytes[2]='0' + zslash = 16 + + + if pslash > zslash: + pslash = zslash + + p = IPNetwork(unicode('.'.join(pbytes)+'/'+str(pslash))) + + ipaddresses = IPAddress.objects.filter(family=4) + for ip in ipaddresses: + if ip.ptr: + ibytes = str(ip.address).split('/')[0].split('.') + islash = str(ip.address).split('/')[1] + i = netaddr.IPAddress(unicode('.'.join(ibytes))) + + if i in p: + if zslash == 24: + zone_id = ibytes[2] + '.' + ibytes[1] + '.' + ibytes[0] + '.in-addr.arpa.' + if not zone_id in zones: + zones[zone_id] = header(zone_id) + zones[zone_id] += ibytes[3].ljust(3) + ' IN PTR ' + ip.ptr.ljust(40) + ' ; ' + ip.description.ljust(20) + ' ; gen by netbox ( '+time.strftime('%A %B %d %Y %H:%M:%S',time.localtime())+' ) \n' + else: + zone_id = ibytes[1]+'.'+ibytes[0]+'.in-addr.arpa.' + if not zone_id in zones: + zones[zone_id] = header(zone_id) + zones[zone_id] += (ibytes[3]+'.'+ibytes[2]).ljust(7) + ' IN PTR ' + ip.ptr.ljust(40) + ' ; ' + ip.description.ljust(20) + ' ; gen by netbox ( '+time.strftime('%A %B %d %Y %H:%M:%S',time.localtime())+' ) \n' + + + + else: + pfull = str(ipaddress.IPv6Address(unicode(str(self.prefix).split('/')[0])).exploded) + pnibbles = pfull.split(':') + pdigits = pfull.replace(':','') + pslash = int(str(self.prefix).split('/')[1]) + + zslash = pslash if pslash % 16 == 0 else pslash/16+16 + pnibbles = pnibbles[:zslash/16] + ['0000'] * (8 - zslash/16) + + largerPrefix = Prefix.objects.filter(family=6, prefix__net_contains_or_equals=':'.join(pnibbles)+'/'+str(zslash)) + if largerPrefix: + #choper le plus grand + minSlash = 128 + for pp in largerPrefix: + ppslash = int(str(pp.prefix).split('/')[1]) + if ppslash < minSlash: + minSlash = ppslash + zslash = minSlash + pnibbles = pnibbles[:zslash/16] + ['0000'] * (8 - zslash/16) + + for ip in ipaddresses: + if ip.ptr: + ifull = str(ipaddress.IPv6Address(unicode(str(ip.address).split('/')[0])).exploded) + inibbles = ifull.split(':') + idigits = ifull.replace(':','')[::-1] + islash = int(str(ip.address).split('/')[1]) + + pdigitszone = pdigits[:zslash/4][::-1] + zone_id = '.'.join(pdigitszone)+'.ip6.arpa.' + if not zone_id in zones: + zones[zone_id] = header(zone_id) + + zones[zone_id] += ('.'.join(idigits[:32-zslash/4])).ljust(30)+' IN PTR ' + ip.ptr.ljust(40) + ' ; ' + ip.description.ljust(20) + ' ; gen by netbox ( '+time.strftime('%A %B %d %Y %H:%M:%S',time.localtime())+' ) \n' + + + for z in zones: + z += '\n\n; end ' + + ret = [] + for zid,zc in zones.items(): + ret.append({ + 'num': len(ret), + 'id': zid, + 'content': zc, + }) + + return ret + class IPAddress(CreatedUpdatedModel): """ @@ -310,6 +478,7 @@ class IPAddress(CreatedUpdatedModel): vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True, verbose_name='VRF') tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT) + ptr = models.CharField(max_length=100, blank=True, verbose_name='PTR') 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, @@ -343,11 +512,39 @@ class IPAddress(CreatedUpdatedModel): raise ValidationError("Duplicate IP address found in global table: {}".format(duplicate_ips.first())) def save(self, *args, **kwargs): + self.update_dns() if self.address: # Infer address family from IPAddress object self.family = self.address.version + dns_records = dns.models.Record.objects.filter(address=self) + for r in dns_records: + r.save() super(IPAddress, self).save(*args, **kwargs) + def update_dns(self): + """Auto-create a corresponding A/AAAA DNS record (if possible) whenever the PTR field is modified""" + if self.ptr: + which_zone = None + zones = dns.models.Zone.objects.all() + for zone in zones: + if self.ptr.endswith(zone.name): + which_zone = zone + break + + if which_zone: + zone_name = which_zone.name + record_name = self.ptr[:-len(zone_name)] + if record_name.endswith('.'): + record_name = record_name[:-1] + record_type = 'A' if self.family==4 else 'AAAA' + + dns.models.Record.objects.get_or_create( + name = record_name, + record_type = record_type, + zone = which_zone, + address = self + ) + def to_csv(self): # Determine if this IP is primary for a Device @@ -360,6 +557,7 @@ class IPAddress(CreatedUpdatedModel): return ','.join([ str(self.address), self.vrf.rd if self.vrf else '', + self.ptr if self.ptr 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 8ac21e04c..929e86505 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -172,6 +172,7 @@ class IPAddressTable(BaseTable): address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address') vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF') tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant') + ptr = tables.Column(verbose_name='PTR') device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False, verbose_name='Device') interface = tables.Column(orderable=False, verbose_name='Interface') @@ -179,7 +180,7 @@ class IPAddressTable(BaseTable): class Meta(BaseTable.Meta): model = IPAddress - fields = ('pk', 'address', 'vrf', 'tenant', 'device', 'interface', 'description') + fields = ('pk', 'address', 'vrf', 'tenant', 'ptr', 'device', 'interface', 'description') class IPAddressBriefTable(BaseTable): diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 95aa33b1e..8f75ee9f0 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -6,6 +6,8 @@ from django.db.models import Count, Q 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, @@ -276,9 +278,10 @@ def prefix(request, pk): except Aggregate.DoesNotExist: aggregate = None + child_ip = IPAddress.objects.filter(vrf=prefix.vrf, address__net_contained_or_equal=str(prefix.prefix)) + # Count child IP addresses - ipaddress_count = IPAddress.objects.filter(vrf=prefix.vrf, address__net_contained_or_equal=str(prefix.prefix))\ - .count() + ipaddress_count = child_ip.count() # Parent prefixes table parent_prefixes = Prefix.objects.filter(Q(vrf=prefix.vrf) | Q(vrf__isnull=True))\ @@ -355,11 +358,14 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): fields_to_update[field] = None elif form.cleaned_data[field]: fields_to_update[field] = form.cleaned_data[field] - for field in ['site', 'status', 'role', 'description']: + for field in ['site', 'status', 'role', 'description', '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) + plist = self.cls.objects.filter(pk__in=pk_list) + for p in plist: + p.set_bind_changed(True) + return plist.update(**fields_to_update) class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -419,11 +425,16 @@ 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, }) @@ -480,11 +491,14 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): fields_to_update[field] = None elif form.cleaned_data[field]: fields_to_update[field] = form.cleaned_data[field] - for field in ['description']: + for field in ['ptr', 'description']: 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) + iplist = self.cls.objects.filter(pk__in=pk_list) + for ip in iplist: + ip.save() + return iplist.update(**fields_to_update) class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e2a8ea3ef..05f21a2cd 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -106,6 +106,7 @@ INSTALLED_APPS = ( 'circuits', 'dcim', 'ipam', + 'dns', 'extras', 'secrets', 'tenancy', diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index b67f04cfd..fb562c483 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -21,6 +21,7 @@ urlpatterns = [ 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'^tenancy/', include('tenancy.urls', namespace='tenancy')), url(r'^profile/', include('users.urls', namespace='users')), @@ -29,6 +30,7 @@ urlpatterns = [ 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/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')), url(r'^api/docs/', include('rest_framework_swagger.urls')), diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 801a0a8b9..677815f81 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -6,6 +6,7 @@ from circuits.models import Provider, Circuit from dcim.models import Site, Rack, Device, ConsolePort, PowerPort, InterfaceConnection from extras.models import UserAction from ipam.models import Aggregate, Prefix, IPAddress, VLAN, VRF +from dns.models import Zone, Record from secrets.models import Secret from tenancy.models import Tenant @@ -32,6 +33,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/project-static/css/base.css b/netbox/project-static/css/base.css index 13f4e1455..b48bbb7a5 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -9,7 +9,7 @@ html, body { height: 100%; } body { - padding-top: 70px; + padding-top: 60px; } .container { width: auto; @@ -19,7 +19,7 @@ body { min-height: 100%; height: auto !important; margin: 0 auto -61px; /* the bottom margin is the negative value of the footer's height */ - padding-bottom: 30px; + padding-bottom: 5px; } .footer, .push { height: 60px; /* .push must be the same height as .footer */ diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 570f73a4d..b62244907 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -165,6 +165,25 @@ {% endif %} +