├── dnsmanager ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── viewzone.py │ │ └── importbind.py ├── migrations │ ├── __init__.py │ ├── 0002_canonicalnamerecord_unique.py │ ├── 0004_mx_origin_support.py │ ├── 0003_ns_origin_support.py │ └── 0001_initial.py ├── signals.py ├── templates │ └── dnsmanager │ │ ├── zone_list.txt │ │ └── zone_detail.txt ├── urls.py ├── settings.py ├── views.py ├── defaults.py ├── mommy_recipes.py ├── admin.py ├── recipes.py ├── tests.py └── models.py ├── test_app ├── __init__.py ├── urls.py ├── mommy_recipes.py ├── models.py ├── runtests.py └── settings.py ├── MANIFEST.in ├── docs └── screenshot.png ├── requirements.txt ├── manage.py ├── .travis.yml ├── .gitignore ├── LICENSE ├── setup.py └── README.md /dnsmanager/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dnsmanager/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dnsmanager/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dnsmanager/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test_app/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include dnsmanager *.txt 4 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voltgrid/django-dnsmanager/HEAD/docs/screenshot.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django 2 | dnspython==1.12.0 3 | django-reversion==1.9.3 4 | 5 | # Testing / Development 6 | model-mommy==1.2.6 7 | -------------------------------------------------------------------------------- /dnsmanager/signals.py: -------------------------------------------------------------------------------- 1 | import django.dispatch 2 | 3 | # signal 4 | zone_fully_saved_signal = django.dispatch.Signal(providing_args=["instance", "created"]) -------------------------------------------------------------------------------- /dnsmanager/templates/dnsmanager/zone_list.txt: -------------------------------------------------------------------------------- 1 | {% for zone in object_list %} 2 | {% if zone.is_valid %}zone "{{ zone.domain }}" { type master; file "/var/named/masters/{{ zone.domain }}.zone"; };{% endif %} 3 | {% endfor %} -------------------------------------------------------------------------------- /test_app/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, include, url 2 | 3 | # from django.contrib import admin 4 | # admin.autodiscover() 5 | 6 | urlpatterns = patterns('', 7 | url(r'^dns/', include('dnsmanager.urls')), 8 | #url(r'^admin/', include(admin.site.urls)), 9 | ) -------------------------------------------------------------------------------- /test_app/mommy_recipes.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | 3 | from model_mommy.recipe import Recipe, foreign_key, seq 4 | 5 | from .models import Domain 6 | 7 | user = Recipe(User) 8 | 9 | domain = Recipe(Domain, user=foreign_key(user), name="host-%s.example.com" % seq(1)) -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_app.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /test_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User 3 | 4 | 5 | class Domain(models.Model): 6 | 7 | user = models.ForeignKey(User, related_name='domains') 8 | name = models.CharField(max_length=253, unique=True, help_text='Domain Name') 9 | 10 | class Meta: 11 | ordering = ['name'] 12 | 13 | def __unicode__(self): 14 | return "%s" % self.name -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - 2.7 5 | 6 | env: 7 | - DJANGO=Django==1.8.17 8 | - DJANGO=Django==1.9.12 9 | 10 | install: 11 | - pip install -q $DJANGO 12 | - pip install coveralls model-mommy==1.2.6 13 | - python setup.py -q install 14 | 15 | before_script: 16 | - rm -rf build 17 | 18 | script: 19 | - coverage run manage.py test -v 2 dnsmanager 20 | 21 | after_success: 22 | - coveralls --verbose 23 | 24 | sudo: false -------------------------------------------------------------------------------- /dnsmanager/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import permission_required 2 | from django.conf.urls import patterns, url 3 | 4 | from .views import ZoneListView, ZoneDetailView 5 | 6 | urlpatterns = patterns('', 7 | url(r'^zone/$', 8 | permission_required('zone.view_zones')(ZoneListView.as_view()), 9 | name='zone_list'), 10 | url(r'^zone/(?P[\-\d\w]+)$', 11 | permission_required('zone.view_zones')(ZoneDetailView.as_view()), 12 | name='zone_detail'), 13 | ) -------------------------------------------------------------------------------- /dnsmanager/migrations/0002_canonicalnamerecord_unique.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('dnsmanager', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterUniqueTogether( 15 | name='canonicalnamerecord', 16 | unique_together=set([('zone', 'data')]), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /test_app/runtests.py: -------------------------------------------------------------------------------- 1 | import os, sys 2 | os.environ['DJANGO_SETTINGS_MODULE'] = 'test_app.settings' 3 | test_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 4 | sys.path.insert(0, test_dir) 5 | 6 | import django 7 | django.setup() 8 | 9 | from django.test.utils import get_runner 10 | from django.conf import settings 11 | 12 | def runtests(): 13 | TestRunner = get_runner(settings) 14 | test_runner = TestRunner(verbosity=1, interactive=False) 15 | failures = test_runner.run_tests([]) 16 | sys.exit(bool(failures)) 17 | 18 | if __name__ == '__main__': 19 | runtests() 20 | -------------------------------------------------------------------------------- /dnsmanager/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from defaults import ZONE_DEFAULTS_DEFAULT, DNS_MANAGER_RECIPES_DEFAULT, DNS_MANAGER_NAMESERVERS_DEFAULT 4 | 5 | ZONE_DEFAULTS = getattr(settings, 'ZONE_DEFAULTS', ZONE_DEFAULTS_DEFAULT) 6 | 7 | DNS_MANAGER_DOMAIN_MODEL = getattr(settings, 'DNS_MANAGER_DOMAIN_MODEL', None) 8 | DNS_MANAGER_ZONE_ADMIN_FILTER = getattr(settings, 'DNS_MANAGER_ZONE_ADMIN_FILTER', None) 9 | DNS_MANAGER_RECIPES = getattr(settings, 'DNS_MANAGER_RECIPES', DNS_MANAGER_RECIPES_DEFAULT) 10 | 11 | # These are the nameservers that we expect our zones to be delegated to 12 | DNS_MANAGER_NAMESERVERS = getattr(settings, 'DNS_MANAGER_NAMESERVERS', DNS_MANAGER_NAMESERVERS_DEFAULT) -------------------------------------------------------------------------------- /dnsmanager/migrations/0004_mx_origin_support.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('dnsmanager', '0003_ns_origin_support'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='mailexchangerecord', 16 | name='origin', 17 | field=models.CharField(default=b'@', help_text=b'MX Origin', max_length=255), 18 | ), 19 | migrations.AlterUniqueTogether( 20 | name='mailexchangerecord', 21 | unique_together=set([('zone', 'data', 'origin')]), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /dnsmanager/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import ListView 2 | from django.views.generic import DetailView 3 | 4 | from .models import Zone 5 | 6 | 7 | class ZoneListView(ListView): 8 | model = Zone 9 | template_name = 'dnsmanager/zone_list.txt' 10 | 11 | def render_to_response(self, context, **response_kwargs): 12 | return super(ZoneListView, self).render_to_response(context, content_type='text/plain', **response_kwargs) 13 | 14 | 15 | class ZoneDetailView(DetailView): 16 | queryset = Zone.objects.all() 17 | template_name = 'dnsmanager/zone_detail.txt' 18 | 19 | def render_to_response(self, context, **response_kwargs): 20 | return super(ZoneDetailView, self).render_to_response(context, content_type='text/plain', **response_kwargs) -------------------------------------------------------------------------------- /dnsmanager/management/commands/viewzone.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django.template.loader import render_to_string 3 | 4 | from dnsmanager.models import Zone 5 | 6 | 7 | class Command(BaseCommand): 8 | args = '' 9 | help = 'View a the specified Bind zone or bind zone list' 10 | 11 | def handle(self, *args, **options): 12 | 13 | if len(args) == 0: 14 | zone_list = Zone.objects.all() 15 | rendered = render_to_string('dnsmanager/zone_list.txt', {'object_list': zone_list}) 16 | self.stdout.write('%s' % rendered) 17 | else: 18 | for zone_id in args: 19 | zone = Zone.objects.get(pk=zone_id) 20 | self.stdout.write('%s' % zone.render()) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | .eggs/ 25 | include/ 26 | 27 | # Installer logs 28 | pip-log.txt 29 | pip-delete-this-directory.txt 30 | 31 | # Unit test / coverage reports 32 | htmlcov/ 33 | .tox/ 34 | .coverage 35 | .cache 36 | nosetests.xml 37 | coverage.xml 38 | 39 | # Translations 40 | *.mo 41 | 42 | # Mr Developer 43 | .mr.developer.cfg 44 | .project 45 | .pydevproject 46 | 47 | # Rope 48 | .ropeproject 49 | 50 | # Django stuff: 51 | *.log 52 | *.pot 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # Pycharm 58 | .idea 59 | -------------------------------------------------------------------------------- /dnsmanager/defaults.py: -------------------------------------------------------------------------------- 1 | 2 | ZONE_DEFAULTS_DEFAULT = dict() 3 | ZONE_DEFAULTS_DEFAULT['refresh'] = 28800 # 8 hours 4 | ZONE_DEFAULTS_DEFAULT['retry'] = 7200 # 2 hours 5 | ZONE_DEFAULTS_DEFAULT['expire'] = 2419200 # 1 month 6 | ZONE_DEFAULTS_DEFAULT['minimum'] = 600 # 10 minutes 7 | ZONE_DEFAULTS_DEFAULT['ttl'] = 3600 # 1 hour 8 | ZONE_DEFAULTS_DEFAULT['soa'] = 'hostmaster' 9 | 10 | DNS_MANAGER_RECIPES_DEFAULT = ( 11 | ('dnsmanager.recipes.GoogleApps', 'Set Google Apps MX / CNAME'), 12 | ('dnsmanager.recipes.Office365', 'Set Office 365 MX / CNAME / SPF / SRV'), 13 | ('dnsmanager.recipes.RemovePerRecordTtls', 'Reset Record TTLs'), 14 | ('dnsmanager.recipes.ResetZoneDefaults', 'Reset Zone Defaults'), 15 | ('dnsmanager.recipes.ReSave', 'Force Resave / Publish'), 16 | ('dnsmanager.recipes.ReValidate', 'Force Revalidation') 17 | ) 18 | 19 | DNS_MANAGER_NAMESERVERS_DEFAULT = ('ns1.example.com.', 'ns2.example.com.') -------------------------------------------------------------------------------- /test_app/settings.py: -------------------------------------------------------------------------------- 1 | print('Loaded %s' % __file__) 2 | 3 | DATABASES = { 4 | 'default': { 5 | 'ENGINE': 'django.db.backends.sqlite3', 6 | 'NAME': ':memory:', 7 | }, 8 | } 9 | 10 | INSTALLED_APPS = ( 11 | 'django.contrib.auth', 12 | 'django.contrib.sessions', 13 | 'django.contrib.contenttypes', 14 | 'dnsmanager', 15 | 'test_app', 16 | ) 17 | 18 | SECRET_KEY = '_' 19 | ROOT_URLCONF = 'test_app.urls' 20 | 21 | TEMPLATE_LOADERS = ( 22 | 'django.template.loaders.app_directories.Loader', 23 | ) 24 | 25 | MIDDLEWARE_CLASSES = ( 26 | 'django.middleware.common.CommonMiddleware', 27 | 'django.contrib.sessions.middleware.SessionMiddleware', 28 | 'django.middleware.csrf.CsrfViewMiddleware', 29 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 30 | 'django.contrib.messages.middleware.MessageMiddleware', 31 | ) 32 | 33 | DNS_MANAGER_DOMAIN_MODEL = 'test_app.Domain' 34 | 35 | from model_mommy.generators import gen_integer 36 | 37 | MOMMY_CUSTOM_FIELDS_GEN = { 38 | 'dnsmanager.models.IntegerRangeField': gen_integer, 39 | } -------------------------------------------------------------------------------- /dnsmanager/mommy_recipes.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from model_mommy.recipe import Recipe, foreign_key, seq 4 | from model_mommy import mommy 5 | 6 | from .models import Zone, AddressRecord, CanonicalNameRecord, MailExchangeRecord, \ 7 | NameServerRecord, TextRecord, ServiceRecord 8 | 9 | # Dynamically import the mommy_recipe for the DOMAIN_MODEL 10 | # If `DNS_MANAGER_DOMAIN_MODEL = 'vg.accounts.Domains'` then 11 | # this is equivalent to: `from vg.account.mommy_recipes import domain` 12 | 13 | t = settings.DNS_MANAGER_DOMAIN_MODEL.rsplit('.', 1)[0] 14 | module = __import__(t + '.mommy_recipes', fromlist=['domain']) 15 | domain = getattr(module, 'domain') 16 | 17 | zone = Recipe(Zone, domain=foreign_key(domain)) 18 | 19 | address_record = Recipe(AddressRecord, zone=foreign_key(zone), ip=mommy.generators.gen_ipv4(),) 20 | 21 | cname_record = Recipe(CanonicalNameRecord, zone=foreign_key(zone)) 22 | 23 | mx_record = Recipe(MailExchangeRecord, zone=foreign_key(zone)) 24 | 25 | ns_record = Recipe(NameServerRecord, zone=foreign_key(zone)) 26 | 27 | text_record = Recipe(TextRecord, zone=foreign_key(zone), text='"%s"' % seq("test")) 28 | 29 | service_record = Recipe(ServiceRecord, zone=foreign_key(zone)) 30 | -------------------------------------------------------------------------------- /dnsmanager/management/commands/importbind.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.management.base import BaseCommand, CommandError 4 | from django.conf import settings 5 | from django.db.models.loading import get_model 6 | from dnsmanager.models import Zone 7 | 8 | 9 | class Command(BaseCommand): 10 | args = '' 11 | help = 'Import the specified Bind zone file' 12 | 13 | def handle(self, *args, **options): 14 | 15 | if int(options.get("verbosity", 1)) > 1: 16 | verbose = True 17 | else: 18 | verbose = False 19 | 20 | for zone_file in args: 21 | # assume filename is domain 22 | domain = os.path.splitext(os.path.basename(zone_file))[0] 23 | 24 | # Dynamically load our Domain name providing model 25 | app_label, model_name = settings.DNS_MANAGER_DOMAIN_MODEL.rsplit('.', 1) 26 | domain_model = get_model(app_label, model_name) 27 | domain_obj = domain_model.objects.get(name=domain) 28 | 29 | # Domain must already be created in accounts 30 | zone, created = Zone.objects.get_or_create(domain=domain_obj) 31 | 32 | with open(zone_file, mode='r') as f: 33 | text = f.read() 34 | 35 | zone.update_from_text(text) 36 | 37 | self.stdout.write('Successfully imported file "%s"' % zone_file) -------------------------------------------------------------------------------- /dnsmanager/migrations/0003_ns_origin_support.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('dnsmanager', '0002_canonicalnamerecord_unique'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterModelOptions( 15 | name='addressrecord', 16 | options={'ordering': ['data']}, 17 | ), 18 | migrations.AlterModelOptions( 19 | name='canonicalnamerecord', 20 | options={'ordering': ['data']}, 21 | ), 22 | migrations.AlterModelOptions( 23 | name='nameserverrecord', 24 | options={'ordering': ['data']}, 25 | ), 26 | migrations.AlterModelOptions( 27 | name='servicerecord', 28 | options={'ordering': ['data', 'priority', 'target']}, 29 | ), 30 | migrations.AlterModelOptions( 31 | name='textrecord', 32 | options={'ordering': ['data', 'text']}, 33 | ), 34 | migrations.AddField( 35 | model_name='nameserverrecord', 36 | name='origin', 37 | field=models.CharField(default=b'@', help_text=b'NS Origin', max_length=255), 38 | ), 39 | migrations.AlterUniqueTogether( 40 | name='nameserverrecord', 41 | unique_together=set([('zone', 'data', 'origin')]), 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Volt Grid 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the {organization} nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | VERSION = '0.0.2' 4 | 5 | try: 6 | from pypandoc import convert 7 | read_md = lambda f: convert(f, 'rst') 8 | except ImportError: 9 | print("Warning: pypandoc module not found, could not convert Markdown to RST") 10 | read_md = lambda f: open(f, 'r').read() 11 | 12 | setup( 13 | name='django-dnsmanager', 14 | version=VERSION, 15 | description='Reusable Django app that provides DNS Zone editing and management', 16 | long_description=read_md('README.md'), 17 | author='Andrew Cutler', 18 | author_email='andrew@voltgrid.com', 19 | url='https://github.com/voltgrid/django-dnsmanager', 20 | package_dir={'dnsmanager': 'dnsmanager'}, 21 | packages=find_packages(), 22 | package_data = { 23 | # If any package contains *.txt etc include 24 | '': ['*.txt',], 25 | }, 26 | include_package_data=True, 27 | zip_safe=False, 28 | classifiers=[ 29 | 'Development Status :: 4 - Beta', 30 | 'Framework :: Django', 31 | 'Intended Audience :: Developers', 32 | 'Intended Audience :: System Administrators', 33 | 'License :: OSI Approved :: BSD License', 34 | 'Operating System :: OS Independent', 35 | 'Programming Language :: Python', 36 | 'Topic :: Internet :: Name Service (DNS)', 37 | 'Topic :: System :: Systems Administration' 38 | ], 39 | install_requires=[ 40 | 'django>=1.8', 41 | 'django<1.9', 42 | 'dnspython', 43 | 'django-reversion==1.9.3' 44 | ], 45 | tests_require=['coveralls', 'model_mommy'], 46 | test_suite='test_app.runtests.runtests', 47 | ) 48 | -------------------------------------------------------------------------------- /dnsmanager/templates/dnsmanager/zone_detail.txt: -------------------------------------------------------------------------------- 1 | $ORIGIN . 2 | $TTL {{ object.ttl }} 3 | {{ object.domain }} IN SOA {% with object.nameserverrecords.all|first as soa_ns %}{{ soa_ns.data }}{% endwith %} {{ object.rname }} ( 4 | {{ object.serial }} ; serial 5 | {{ object.refresh }} ; refresh 6 | {{ object.retry }} ; retry 7 | {{ object.expire }} ; expire 8 | {{ object.minimum }} ; minimum: nxdomain ttl (bind 9+) 9 | ) 10 | 11 | $ORIGIN {{ object.domain }}. 12 | 13 | ; Name Server Records 14 | {% for object in object.nameserverrecords.all %} 15 | {{ object.origin }} {{ object.ttlx }} IN NS {{ object.data }} 16 | {% endfor %} 17 | 18 | ; Address Records 19 | {% for object in object.addressrecords.all %} 20 | {{ object.data }} {{ object.ttlx }} IN A {{ object.ip }} 21 | {% endfor %} 22 | 23 | ; Canonical Name Records 24 | {% for object in object.canonicalnamerecords.all %} 25 | {{ object.data }} {{ object.ttlx }} IN CNAME {{ object.target }} 26 | {% endfor %} 27 | 28 | ; Mail Exchange Records 29 | {% for object in object.mailexchangerecords.all %} 30 | {{ object.origin }} {{ object.ttlx }} IN MX {{ object.priority }} {{ object.data }} 31 | {% endfor %} 32 | 33 | ; TXT Records 34 | {% for object in object.textrecords.all %} 35 | {{ object.data }} {{ object.ttlx }} IN TXT {{ object.text|safe }} 36 | {% endfor %} 37 | 38 | ; SRV Records 39 | {% for object in object.servicerecords.all %} 40 | {{ object.data }} {{ object.ttlx }} IN SRV {{ object.priority }} {{ object.weight }} {{ object.port }} {{ object.target }} 41 | {% endfor %} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DNS Manager for Django 2 | 3 | Reusable Django app that provides DNS Zone editing and management. 4 | 5 | This is used by [Volt Grid](https://www.voltgrid.com/) and [Panubo DNS UI](https://github.com/panubo/panubo-dns). 6 | 7 | [![Build Status](https://travis-ci.org/voltgrid/django-dnsmanager.svg?branch=master)](https://travis-ci.org/voltgrid/django-dnsmanager) 8 | [![Coverage Status](https://coveralls.io/repos/voltgrid/django-dnsmanager/badge.png)](https://coveralls.io/r/voltgrid/django-dnsmanager) 9 | 10 | ![Screenshot of DNS Manager Zone Editing](docs/screenshot.png "Zone Editing") 11 | 12 | ## Features 13 | 14 | * Import & Export Bind zone files 15 | * Zone list generation 16 | * Zone validation 17 | * Recipe based zone updates (eg one click add Google Apps MX / Cname records) 18 | * Easily integrate with Bind 19 | * Zone versioning using Django Reversion 20 | 21 | ## Installation 22 | 23 | Install with pip: 24 | 25 | pip install git+https://github.com/voltgrid/django-dnsmanager.git#egg=dnsmanager 26 | 27 | Add to your Django project in your Python path. 28 | 29 | Add `reversion` and `dnsmanager` to your `INSTALLED_APPS`. 30 | 31 | Set `DNS_MANAGER_DOMAIN_MODEL` in `settings.py`. This must point to a model that provides a _name_ field. Eg: 32 | 33 | class Domain(models.Model): 34 | 35 | user = models.ForeignKey(User, related_name='domains') 36 | name = models.CharField(max_length=253, unique=True, help_text='Domain Name') 37 | 38 | class Meta: 39 | ordering = ['name'] 40 | 41 | def __unicode__(self): 42 | return "%s" % self.name 43 | 44 | Set `DNS_MANAGER_ZONE_ADMIN_FILTER` in `settings.py`. This must point to a filterable entity `= ('domain__user', )` 45 | 46 | Run `manage.py syncdb`. 47 | -------------------------------------------------------------------------------- /dnsmanager/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | import reversion 3 | 4 | import settings 5 | from signals import zone_fully_saved_signal 6 | 7 | 8 | from models import AddressRecord, CanonicalNameRecord, MailExchangeRecord, \ 9 | NameServerRecord, TextRecord, ServiceRecord, Zone 10 | 11 | 12 | class AddressRecordInline(admin.TabularInline): 13 | model = AddressRecord 14 | extra = 0 15 | 16 | 17 | class CanonicalNameRecordInline(admin.TabularInline): 18 | model = CanonicalNameRecord 19 | extra = 0 20 | 21 | 22 | class MailExchangeRecordInline(admin.TabularInline): 23 | model = MailExchangeRecord 24 | extra = 0 25 | 26 | 27 | class NameServerRecordInline(admin.TabularInline): 28 | model = NameServerRecord 29 | extra = 0 30 | 31 | 32 | class TextRecordInline(admin.TabularInline): 33 | model = TextRecord 34 | extra = 0 35 | 36 | 37 | class ServiceRecordInline(admin.TabularInline): 38 | model = ServiceRecord 39 | extra = 0 40 | 41 | 42 | @admin.register(Zone) 43 | class ZoneAdmin(reversion.VersionAdmin): 44 | inlines = [AddressRecordInline, 45 | CanonicalNameRecordInline, 46 | MailExchangeRecordInline, 47 | NameServerRecordInline, 48 | TextRecordInline, 49 | ServiceRecordInline] 50 | list_display = ('__unicode__', 'is_valid', 'is_delegated') 51 | list_filter = settings.DNS_MANAGER_ZONE_ADMIN_FILTER 52 | search_fields = ['domain__name', 53 | 'addressrecords__data', 54 | 'addressrecords__ip', 55 | 'canonicalnamerecords__data', 56 | 'canonicalnamerecords__target', 57 | 'mailexchangerecords__data', 58 | 'mailexchangerecords__origin', 59 | 'nameserverrecords__data', 60 | 'nameserverrecords__origin', 61 | 'textrecords__data', 62 | 'textrecords__text', 63 | 'servicerecords__data', 64 | 'servicerecords__target', 65 | ] 66 | 67 | def run_recipe(self, recipe): 68 | """ Execute the given recipe from the recipe model """ 69 | @reversion.create_revision() 70 | def apply_recipe(modeladmin, request, queryset): 71 | for zone in queryset.all(): 72 | r = recipe(zone) 73 | r.save() 74 | zone_fully_saved_signal.send(sender=self.__class__, instance=zone, created=False) 75 | return apply_recipe 76 | 77 | def get_actions(self, request): 78 | """ Set our custom recipe actions """ 79 | actions = super(ZoneAdmin, self).get_actions(request) 80 | for item in settings.DNS_MANAGER_RECIPES: 81 | package, name = item[0].rsplit('.', 1) 82 | module = __import__(package, fromlist=[name]) 83 | cls = getattr(module, name) 84 | actions[item[1]] = (self.run_recipe(cls), item[1], item[1]) 85 | return actions 86 | 87 | def response_add(self, request, obj, post_url_continue=None): 88 | obj = self.after_saving_model_and_related_inlines(obj, created=True) 89 | return super(ZoneAdmin, self).response_add(request, obj) 90 | 91 | def response_change(self, request, obj): 92 | obj = self.after_saving_model_and_related_inlines(obj, created=False) 93 | return super(ZoneAdmin, self).response_change(request, obj) 94 | 95 | def after_saving_model_and_related_inlines(self, obj, created): 96 | zone_fully_saved_signal.send(sender=self.__class__, instance=obj, created=created) 97 | return obj -------------------------------------------------------------------------------- /dnsmanager/recipes.py: -------------------------------------------------------------------------------- 1 | from .models import AddressRecord 2 | from .models import CanonicalNameRecord 3 | from .models import MailExchangeRecord 4 | from .models import NameServerRecord 5 | from .models import TextRecord 6 | from .models import ServiceRecord 7 | from .settings import ZONE_DEFAULTS 8 | 9 | 10 | class Recipe(object): 11 | """ Custom Zone Recipe """ 12 | def __init__(self, zone): 13 | self.zone = zone 14 | 15 | def save(self): 16 | self.zone.save() 17 | 18 | 19 | class NameServerRecipe(Recipe): 20 | """ Superclass for Name Server Recipe """ 21 | data = None 22 | 23 | def __init__(self, zone): 24 | super(NameServerRecipe, self).__init__(zone) 25 | self.set_ns(self.data) 26 | 27 | def set_ns(self, data): 28 | if data is not None: 29 | # Remove existing NS 30 | NameServerRecord.objects.filter(zone=self.zone).delete() 31 | for d in data: 32 | NameServerRecord.objects.get_or_create(zone=self.zone, data=d) 33 | 34 | 35 | class CnameRecipe(Recipe): 36 | """ Superclass for CNAME Recipe """ 37 | 38 | data = None 39 | 40 | def __init__(self, zone): 41 | super(CnameRecipe, self).__init__(zone) 42 | self.set_cname(self.data) 43 | 44 | def set_cname(self, data): 45 | if data is not None: 46 | for d, t, ttl in data: 47 | CanonicalNameRecord.objects.get_or_create(zone=self.zone, data=d, target=t, ttl=ttl) 48 | 49 | 50 | class MxRecipe(Recipe): 51 | """ Superclass for MX Recipe """ 52 | 53 | data = None 54 | 55 | def __init__(self, zone): 56 | super(MxRecipe, self).__init__(zone) 57 | self.set_mx(self.data) 58 | 59 | def set_mx(self, data): 60 | if data is not None: 61 | # Remove existing MX 62 | MailExchangeRecord.objects.filter(zone=self.zone).delete() 63 | for p, d, ttl in data: 64 | MailExchangeRecord.objects.get_or_create(zone=self.zone, data=d, priority=p, ttl=ttl) 65 | 66 | 67 | class SPFRecipe(Recipe): 68 | """ Superclass for Text Recipe """ 69 | 70 | data = None 71 | 72 | def __init__(self, zone): 73 | super(SPFRecipe, self).__init__(zone) 74 | self.set_spf(self.data) 75 | 76 | def set_spf(self, data): 77 | if data is not None: 78 | # Remove existing SPF 79 | TextRecord.objects.filter(zone=self.zone, text__startswith='"v=spf1').delete() 80 | for spf, ttl in data: 81 | TextRecord.objects.get_or_create(zone=self.zone, data='@', text=spf, ttl=ttl) 82 | 83 | 84 | class ServiceRecipe(Recipe): 85 | """ Superclass for Service Recipe """ 86 | 87 | data = None 88 | 89 | def __init__(self, zone): 90 | super(ServiceRecipe, self).__init__(zone) 91 | self.set_service(self.data) 92 | 93 | def set_service(self, data): 94 | if data is not None: 95 | for data, target, priority, weight, port, ttl in data: 96 | ServiceRecord.objects.get_or_create(zone=self.zone, priority=priority, weight=weight, port=port, target=target, data=data, ttl=ttl) 97 | 98 | 99 | class Office365(CnameRecipe, MxRecipe, SPFRecipe, ServiceRecipe): 100 | 101 | data_mx = [ 102 | ('0', 'mail.protection.outlook.com.', 3600), 103 | ] 104 | 105 | data_cname = [ 106 | ('autodiscover', 'autodiscover.outlook.com.', 3600), 107 | ('lyncdiscover', 'webdir.online.lync.com.', 3600), 108 | ('sip', 'sipdir.online.lync.com.', 3600), 109 | ('msoid', 'clientconfig.microsoftonline-p.net.', 3600), 110 | ] 111 | 112 | data_spf = [ 113 | ('"v=spf1 include:spf.protection.outlook.com -all"', 3600), 114 | ] 115 | 116 | # data, target, priority, weight, port, ttl 117 | data_service = [ 118 | ('_sipfederationtls._tcp', 'sipfed.online.lync.com.', 100, 1, 5061, 3600), 119 | ('_sip._tls', 'sipdir.online.lync.com.', 100, 1, 443, 3600), 120 | ] 121 | 122 | def __init__(self, zone): 123 | super(Office365, self).__init__(zone) 124 | self.set_mx(self.data_mx) 125 | self.set_cname(self.data_cname) 126 | self.set_spf(self.data_spf) 127 | self.set_service(self.data_service) 128 | 129 | 130 | class GoogleApps(CnameRecipe, MxRecipe): 131 | 132 | data_cname = [ 133 | ('calendar', 'ghs.googlehosted.com.', None), 134 | ('docs', 'ghs.googlehosted.com.', None), 135 | ('mail', 'ghs.googlehosted.com.', None), 136 | ('sites', 'ghs.googlehosted.com.', None), 137 | ('video', 'ghs.googlehosted.com.', None), 138 | ] 139 | 140 | data_mx = [ 141 | ('10', 'aspmx.l.google.com.', None), 142 | ('20', 'alt1.aspmx.l.google.com.', None), 143 | ('20', 'alt2.aspmx.l.google.com.', None), 144 | ('30', 'alt3.aspmx.l.google.com.', None), 145 | ('30', 'alt4.aspmx.l.google.com.', None), 146 | ] 147 | 148 | def __init__(self, zone): 149 | super(GoogleApps, self).__init__(zone) 150 | self.set_cname(self.data_cname) 151 | self.set_mx(self.data_mx) 152 | 153 | 154 | class RemovePerRecordTtls(Recipe): 155 | """ Remove Per Record TTLs """ 156 | def __init__(self, zone): 157 | super(RemovePerRecordTtls, self).__init__(zone) 158 | AddressRecord.objects.filter(zone=self.zone).update(ttl=None) 159 | CanonicalNameRecord.objects.filter(zone=self.zone).update(ttl=None) 160 | MailExchangeRecord.objects.filter(zone=self.zone).update(ttl=None) 161 | NameServerRecord.objects.filter(zone=self.zone).update(ttl=None) 162 | TextRecord.objects.filter(zone=self.zone).update(ttl=None) 163 | ServiceRecord.objects.filter(zone=self.zone).update(ttl=None) 164 | 165 | 166 | class ResetZoneDefaults(Recipe): 167 | """ Reset Zone TTLs, expiry etc """ 168 | def __init__(self, zone): 169 | super(ResetZoneDefaults, self).__init__(zone) 170 | self.zone.refresh = ZONE_DEFAULTS['refresh'] 171 | self.zone.retry = ZONE_DEFAULTS['retry'] 172 | self.zone.expire = ZONE_DEFAULTS['expire'] 173 | self.zone.minimum = ZONE_DEFAULTS['minimum'] 174 | self.zone.ttl = ZONE_DEFAULTS['ttl'] 175 | self.zone.soa_email = ZONE_DEFAULTS['soa'] 176 | 177 | 178 | class ReSave(Recipe): 179 | """ Force resave of the zone """ 180 | pass # Null recipe 181 | 182 | 183 | class ReValidate(Recipe): 184 | """ Force revalidation of the zone """ 185 | def __init__(self, zone): 186 | super(ReValidate, self).__init__(zone) 187 | self.zone.clear_cache() 188 | 189 | def save(self): 190 | pass 191 | -------------------------------------------------------------------------------- /dnsmanager/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | import dnsmanager.models 6 | from dnsmanager.settings import DNS_MANAGER_DOMAIN_MODEL 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name='AddressRecord', 14 | fields=[ 15 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 16 | ('created', models.DateTimeField(auto_now_add=True, verbose_name=b'Date Created')), 17 | ('updated', models.DateTimeField(auto_now=True, verbose_name=b'Date Updated')), 18 | ('version', models.IntegerField(default=0, editable=False)), 19 | ('data', models.CharField(help_text=b'Data', max_length=255)), 20 | ('ttl', models.PositiveIntegerField(null=True, blank=True)), 21 | ('ip', models.GenericIPAddressField(help_text=b'IP Address')), 22 | ], 23 | options={ 24 | 'db_table': 'dns_addressrecord', 25 | }, 26 | ), 27 | migrations.CreateModel( 28 | name='CanonicalNameRecord', 29 | fields=[ 30 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 31 | ('created', models.DateTimeField(auto_now_add=True, verbose_name=b'Date Created')), 32 | ('updated', models.DateTimeField(auto_now=True, verbose_name=b'Date Updated')), 33 | ('version', models.IntegerField(default=0, editable=False)), 34 | ('data', models.CharField(help_text=b'Data', max_length=255)), 35 | ('ttl', models.PositiveIntegerField(null=True, blank=True)), 36 | ('target', models.CharField(help_text=b'Target', max_length=128)), 37 | ], 38 | options={ 39 | 'db_table': 'dns_canonicalnamerecord', 40 | }, 41 | ), 42 | migrations.CreateModel( 43 | name='MailExchangeRecord', 44 | fields=[ 45 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 46 | ('created', models.DateTimeField(auto_now_add=True, verbose_name=b'Date Created')), 47 | ('updated', models.DateTimeField(auto_now=True, verbose_name=b'Date Updated')), 48 | ('version', models.IntegerField(default=0, editable=False)), 49 | ('data', models.CharField(help_text=b'Data', max_length=255)), 50 | ('ttl', models.PositiveIntegerField(null=True, blank=True)), 51 | ('priority', dnsmanager.models.IntegerRangeField(help_text=b'Priority')), 52 | ], 53 | options={ 54 | 'ordering': ['priority', 'data'], 55 | 'db_table': 'dns_mailexchangerecord', 56 | }, 57 | ), 58 | migrations.CreateModel( 59 | name='NameServerRecord', 60 | fields=[ 61 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 62 | ('created', models.DateTimeField(auto_now_add=True, verbose_name=b'Date Created')), 63 | ('updated', models.DateTimeField(auto_now=True, verbose_name=b'Date Updated')), 64 | ('version', models.IntegerField(default=0, editable=False)), 65 | ('data', models.CharField(help_text=b'Data', max_length=255)), 66 | ('ttl', models.PositiveIntegerField(null=True, blank=True)), 67 | ], 68 | options={ 69 | 'db_table': 'dns_nameserverrecord', 70 | }, 71 | ), 72 | migrations.CreateModel( 73 | name='ServiceRecord', 74 | fields=[ 75 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 76 | ('created', models.DateTimeField(auto_now_add=True, verbose_name=b'Date Created')), 77 | ('updated', models.DateTimeField(auto_now=True, verbose_name=b'Date Updated')), 78 | ('version', models.IntegerField(default=0, editable=False)), 79 | ('data', models.CharField(help_text=b'Data', max_length=255)), 80 | ('ttl', models.PositiveIntegerField(null=True, blank=True)), 81 | ('priority', dnsmanager.models.IntegerRangeField(help_text=b'Priority')), 82 | ('weight', dnsmanager.models.IntegerRangeField(help_text=b'Weight')), 83 | ('port', dnsmanager.models.IntegerRangeField(help_text=b'TCP / UDP Port')), 84 | ('target', models.CharField(help_text=b'Target', max_length=128)), 85 | ], 86 | options={ 87 | 'db_table': 'dns_servicerecord', 88 | }, 89 | ), 90 | migrations.CreateModel( 91 | name='TextRecord', 92 | fields=[ 93 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 94 | ('created', models.DateTimeField(auto_now_add=True, verbose_name=b'Date Created')), 95 | ('updated', models.DateTimeField(auto_now=True, verbose_name=b'Date Updated')), 96 | ('version', models.IntegerField(default=0, editable=False)), 97 | ('data', models.CharField(help_text=b'Data', max_length=255)), 98 | ('ttl', models.PositiveIntegerField(null=True, blank=True)), 99 | ('text', models.CharField(help_text=b'Text', max_length=255)), 100 | ], 101 | options={ 102 | 'db_table': 'dns_textrecord', 103 | }, 104 | ), 105 | migrations.CreateModel( 106 | name='Zone', 107 | fields=[ 108 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 109 | ('created', models.DateTimeField(auto_now_add=True, verbose_name=b'Date Created')), 110 | ('updated', models.DateTimeField(auto_now=True, verbose_name=b'Date Updated')), 111 | ('version', models.IntegerField(default=0, editable=False)), 112 | ('soa_email', models.CharField(default=b'hostmaster', max_length=128)), 113 | ('serial', models.PositiveIntegerField(default=0)), 114 | ('refresh', models.PositiveIntegerField(default=28800)), 115 | ('retry', models.PositiveIntegerField(default=7200)), 116 | ('expire', models.PositiveIntegerField(default=2419200)), 117 | ('minimum', models.PositiveIntegerField(default=600, help_text=b'nxdomain ttl, bind9+')), 118 | ('ttl', models.PositiveIntegerField(default=3600, help_text=b'Default record TTL')), 119 | ('domain', models.OneToOneField(to='.'.join(DNS_MANAGER_DOMAIN_MODEL.split('.')[-2:]))), 120 | ], 121 | options={ 122 | 'ordering': ['domain'], 123 | 'db_table': 'dns_zone', 124 | 'permissions': (('view_zones', 'Can view zones'),), 125 | }, 126 | ), 127 | migrations.AddField( 128 | model_name='textrecord', 129 | name='zone', 130 | field=models.ForeignKey(to='dnsmanager.Zone'), 131 | ), 132 | migrations.AddField( 133 | model_name='servicerecord', 134 | name='zone', 135 | field=models.ForeignKey(to='dnsmanager.Zone'), 136 | ), 137 | migrations.AddField( 138 | model_name='nameserverrecord', 139 | name='zone', 140 | field=models.ForeignKey(to='dnsmanager.Zone'), 141 | ), 142 | migrations.AddField( 143 | model_name='mailexchangerecord', 144 | name='zone', 145 | field=models.ForeignKey(to='dnsmanager.Zone'), 146 | ), 147 | migrations.AddField( 148 | model_name='canonicalnamerecord', 149 | name='zone', 150 | field=models.ForeignKey(to='dnsmanager.Zone'), 151 | ), 152 | migrations.AddField( 153 | model_name='addressrecord', 154 | name='zone', 155 | field=models.ForeignKey(to='dnsmanager.Zone'), 156 | ), 157 | migrations.AlterUniqueTogether( 158 | name='servicerecord', 159 | unique_together=set([('zone', 'data', 'target')]), 160 | ), 161 | migrations.AlterUniqueTogether( 162 | name='nameserverrecord', 163 | unique_together=set([('zone', 'data')]), 164 | ), 165 | migrations.AlterUniqueTogether( 166 | name='mailexchangerecord', 167 | unique_together=set([('zone', 'data')]), 168 | ), 169 | migrations.AlterUniqueTogether( 170 | name='canonicalnamerecord', 171 | unique_together=set([('zone', 'data', 'target')]), 172 | ), 173 | migrations.AlterUniqueTogether( 174 | name='addressrecord', 175 | unique_together=set([('zone', 'data', 'ip')]), 176 | ), 177 | ] 178 | -------------------------------------------------------------------------------- /dnsmanager/tests.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.exceptions import ValidationError 3 | from django.core.urlresolvers import reverse_lazy 4 | from django.contrib.auth.models import Permission 5 | from django.test import TestCase 6 | from django.test import RequestFactory 7 | 8 | from model_mommy import mommy 9 | 10 | from .models import Zone, AddressRecord, CanonicalNameRecord, MailExchangeRecord, NameServerRecord, TextRecord, \ 11 | validate_hostname_string, validate_hostname_digs 12 | 13 | from .views import ZoneListView, ZoneDetailView 14 | 15 | 16 | # Creation Tests 17 | class DNSCreationTest(TestCase): 18 | 19 | def test_zone_creation(self): 20 | zone = mommy.make_recipe('dnsmanager.zone') 21 | self.assertTrue(isinstance(zone, Zone)) 22 | return zone 23 | 24 | def test_address_record_creation(self): 25 | address_record = mommy.make_recipe('dnsmanager.address_record') 26 | self.assertTrue(isinstance(address_record, AddressRecord)) 27 | return address_record 28 | 29 | def test_complex_zone_creation(self): 30 | zone = mommy.make_recipe('dnsmanager.zone') 31 | # records 32 | address_record_1 = mommy.make_recipe('dnsmanager.address_record', zone=zone, data='@') 33 | cname_record = mommy.make_recipe('dnsmanager.cname_record', zone=zone, data='www', target='@') 34 | mx_record = mommy.make_recipe('dnsmanager.mx_record', zone=zone, priority=10) 35 | ns_record_1 = mommy.make_recipe('dnsmanager.ns_record', zone=zone, data='ns1.example.com') 36 | ns_record_2 = mommy.make_recipe('dnsmanager.ns_record', zone=zone, data='ns2.example.com') 37 | text_record = mommy.make_recipe('dnsmanager.text_record', zone=zone, data="@", text='"v=spf1 a -all"') 38 | srv_record = mommy.make_recipe('dnsmanager.service_record', 39 | zone=zone, 40 | data="_sip._tls", 41 | target='sip.example.com.', 42 | port=443, 43 | weight=10, 44 | priority=1) 45 | self.assertTrue(isinstance(zone, Zone)) 46 | 47 | def test_zone_import(self): 48 | data = { 49 | "id": 63, 50 | "domain": "voltgrid.com", 51 | "data": "$ORIGIN .\n" 52 | "$TTL 3600\n" 53 | "voltgrid.com IN SOA ns1.voltgrid.com. dns-admin (\n" 54 | " 2013120600 ; serial\n" 55 | " 28800 ; refresh\n" 56 | " 7200 ; retry\n" 57 | " 604800 ; expire\n" 58 | " 600 ; nxdomain ttl (bind 9+)\n" 59 | " )\n" 60 | "\n" 61 | "$ORIGIN voltgrid.com.\n" 62 | "\n" 63 | "; NameServerRecords\n" 64 | "\n" 65 | "@ 3600 IN NS ns1.voltgrid.com.\n" 66 | "\n" 67 | "@ 3600 IN NS ns2.voltgrid.com.\n" 68 | "\n" 69 | "\n" 70 | "; AddressRecords\n" 71 | "\n" 72 | "@ 3600 IN A 103.245.152.101\n" 73 | "\n" 74 | "www 3600 IN A 103.245.152.101\n" 75 | "\n" 76 | "\n" 77 | "; CanonicalNameRecord\n" 78 | "\n" 79 | "\n" 80 | "; MailExchangeRecord\n" 81 | "\n" 82 | "\n" 83 | "; TXT\n" 84 | "; SRV\n" 85 | "_sip._tls 3600 IN SRV 100 1 443 sipdir.online.lync.com.\n" 86 | "", 87 | "updated": "2013-12-06T04:30:12Z" 88 | } 89 | 90 | domain = mommy.make_recipe(settings.DNS_MANAGER_DOMAIN_MODEL.rsplit('.', 1)[0] + '.domain', name=data['domain']) 91 | 92 | zone, created = Zone.objects.get_or_create(domain=domain) 93 | zone.update_from_text(text=data['data']) 94 | 95 | self.assertEqual(zone.addressrecords.count(), 2) 96 | self.assertEqual(zone.nameserverrecords.count(), 2) 97 | self.assertEqual(zone.servicerecords.count(), 1) 98 | self.assertNotEqual(zone.serial, 2013120600) 99 | 100 | 101 | # Recipe Tests 102 | from dnsmanager.recipes import GoogleApps, Office365, RemovePerRecordTtls, ResetZoneDefaults 103 | 104 | 105 | class RecipeTest(TestCase): 106 | 107 | def test_create_zone(self): 108 | zone = mommy.make_recipe('dnsmanager.zone') 109 | # records 110 | address_record_1 = mommy.make_recipe('dnsmanager.address_record', zone=zone, data='@') 111 | cname_record = mommy.make_recipe('dnsmanager.cname_record', zone=zone, data='www', target='@') 112 | mx_record = mommy.make_recipe('dnsmanager.mx_record', zone=zone, priority=10) 113 | ns_record_1 = mommy.make_recipe('dnsmanager.ns_record', zone=zone, data='ns1.example.com') 114 | ns_record_2 = mommy.make_recipe('dnsmanager.ns_record', zone=zone, data='ns2.example.com') 115 | text_record = mommy.make_recipe('dnsmanager.text_record', zone=zone, data="@", text='"v=spf1 a -all"') 116 | return zone 117 | 118 | def test_google_apps_recipe(self): 119 | zone = mommy.make_recipe('dnsmanager.zone') 120 | # add a record that will be removed later 121 | mommy.make_recipe('dnsmanager.mx_record', zone=zone, priority=10) 122 | # run recipe 123 | GoogleApps(zone) 124 | self.assertEqual(zone.mailexchangerecords.all().count(), 5) 125 | self.assertGreaterEqual(zone.canonicalnamerecords.all().count(), 5) 126 | 127 | def test_office_365_recipe(self): 128 | zone = mommy.make_recipe('dnsmanager.zone') 129 | # add a record that will be removed later 130 | mommy.make_recipe('dnsmanager.mx_record', zone=zone, priority=10) 131 | # run recipe 132 | Office365(zone) 133 | self.assertEqual(zone.mailexchangerecords.all().count(), 1) 134 | self.assertEqual(zone.canonicalnamerecords.all().count(), 4) 135 | self.assertEqual(zone.textrecords.all().count(), 1) 136 | self.assertEqual(zone.servicerecords.all().count(), 2) 137 | 138 | def test_remove_record_ttl_email(self): 139 | zone = mommy.make_recipe('dnsmanager.zone') 140 | # add a few records that will be used later 141 | mommy.make_recipe('dnsmanager.address_record', zone=zone, ttl='1111') 142 | mommy.make_recipe('dnsmanager.cname_record', zone=zone, ttl='2222') 143 | mommy.make_recipe('dnsmanager.mx_record', zone=zone, ttl='3333') 144 | mommy.make_recipe('dnsmanager.ns_record', zone=zone, ttl='4444') 145 | mommy.make_recipe('dnsmanager.text_record', zone=zone, ttl='5555') 146 | # run recipe 147 | RemovePerRecordTtls(zone) 148 | # check results 149 | for obj in zone.addressrecords.all(): 150 | self.assertEqual(obj.ttl, None) 151 | for obj in zone.canonicalnamerecords.all(): 152 | self.assertEqual(obj.ttl, None) 153 | for obj in zone.mailexchangerecords.all(): 154 | self.assertEqual(obj.ttl, None) 155 | for obj in zone.nameserverrecords.all(): 156 | self.assertEqual(obj.ttl, None) 157 | for obj in zone.textrecords.all(): 158 | self.assertEqual(obj.ttl, None) 159 | 160 | def test_reset_zone_defaults(self): 161 | from .settings import ZONE_DEFAULTS 162 | from random import randint 163 | zone = mommy.make_recipe('dnsmanager.zone') 164 | # change our values 165 | zone.refresh = ZONE_DEFAULTS['refresh'] + randint(1, 10000) 166 | zone.retry = ZONE_DEFAULTS['retry'] + randint(1, 10000) 167 | zone.expire = ZONE_DEFAULTS['expire'] + randint(1, 10000) 168 | zone.minimum = ZONE_DEFAULTS['minimum'] + randint(1, 10000) 169 | zone.ttl = ZONE_DEFAULTS['ttl'] + randint(1, 10000) 170 | # run recipe 171 | ResetZoneDefaults(zone) 172 | # check results 173 | self.assertEqual(zone.refresh, ZONE_DEFAULTS['refresh']) 174 | self.assertEqual(zone.retry, ZONE_DEFAULTS['retry']) 175 | self.assertEqual(zone.expire, ZONE_DEFAULTS['expire']) 176 | self.assertEqual(zone.minimum, ZONE_DEFAULTS['minimum']) 177 | self.assertEqual(zone.ttl, ZONE_DEFAULTS['ttl']) 178 | 179 | 180 | class ZoneTest(TestCase): 181 | 182 | def setUp(self): 183 | self.user = mommy.make_recipe(settings.DNS_MANAGER_DOMAIN_MODEL.rsplit('.', 1)[0] + '.user') 184 | # grant permission 185 | permission = Permission.objects.get(codename='view_zones') 186 | self.user.user_permissions.add(permission) 187 | # Data 188 | self.domain_name = 'example.com.au' 189 | domain = mommy.make_recipe(settings.DNS_MANAGER_DOMAIN_MODEL.rsplit('.', 1)[0] + '.domain', name=self.domain_name) 190 | self.obj = mommy.make_recipe('dnsmanager.zone', domain=domain) 191 | self.factory = RequestFactory() 192 | 193 | def test_detail_view(self): 194 | # Actual request 195 | request = self.factory.get(reverse_lazy('zone_detail', kwargs={'pk': self.obj.domain.pk})) 196 | request.user = self.user 197 | view = ZoneDetailView.as_view() 198 | response = view(request, pk=self.obj.domain.pk) 199 | self.assertEqual(response.status_code, 200) 200 | 201 | def test_zone_list(self): 202 | request = self.factory.get(reverse_lazy('zone_list')) 203 | request.user = self.user 204 | view = ZoneListView.as_view() 205 | response = view(request) 206 | self.assertEqual(response.status_code, 200) 207 | 208 | 209 | class DomainValidationTest(TestCase): 210 | 211 | def test_leading_underscore(self): 212 | domain = '_foo.bar.example.com.' 213 | self.assertEquals(validate_hostname_string(domain), True) 214 | 215 | def test_inner_underscore(self): 216 | domain = 'foo._bar.example.com.' 217 | self.assertEquals(validate_hostname_string(domain), True) 218 | 219 | def test_leading_hyphen(self): 220 | domain = '-foo.bar.example.com.' 221 | with self.assertRaises(ValidationError): 222 | validate_hostname_string(domain) 223 | 224 | def test_inner_hyphen(self): 225 | domain = 'foo.-bar.example.com.' 226 | with self.assertRaises(ValidationError): 227 | validate_hostname_string(domain) 228 | 229 | def test_foo(self): 230 | self.assertEquals(validate_hostname_digs('example.com'), True) 231 | self.assertEquals(validate_hostname_digs('foo.example.com'), False) 232 | 233 | # TODO: Fixme 234 | # def test_too_long(self): 235 | # domain = 'foobarbazfoobarbazfoobarbazfoobarbazfoobarbazfoobarbazfoobarbazfoobarbaz.example.com.' 236 | # with self.assertRaises(ValidationError): 237 | # validate_hostname_string(domain) -------------------------------------------------------------------------------- /dnsmanager/models.py: -------------------------------------------------------------------------------- 1 | import time 2 | import socket 3 | import re 4 | 5 | import dns.message 6 | import dns.query 7 | import dns.resolver 8 | import dns.zone 9 | 10 | from django.core.cache import cache 11 | from django.core.exceptions import ValidationError 12 | from django.core.exceptions import ObjectDoesNotExist 13 | from django.core.urlresolvers import reverse 14 | from django.conf import settings 15 | from django.db import models 16 | from django.template.loader import render_to_string 17 | 18 | from .settings import ZONE_DEFAULTS, DNS_MANAGER_NAMESERVERS 19 | 20 | 21 | class IntegerRangeField(models.IntegerField): 22 | """ Allow limiting Integer fields """ 23 | 24 | def __init__(self, verbose_name=None, name=None, min_value=None, max_value=None, **kwargs): 25 | self.min_value, self.max_value = min_value, max_value 26 | models.IntegerField.__init__(self, verbose_name, name, **kwargs) 27 | 28 | def formfield(self, **kwargs): 29 | defaults = {'min_value': self.min_value, 'max_value':self.max_value} 30 | defaults.update(kwargs) 31 | return super(IntegerRangeField, self).formfield(**defaults) 32 | 33 | 34 | class DateMixin(models.Model): 35 | """ Model Mixin to add modification and creation datestamps """ 36 | created = models.DateTimeField("Date Created", auto_now_add=True) 37 | updated = models.DateTimeField("Date Updated", auto_now=True) 38 | 39 | version = models.IntegerField(default=0, editable=False) 40 | 41 | class Meta: 42 | abstract = True 43 | 44 | def save(self, *args, **kwargs): 45 | # increment version on save 46 | self.version += 1 47 | super(DateMixin, self).save(*args, **kwargs) 48 | 49 | 50 | def validate_hostname_exists(fqdn): 51 | """ 52 | :param hostname: Is hostname valid 53 | :return: True, or ValidationError 54 | """ 55 | # Check hostname exists 56 | try: 57 | socket.gethostbyname(fqdn) 58 | return True 59 | except socket.error: 60 | raise ValidationError('Hostname does not exist.') 61 | 62 | 63 | def validate_hostname_string(hostname): 64 | """ 65 | :param hostname: Is hostname valid 66 | :return: True, False, or ValidationError 67 | """ 68 | # More complete validation here http://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names 69 | if hostname == "@": 70 | return True 71 | if len(hostname) > 255: 72 | return False 73 | if hostname[-1] == ".": 74 | hostname = hostname[:-1] # strip exactly one dot from the right, if present 75 | allowed = re.compile("(?!-)([A-Z\d-]|\_){1,63}(? 0: 104 | return True 105 | else: 106 | return False 107 | 108 | 109 | class Zone(DateMixin): 110 | domain = models.OneToOneField('.'.join(settings.DNS_MANAGER_DOMAIN_MODEL.split('.')[-2:])) 111 | soa_email = models.CharField(max_length=128, default=ZONE_DEFAULTS['soa']) 112 | serial = models.PositiveIntegerField(default=0) 113 | refresh = models.PositiveIntegerField(default=ZONE_DEFAULTS['refresh']) 114 | retry = models.PositiveIntegerField(default=ZONE_DEFAULTS['retry']) 115 | expire = models.PositiveIntegerField(default=ZONE_DEFAULTS['expire']) 116 | minimum = models.PositiveIntegerField(default=ZONE_DEFAULTS['minimum'], help_text="nxdomain ttl, bind9+") 117 | ttl = models.PositiveIntegerField(default=ZONE_DEFAULTS['ttl'], help_text='Default record TTL') 118 | 119 | class Meta: 120 | db_table = 'dns_zone' 121 | ordering = ['domain'] 122 | permissions = ( 123 | ("view_zones", "Can view zones"), 124 | ) 125 | 126 | def __unicode__(self): 127 | return "%s [%s]" % (self.domain, self.serial) 128 | 129 | def clear_cache(self): 130 | try: 131 | return cache.delete_pattern("%s_*" % self.domain_name) 132 | except AttributeError: 133 | return False 134 | 135 | def delete(self, *args, **kwargs): 136 | self.clear_cache() 137 | super(Zone, self).delete(*args, **kwargs) 138 | 139 | def save(self, *args, **kwargs): 140 | # increment serial on save 141 | serial_now = int(time.strftime('%Y%m%d00')) 142 | if self.serial < serial_now: 143 | self.serial = serial_now 144 | else: 145 | self.serial += 1 146 | self.clear_cache() 147 | super(Zone, self).save(*args, **kwargs) 148 | 149 | @property 150 | def description(self): 151 | return 'Hosted DNS Zone (%s)' % self.domain 152 | 153 | @property 154 | def owner_src(self): 155 | return self.domain.owner_src 156 | 157 | @property 158 | def rname(self): 159 | return self.soa_email.replace('@', '.') 160 | 161 | @property 162 | def domain_name(self): 163 | return str(self.domain) 164 | 165 | def get_absolute_url(self): 166 | return reverse('zone_detail', kwargs={'pk': self.pk, }) 167 | 168 | def get_zone(self): 169 | return dns.zone.from_text(str(self.render()), origin=str(self.domain), check_origin=True, relativize=True) 170 | 171 | def validate(self): 172 | try: 173 | # Can't run this on clean due to relation not being saved.. need a custom method. 174 | if self.nameserverrecords.count() < 2: 175 | raise ValidationError('You must assign at least two name servers.') 176 | if self.addressrecords.count() < 1: 177 | raise ValidationError('You must assign at least one address record.') 178 | # Validate that a / cname conflict does not occur 179 | for a_record in self.addressrecords.all(): 180 | if self.canonicalnamerecords.filter(data=a_record.data).exists(): 181 | raise ValidationError('Cannot have CNAME and A records with same hostname.') 182 | except ObjectDoesNotExist: 183 | # In case that related record fails to validate 184 | pass 185 | try: 186 | # Validate by loading with Python DNS 187 | self.get_zone() 188 | except Exception as e: 189 | raise ValidationError('Failed to parse zone file with: %s' % str(e)) 190 | 191 | def is_valid(self): 192 | key = '%s_validation' % (self.domain_name) 193 | data = cache.get(key, None) 194 | if data is None: 195 | try: 196 | self.validate() 197 | except ValidationError: 198 | data = False 199 | else: 200 | data = True 201 | cache.set(key, data, None) 202 | return data 203 | is_valid.boolean = True # Attribute for django admin (makes for pretty icons) 204 | 205 | def check_delegation(self): 206 | try: 207 | answers = dns.resolver.query(self.domain_name, 'NS') 208 | for rdata in answers: 209 | if str(rdata).lower() not in DNS_MANAGER_NAMESERVERS: 210 | raise ValidationError('Zone nameserver %s is not in DNS_MANAGER_NAMESERVERS' % str(rdata)) 211 | if len(answers) <= 1: 212 | raise ValidationError('Zone has insufficient nameservers count: %s' % len(answers)) 213 | except Exception as e: 214 | raise ValidationError('Exception during delegation check: %s' % str(e)) 215 | 216 | def is_delegated(self): 217 | key = '%s_delegation' % (self.domain_name) 218 | data = cache.get(key, None) 219 | if data is None: 220 | try: 221 | self.check_delegation() 222 | except ValidationError: 223 | data = False 224 | else: 225 | data = True 226 | cache.set(key, data, None) 227 | return data 228 | is_delegated.boolean = True # Attribute for django admin (makes for pretty icons) 229 | 230 | def render(self): 231 | """ 232 | :return: Render the zone to a Bind zone string 233 | """ 234 | return render_to_string('dnsmanager/zone_detail.txt', {'object': self}) 235 | 236 | def update_from_text(self, text, partial=False): 237 | text = str(text.replace('\r\n', '\n')) # DOS 2 Unix 238 | try: 239 | bind_zone = dns.zone.from_text(text=text, origin=self.domain.name, check_origin=False, relativize=True) 240 | except (AttributeError, dns.exception.SyntaxError) as e: 241 | return False, 'Zone Update Failed: %s' % str(e) 242 | 243 | for (name, ttl, rdata) in bind_zone.iterate_rdatas('SOA'): # should only be one 244 | self.expire = rdata.expire 245 | self.minimum = rdata.minimum 246 | self.refresh = rdata.refresh 247 | self.retry = rdata.retry 248 | self.soa_email = str(rdata.rname).lower() 249 | self.serial = rdata.serial 250 | self.save() 251 | 252 | if not partial: 253 | self.addressrecords.all().delete() 254 | self.nameserverrecords.all().delete() 255 | self.mailexchangerecords.all().delete() 256 | self.canonicalnamerecords.all().delete() 257 | self.textrecords.all().delete() 258 | 259 | for (name, ttl, rdata) in bind_zone.iterate_rdatas('A'): 260 | r, c = AddressRecord.objects.get_or_create(zone=self, data=str(name).lower(), ip=str(rdata)) 261 | r.ttl = int(ttl) 262 | r.save() 263 | 264 | for (name, ttl, rdata) in bind_zone.iterate_rdatas('NS'): 265 | r, c = NameServerRecord.objects.get_or_create(zone=self, data=str(rdata).lower()) 266 | r.ttl = int(ttl) 267 | r.save() 268 | 269 | for (name, ttl, rdata) in bind_zone.iterate_rdatas('MX'): 270 | r, c = MailExchangeRecord.objects.get_or_create(zone=self, 271 | data=str(rdata.exchange).lower(), 272 | priority=int(rdata.preference)) 273 | r.save() 274 | 275 | for (name, ttl, rdata) in bind_zone.iterate_rdatas('CNAME'): 276 | r, c = CanonicalNameRecord.objects.get_or_create(zone=self, data=str(name), target=str(rdata).lower()) 277 | r.ttl = int(ttl) 278 | r.save() 279 | 280 | for (name, ttl, rdata) in bind_zone.iterate_rdatas('TXT'): 281 | r, c = TextRecord.objects.get_or_create(zone=self, data=str(name), text=str(rdata)) 282 | r.ttl = int(ttl) 283 | r.save() 284 | 285 | for (name, ttl, rdata) in bind_zone.iterate_rdatas('SRV'): 286 | r, c = ServiceRecord.objects.get_or_create(zone=self, 287 | data=name.to_text(), 288 | target=rdata.target.to_text(), 289 | port=int(rdata.port), 290 | weight=int(rdata.weight), 291 | priority=rdata.priority) 292 | r.ttl = int(ttl) 293 | r.save() 294 | 295 | return True, 'Zone Update Successful' 296 | 297 | 298 | class BaseZoneRecord(DateMixin): 299 | 300 | zone = models.ForeignKey(Zone, related_name="%(class)ss") 301 | data = models.CharField(max_length=255, help_text="Data") 302 | 303 | ttl = models.PositiveIntegerField(blank=True, null=True) 304 | 305 | class Meta: 306 | # order_with_respect_to = 'zone' 307 | abstract = True 308 | 309 | def __unicode__(self): 310 | return "%s [%s]" % (self.zone, self.data) 311 | 312 | # Override TTL with default 313 | @property 314 | def ttlx(self): 315 | if self.ttl is not None: 316 | return self.ttl 317 | else: 318 | return self.zone.ttl 319 | 320 | @property 321 | def fq_data(self): 322 | if self.data.endswith('.'): 323 | return self.data 324 | else: 325 | return '%s.%s.' % (self.data, self.zone.domain) 326 | 327 | 328 | class AddressRecord(BaseZoneRecord): 329 | 330 | ip = models.GenericIPAddressField(help_text="IP Address") 331 | 332 | class Meta: 333 | db_table = 'dns_addressrecord' 334 | ordering = ['data'] 335 | unique_together = [('zone', 'data', 'ip')] 336 | 337 | def __unicode__(self): 338 | return "%s.%s -> %s" % (self.data, self.zone, self.ip) 339 | 340 | def clean(self): 341 | validate_hostname_string(self.data) 342 | 343 | 344 | class CanonicalNameRecord(BaseZoneRecord): 345 | 346 | target = models.CharField(max_length=128, help_text="Target") 347 | 348 | class Meta: 349 | db_table = 'dns_canonicalnamerecord' 350 | ordering = ['data'] 351 | unique_together = [('zone', 'data')] 352 | 353 | def __unicode__(self): 354 | return "%s.%s -> %s" % (self.data, self.zone, self.target) 355 | 356 | @property 357 | def fq_target(self): 358 | if self.target.endswith('.'): 359 | return self.target 360 | else: 361 | return '%s.%s.' % (self.target, self.zone.domain) 362 | 363 | def clean(self): 364 | validate_hostname_string(self.data) 365 | validate_hostname_string(self.target) 366 | validate_hostname_digs(self.fq_target) 367 | 368 | 369 | class MailExchangeRecord(BaseZoneRecord): 370 | 371 | priority = IntegerRangeField(min_value=0, max_value=65535, help_text="Priority") 372 | origin = models.CharField(max_length=255, help_text="MX Origin", default='@') 373 | 374 | class Meta: 375 | db_table = 'dns_mailexchangerecord' 376 | unique_together = [('zone', 'data', 'origin')] 377 | ordering = ['priority', 'data'] 378 | 379 | def __unicode__(self): 380 | return "%s [%s: %s]" % (self.zone, self.priority, self.data) 381 | 382 | def clean(self): 383 | validate_hostname_string(self.data) 384 | validate_hostname_exists(self.fq_data) 385 | 386 | 387 | class NameServerRecord(BaseZoneRecord): 388 | 389 | origin = models.CharField(max_length=255, help_text="NS Origin", default='@') 390 | 391 | class Meta: 392 | db_table = 'dns_nameserverrecord' 393 | ordering = ['data'] 394 | unique_together = [('zone', 'data', 'origin')] 395 | 396 | def __unicode__(self): 397 | return "%s %s" % (self.zone, self.data) 398 | 399 | def clean(self): 400 | validate_hostname_exists(self.fq_data) 401 | 402 | 403 | class TextRecord(BaseZoneRecord): 404 | 405 | text = models.CharField(max_length=255, help_text="Text") 406 | 407 | class Meta: 408 | db_table = 'dns_textrecord' 409 | ordering = ['data', 'text'] 410 | 411 | def __unicode__(self): 412 | return "%s [%s]" % (self.zone, self.text) 413 | 414 | def clean(self): 415 | if not (self.text.startswith('"') and self.text.endswith('"')): 416 | raise ValidationError('Record must begin and end with double quotes.') 417 | if not self.text.count('"') == 2: 418 | raise ValidationError('Record must not contain more than 2 quotes.') 419 | 420 | 421 | class ServiceRecord(BaseZoneRecord): 422 | 423 | priority = IntegerRangeField(min_value=0, max_value=65535, help_text="Priority") 424 | weight = IntegerRangeField(min_value=0, max_value=65535, help_text="Weight") 425 | port = IntegerRangeField(min_value=1, max_value=65535, help_text="TCP / UDP Port") 426 | target = models.CharField(max_length=128, help_text="Target") 427 | 428 | class Meta: 429 | db_table = 'dns_servicerecord' 430 | ordering = ['data', 'priority', 'target'] 431 | unique_together = [('zone', 'data', 'target')] 432 | 433 | def __unicode__(self): 434 | return "%s.%s -srv-> %s" % (self.data, self.zone, self.target) 435 | 436 | def clean(self): 437 | validate_service_record_data(self.data) 438 | --------------------------------------------------------------------------------