Browse Source

20170418 rrsets endpoint (#50)

feat(api): REST APi to read and write RRsets

Endpoints:

 1. `domains/<name>/rrsets/`: return all RRsets, or create an RRset.
    Accepts GET (optionally with `type` and `subname` query parameters),
    POST.
 2. `domains/<name>/rrsets/<subname>?.../<type>/`: return RRset for
    given subname and type. Accepts GET, PATCH, PUT.

<name>, <subname>, and <type> cannot be changed.  A pdns update is only
performed if at least one of those is changed.  RRsets of type SOA,
RRSIG, DNSKEY, NSEC3PARAM are managed automatically and cannot be
tinkered with.

This commit also introduces the management command sync-from-pdns which
is used to import RRsets from the pdns database into the database
of our Django API app ("local database").   A list of domain names can
be provided as arguments.  If no names are provided, all domains that
are locally known are imported.  This way, it is possible to place
additional domain data in the pdns database, and if a corresponding
domain is not found locally, we do not try to import any associated
RRsets from pdns.  Any previously present local RRsets pertaining to the
imported domains are deleted.  In other words, this import is really a
one-way sync operation.

We provide tests for all new functionality.

For more details, please check the docs supplied within this commit.
Peter Thomassen 8 years ago
parent
commit
f13b45673e

+ 9 - 0
api/desecapi/exceptions.py

@@ -0,0 +1,9 @@
+from rest_framework.exceptions import APIException
+import json
+
+
+class PdnsException(APIException):
+
+    def __init__(self, response):
+        self.status_code = response.status_code
+        self.detail = json.loads(response.text)['error']

+ 31 - 0
api/desecapi/management/commands/sync-from-pdns.py

@@ -0,0 +1,31 @@
+from django.core.management import BaseCommand, CommandError
+from desecapi.models import Domain, RRset
+
+
+class Command(BaseCommand):
+    help = 'Import authoritative data from pdns, making the local database consistent with pdns.'
+
+    def add_arguments(self, parser):
+        parser.add_argument('domain-name', nargs='*', help='Domain name to import. If omitted, will import all domains that are known locally.')
+
+    def handle(self, *args, **options):
+        domains = Domain.objects.all()
+
+        if options['domain-name']:
+            domains = domains.filter(name__in=options['domain-name'])
+            domain_names = domains.values_list('name', flat=True)
+
+            for domain_name in options['domain-name']:
+                if domain_name not in domain_names:
+                    raise CommandError('{} is not a known domain'.format(domain_name))
+
+        for domain in domains:
+            try:
+                domain.sync_from_pdns()
+
+            except Exception as e:
+                msg = 'Error while processing {}: {}'.format(domain.name, e)
+                raise CommandError(msg)
+
+            else:
+                print(domain.name)

+ 47 - 0
api/desecapi/migrations/0015_rrset.py

@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.3 on 2017-05-08 13:20
+from __future__ import unicode_literals
+
+import desecapi.models
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0014_ip_validation'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='RRset',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('updated', models.DateTimeField(null=True)),
+                ('subname', models.CharField(blank=True, max_length=178)),
+                ('type', models.CharField(max_length=10, validators=[desecapi.models.validate_upper])),
+                # max_length is due to row length limit and lifted later when we switch the charset from utf8 to latin1.
+                ('records', models.CharField(blank=True, max_length=16000)),
+                ('ttl', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1)])),
+                ('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rrsets', to='desecapi.Domain')),
+            ],
+        ),
+        migrations.AlterUniqueTogether(
+            name='rrset',
+            unique_together=set([('domain', 'subname', 'type')]),
+        ),
+        # Here we extend the row length as we change the charset accordingly.  We also tell Django what the new field state is.
+        migrations.RunSQL(
+            "ALTER TABLE desecapi_rrset MODIFY records VARCHAR(64000) CHARACTER SET latin1 NOT NULL;",
+            state_operations=[
+                migrations.AlterField(
+                    model_name='rrset',
+                    name='records',
+                    field=models.CharField(blank=True, max_length=64000),
+                ),
+            ],
+        ),
+    ]

+ 7 - 0
api/desecapi/mixins.py

@@ -0,0 +1,7 @@
+class SetterMixin:
+    def __setattr__(self, attrname, val):
+        setter_func = 'setter_' + attrname
+        if attrname in self.__dict__ and callable(getattr(self, setter_func, None)):
+            super().__setattr__(attrname, getattr(self, setter_func)(val))
+        else:
+            super().__setattr__(attrname, val)

+ 134 - 24
api/desecapi/models.py

@@ -1,14 +1,11 @@
 from django.conf import settings
 from django.db import models, transaction
-from django.contrib.auth.models import (
-    BaseUserManager, AbstractBaseUser
-)
+from django.contrib.auth.models import BaseUserManager, AbstractBaseUser
 from django.utils import timezone
-from django.core.exceptions import ValidationError
-from desecapi import pdns
-import datetime, time
-import django.core.exceptions
-import rest_framework.exceptions
+from django.core.exceptions import SuspiciousOperation, ValidationError
+from desecapi import pdns, mixins
+import datetime
+from django.core.validators import MinValueValidator
 
 
 class MyUserManager(BaseUserManager):
@@ -94,7 +91,7 @@ class User(AbstractBaseUser):
         self.save()
 
 
-class Domain(models.Model):
+class Domain(models.Model, mixins.SetterMixin):
     created = models.DateTimeField(auto_now_add=True)
     updated = models.DateTimeField(null=True)
     name = models.CharField(max_length=191, unique=True)
@@ -105,13 +102,6 @@ class Domain(models.Model):
     _dirtyName = False
     _dirtyRecords = False
 
-    def __setattr__(self, attrname, val):
-        setter_func = 'setter_' + attrname
-        if attrname in self.__dict__ and callable(getattr(self, setter_func, None)):
-            super(Domain, self).__setattr__(attrname, getattr(self, setter_func)(val))
-        else:
-            super(Domain, self).__setattr__(attrname, val)
-
     def setter_name(self, val):
         if val != self.name:
             self._dirtyName = True
@@ -140,6 +130,22 @@ class Domain(models.Model):
         if self._dirtyName:
             raise ValidationError('You must not change the domain name')
 
+    @property
+    def pdns_id(self):
+        if '/' in self.name or '?' in self.name:
+            raise SuspiciousOperation('Invalid hostname ' + self.name)
+
+        # Transform to be valid pdns API identifiers (:id in their docs).  The
+        # '/' case here is just a safety measure (this case should never occur due
+        # to the above check).
+        # See also pdns code, apiZoneNameToId() in ws-api.cc
+        name = self.name.translate(str.maketrans({'/': '=2F', '_': '=5F'}))
+
+        if not name.endswith('.'):
+            name += '.'
+
+        return name
+
     def pdns_resync(self):
         """
         Make sure that pdns gets the latest information about this domain/zone.
@@ -147,11 +153,11 @@ class Domain(models.Model):
         """
 
         # Create zone if needed
-        if not pdns.zone_exists(self.name):
-            pdns.create_zone(self.name)
+        if not pdns.zone_exists(self):
+            pdns.create_zone(self)
 
         # update zone to latest information
-        pdns.set_dyn_records(self.name, self.arecord, self.aaaarecord, self.acme_challenge)
+        pdns.set_dyn_records(self)
 
     def pdns_sync(self, new_domain):
         """
@@ -164,23 +170,30 @@ class Domain(models.Model):
 
         # if this zone is new, create it and set dirty flag if necessary
         if new_domain:
-            pdns.create_zone(self.name)
+            pdns.create_zone(self)
             self._dirtyRecords = bool(self.arecord) or bool(self.aaaarecord) or bool(self.acme_challenge)
 
         # make changes if necessary
         if self._dirtyRecords:
-            pdns.set_dyn_records(self.name, self.arecord, self.aaaarecord, self.acme_challenge)
+            pdns.set_dyn_records(self)
 
         self._dirtyRecords = False
 
+    def sync_from_pdns(self):
+        with transaction.atomic():
+            RRset.objects.filter(domain=self).delete()
+            rrsets = pdns.get_rrsets(self)
+            rrsets = [rrset for rrset in rrsets if rrset.type != 'SOA']
+            RRset.objects.bulk_create(rrsets)
+
     @transaction.atomic
     def delete(self, *args, **kwargs):
         super(Domain, self).delete(*args, **kwargs)
 
-        pdns.delete_zone(self.name)
+        pdns.delete_zone(self)
         if self.name.endswith('.dedyn.io'):
-            pdns.set_rrset('dedyn.io', self.name, 'DS', '')
-            pdns.set_rrset('dedyn.io', self.name, 'NS', '')
+            pdns.set_rrset_in_parent(self, 'DS', '')
+            pdns.set_rrset_in_parent(self, 'NS', '')
 
     @transaction.atomic
     def save(self, *args, **kwargs):
@@ -200,9 +213,11 @@ class Domain(models.Model):
 def get_default_value_created():
     return timezone.now()
 
+
 def get_default_value_due():
     return timezone.now() + datetime.timedelta(days=7)
 
+
 def get_default_value_mref():
     return "ONDON" + str((timezone.now() - timezone.datetime(1970,1,1,tzinfo=timezone.utc)).total_seconds())
 
@@ -227,3 +242,98 @@ class Donation(models.Model):
 
     class Meta:
         ordering = ('created',)
+
+
+def validate_upper(value):
+    if value != value.upper():
+        raise ValidationError('Invalid value (not uppercase): %(value)s',
+                              code='invalid',
+                              params={'value': value})
+
+
+class RRset(models.Model, mixins.SetterMixin):
+    created = models.DateTimeField(auto_now_add=True)
+    updated = models.DateTimeField(null=True)
+    domain = models.ForeignKey(Domain, on_delete=models.CASCADE, related_name='rrsets')
+    subname = models.CharField(max_length=178, blank=True)
+    type = models.CharField(max_length=10, validators=[validate_upper])
+    records = models.CharField(max_length=64000, blank=True)
+    ttl = models.PositiveIntegerField(validators=[MinValueValidator(1)])
+    _dirty = False
+
+
+    class Meta:
+        unique_together = (("domain","subname","type"),)
+
+    def __init__(self, *args, **kwargs):
+        self._dirties = set()
+        super().__init__(*args, **kwargs)
+
+    def setter_domain(self, val):
+        if val != self.domain:
+            self._dirties.add('domain')
+
+        return val
+
+    def setter_subname(self, val):
+        # On PUT, RRsetSerializer sends None, denoting the unchanged value
+        if val is None:
+            return self.subname
+
+        if val != self.subname:
+            self._dirties.add('subname')
+
+        return val
+
+    def setter_type(self, val):
+        if val != self.type:
+            self._dirties.add('type')
+
+        return val
+
+    def setter_records(self, val):
+        if val != self.records:
+            self._dirty = True
+
+        return val
+
+    def setter_ttl(self, val):
+        if val != self.ttl:
+            self._dirty = True
+
+        return val
+
+    def clean(self):
+        errors = {}
+        for field in self._dirties:
+            errors[field] = ValidationError(
+                'You cannot change the `%s` field.' % field)
+
+        if errors:
+            raise ValidationError(errors)
+
+    @property
+    def name(self):
+        return '.'.join(filter(None, [self.subname, self.domain.name])) + '.'
+
+    def update_pdns(self):
+        pdns.set_rrset(self)
+        pdns.notify_zone(self.domain)
+
+    @transaction.atomic
+    def delete(self, *args, **kwargs):
+        # Reset records so that our pdns update later will cause deletion
+        self.records = '[]'
+        super().delete(*args, **kwargs)
+
+        self.update_pdns()
+
+    @transaction.atomic
+    def save(self, *args, **kwargs):
+        new = self.pk is None
+        self.updated = timezone.now()
+        self.full_clean()
+        super().save(*args, **kwargs)
+
+        if self._dirty or new:
+            self.update_pdns()

+ 88 - 36
api/desecapi/pdns.py

@@ -1,9 +1,11 @@
 import requests
 import json
 from desecapi import settings
+from desecapi.exceptions import PdnsException
 
 
 headers_nslord = {
+    'Accept': 'application/json',
     'User-Agent': 'desecapi',
     'X-API-Key': settings.NSLORD_PDNS_API_TOKEN,
 }
@@ -14,11 +16,6 @@ headers_nsmaster = {
 }
 
 
-def normalize_hostname(name):
-    if '/' in name or '?' in name:
-        raise Exception('Invalid hostname ' + name)
-    return name if name.endswith('.') else name + '.'
-
 def _pdns_delete(url):
     # We first delete the zone from nslord, the main authoritative source of our DNS data.
     # However, we do not want to wait for the zone to expire on the slave ("nsmaster").
@@ -29,7 +26,7 @@ def _pdns_delete(url):
         if r1.status_code == 422 and 'Could not find domain' in r1.text:
             pass
         else:
-            raise Exception(r1.text)
+            raise PdnsException(r1)
 
     # Delete from nsmaster as well
     r2 = requests.delete(settings.NSMASTER_PDNS_API + url, headers=headers_nsmaster)
@@ -38,34 +35,36 @@ def _pdns_delete(url):
         if r2.status_code == 422 and 'Could not find domain' in r2.text:
             pass
         else:
-            raise Exception(r2.text)
+            raise PdnsException(r2)
 
     return (r1, r2)
 
+
 def _pdns_post(url, body):
     r = requests.post(settings.NSLORD_PDNS_API + url, data=json.dumps(body), headers=headers_nslord)
     if r.status_code < 200 or r.status_code >= 300:
-        raise Exception(r.text)
+        raise PdnsException(r)
     return r
 
+
 def _pdns_patch(url, body):
     r = requests.patch(settings.NSLORD_PDNS_API + url, data=json.dumps(body), headers=headers_nslord)
     if r.status_code < 200 or r.status_code >= 300:
-        raise Exception(r.text)
+        raise PdnsException(r)
     return r
 
 
 def _pdns_get(url):
     r = requests.get(settings.NSLORD_PDNS_API + url, headers=headers_nslord)
-    if r.status_code < 200 or r.status_code >= 500:
-        raise Exception(r.text)
+    if r.status_code < 200 or r.status_code >= 400:
+        raise PdnsException(r)
     return r
 
 
 def _pdns_put(url):
     r = requests.put(settings.NSLORD_PDNS_API + url, headers=headers_nslord)
     if r.status_code < 200 or r.status_code >= 500:
-        raise Exception(r.text)
+        raise PdnsException(r)
     return r
 
 
@@ -98,12 +97,16 @@ def _delete_or_replace_rrset(name, rr_type, value, ttl=60):
             }
 
 
-def create_zone(name, kind='NATIVE'):
+def create_zone(domain, kind='NATIVE'):
     """
     Commands pdns to create a zone with the given name.
     """
+    name = domain.name
+    if not name.endswith('.'):
+        name += '.'
+
     payload = {
-        "name": normalize_hostname(name),
+        "name": name,
         "kind": kind.upper(),
         "masters": [],
         "nameservers": [
@@ -113,63 +116,112 @@ def create_zone(name, kind='NATIVE'):
     }
     _pdns_post('/zones', payload)
 
+    # Don't forget to import automatically generated RRsets (specifically, NS)
+    domain.sync_from_pdns()
 
-def delete_zone(name):
+def delete_zone(domain):
     """
     Commands pdns to delete a zone with the given name.
     """
-    _pdns_delete('/zones/' + normalize_hostname(name))
+    _pdns_delete('/zones/' + domain.pdns_id)
+
+
+def get_zone(domain):
+    """
+    Retrieves a JSON representation of the zone from pdns
+    """
+    r = _pdns_get('/zones/' + domain.pdns_id)
+
+    return r.json()
+
+
+def get_rrsets(domain):
+    """
+    Retrieves a JSON representation of the RRsets in a given zone, optionally restricting to a name and RRset type 
+    """
+    from desecapi.models import RRset
+    from desecapi.serializers import GenericRRsetSerializer
+
+    rrsets = []
+    for rrset in get_zone(domain)['rrsets']:
+        data = {'domain': domain.pk,
+                'subname': rrset['name'][:-(len(domain.name) + 2)],
+                'type': rrset['type'],
+                'records': [record['content'] for record in rrset['records']],
+                'ttl': rrset['ttl']}
+
+        serializer = GenericRRsetSerializer(data=data)
+        serializer.is_valid(raise_exception=True)
+        rrsets.append(RRset(**serializer.validated_data))
+
+    return rrsets
+
 
+def set_rrset(rrset):
+    return set_rrsets(rrset.domain, [rrset])
 
-def zone_exists(name):
+
+def set_rrsets(domain, rrsets):
+    from desecapi.serializers import GenericRRsetSerializer
+    rrsets = [GenericRRsetSerializer(rrset).data for rrset in rrsets]
+
+    data = {'rrsets':
+        [{'name': rrset['name'], 'type': rrset['type'], 'ttl': rrset['ttl'],
+          'changetype': 'REPLACE',
+          'records': [{'content': record, 'disabled': False}
+                      for record in rrset['records']]
+          }
+         for rrset in rrsets]
+    }
+    _pdns_patch('/zones/' + domain.pdns_id, data)
+
+
+def zone_exists(domain):
     """
     Returns whether pdns knows a zone with the given name.
     """
-    reply = _pdns_get('/zones/' + normalize_hostname(name))
-    if reply.status_code == 200:
+    r = _pdns_get('/zones/' + domain.pdns_id)
+    if r.status_code == 200:
         return True
-    elif reply.status_code == 422 and 'Could not find domain' in reply.text:
+    elif r.status_code == 422 and 'Could not find domain' in r.text:
         return False
     else:
-        raise Exception(reply.text)
+        raise PdnsException(r)
 
 
-def notify_zone(name):
+def notify_zone(domain):
     """
     Commands pdns to notify the zone to the pdns slaves.
     """
-    _pdns_put('/zones/%s/notify' % normalize_hostname(name))
+    _pdns_put('/zones/%s/notify' % domain.pdns_id)
 
 
-def set_dyn_records(name, a, aaaa, acme_challenge=''):
+def set_dyn_records(domain):
     """
     Commands pdns to set the A and AAAA record for the zone with the given name to the given record values.
     Only supports one A, one AAAA record.
     If a or aaaa is empty, pdns will be commanded to delete the record.
     """
-    name = normalize_hostname(name)
-
-    _pdns_patch('/zones/' + name, {
+    _pdns_patch('/zones/' + domain.pdns_id, {
         "rrsets": [
-            _delete_or_replace_rrset(name, 'a', a),
-            _delete_or_replace_rrset(name, 'aaaa', aaaa),
-            _delete_or_replace_rrset('_acme-challenge.%s' % name, 'txt', '"%s"' % acme_challenge),
+            _delete_or_replace_rrset(domain.name + '.', 'a', domain.arecord),
+            _delete_or_replace_rrset(domain.name + '.', 'aaaa', domain.aaaarecord),
+            _delete_or_replace_rrset('_acme-challenge.%s.' % domain.name, 'txt', '"%s"' % domain.acme_challenge),
         ]
     })
 
-    notify_zone(name)
+    notify_zone(domain)
 
 
-def set_rrset(zone, name, rr_type, value):
+def set_rrset_in_parent(domain, rr_type, value):
     """
     Commands pdns to set or delete a record set for the zone with the given name.
     If value is empty, the rrset will be deleted.
     """
-    zone = normalize_hostname(zone)
-    name = normalize_hostname(name)
+    parent_id = domain.pdns_id.split('.', 1)[1]
 
-    _pdns_patch('/zones/' + zone, {
+    _pdns_patch('/zones/' + parent_id, {
         "rrsets": [
-            _delete_or_replace_rrset(name, rr_type, value),
+            _delete_or_replace_rrset(domain.name + '.', rr_type, value),
         ]
     })

+ 9 - 0
api/desecapi/permissions.py

@@ -9,3 +9,12 @@ class IsOwner(permissions.BasePermission):
     def has_object_permission(self, request, view, obj):
         return obj.owner == request.user
 
+
+class IsDomainOwner(permissions.BasePermission):
+    """
+    Custom permission to only allow owners of a domain to view or edit an object owned by that domain.
+    """
+
+    def has_object_permission(self, request, view, obj):
+        return obj.domain.owner == request.user
+

+ 41 - 2
api/desecapi/serializers.py

@@ -1,11 +1,50 @@
 from rest_framework import serializers
-from desecapi.models import Domain, Donation, User
+from desecapi.models import Domain, Donation, User, RRset
 from djoser import serializers as djoserSerializers
+import json
+
+
+class JSONSerializer(serializers.Field):
+    def to_representation(self, obj):
+        return json.loads(obj)
+
+    def to_internal_value(self, data):
+        return json.dumps(data)
+
+
+class RecordsSerializer(JSONSerializer):
+    def to_internal_value(self, records):
+        if isinstance(records, str) or not all(isinstance(record, str) for record in records):
+            msg = 'Incorrect type. Expected a list of strings'
+            raise serializers.ValidationError(msg)
+
+        # https://lists.isc.org/pipermail/bind-users/2008-April/070148.html
+        if not len(records) < 4092:
+            msg = 'Records too long. Must be less than 4092 characters, but was %d'
+            raise serializers.ValidationError(msg % len(records))
+
+        return super().to_internal_value(records)
+
+
+class GenericRRsetSerializer(serializers.ModelSerializer):
+    subname = serializers.CharField(allow_blank=True, required=False)
+    type = serializers.CharField(required=False)
+    records = RecordsSerializer()
+
+
+    class Meta:
+        model = RRset
+        fields = ('domain', 'subname', 'name', 'records', 'ttl', 'type',)
+
+
+class RRsetSerializer(GenericRRsetSerializer):
+    # The value of this field is set in RRsetList.perform_create()
+    domain = serializers.SlugRelatedField(read_only=True, slug_field='name')
 
 
 class DomainSerializer(serializers.ModelSerializer):
     owner = serializers.ReadOnlyField(source='owner.email')
-    name = serializers.RegexField(regex=r'^[A-Za-z0-9\.\-]+$',trim_whitespace=False)
+    name = serializers.RegexField(regex=r'^[A-Za-z0-9_.-]+$', trim_whitespace=False)
 
     class Meta:
         model = Domain

+ 4 - 4
api/desecapi/settings.py

@@ -72,13 +72,13 @@ DATABASES = {
         'USER': 'desec',
         'PASSWORD': os.environ['DESECSTACK_DBAPI_PASSWORD_desec'],
         'HOST': 'dbapi',
-        'CHARSET': 'utf8mb4',
+        'OPTIONS': {
+            'charset': 'utf8mb4',
+            'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
+        },
         'TEST': {
             'CHARSET': 'utf8mb4',
         },
-        'OPTIONS': {
-            'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
-        }
     },
 
 }

+ 36 - 11
api/desecapi/tests/testdomains.py

@@ -34,8 +34,6 @@ class UnauthenticatedDomainTests(APITestCase):
 
 class AuthenticatedDomainTests(APITestCase):
     def setUp(self):
-        httpretty.reset()
-        httpretty.disable()
         if not hasattr(self, 'owner'):
             self.owner = utils.createUser()
             self.ownedDomains = [utils.createDomain(self.owner), utils.createDomain(self.owner)]
@@ -43,6 +41,10 @@ class AuthenticatedDomainTests(APITestCase):
             self.token = utils.createToken(user=self.owner)
             self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
 
+    def tearDown(self):
+        httpretty.reset()
+        httpretty.disable()
+
     def testExpectOnlyOwnedDomains(self):
         url = reverse('domain-list')
         response = self.client.get(url, format='json')
@@ -185,16 +187,23 @@ class AuthenticatedDomainTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data['acme_challenge'], 'test_challenge')
 
-    def testPostingCausesPdnsAPICall(self):
+    def testPostingCausesPdnsAPICalls(self):
+        name = utils.generateDomainname()
+
         httpretty.enable()
         httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones')
+        httpretty.register_uri(httpretty.GET,
+                               settings.NSLORD_PDNS_API + '/zones/' + name + '.',
+                               body='{"rrsets": []}',
+                               content_type="application/json")
 
         url = reverse('domain-list')
-        data = {'name': utils.generateDomainname()}
-        response = self.client.post(url, data)
+        response = self.client.post(url, {'name': name})
 
-        self.assertTrue(data['name'] in httpretty.last_request().parsed_body)
-        self.assertTrue('ns1.desec.io' in httpretty.last_request().parsed_body)
+        self.assertEqual(httpretty.httpretty.latest_requests[-2].method, 'POST')
+        self.assertTrue(name in httpretty.httpretty.latest_requests[-2].parsed_body)
+        self.assertTrue('ns1.desec.io' in httpretty.httpretty.latest_requests[-2].parsed_body)
+        self.assertEqual(httpretty.last_request().method, 'GET')
 
     def testPostingWithRecordsCausesPdnsAPIPatch(self):
         name = utils.generateDomainname()
@@ -202,6 +211,10 @@ class AuthenticatedDomainTests(APITestCase):
         httpretty.enable()
         httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones')
         httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + name + '.')
+        httpretty.register_uri(httpretty.GET,
+                               settings.NSLORD_PDNS_API + '/zones/' + name + '.',
+                               body='{"rrsets": []}',
+                               content_type="application/json")
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify')
 
         url = reverse('domain-list')
@@ -219,6 +232,10 @@ class AuthenticatedDomainTests(APITestCase):
 
         httpretty.enable()
         httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones')
+        httpretty.register_uri(httpretty.GET,
+                               settings.NSLORD_PDNS_API + '/zones/' + name + '.',
+                               body='{"rrsets": []}',
+                               content_type="application/json")
         httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + name + '.')
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify')
 
@@ -283,8 +300,6 @@ class AuthenticatedDomainTests(APITestCase):
 
 class AuthenticatedDynDomainTests(APITestCase):
     def setUp(self):
-        httpretty.reset()
-        httpretty.disable()
         if not hasattr(self, 'owner'):
             self.owner = utils.createUser(dyn=True)
             self.ownedDomains = [utils.createDomain(self.owner, dyn=True), utils.createDomain(self.owner, dyn=True)]
@@ -292,6 +307,10 @@ class AuthenticatedDynDomainTests(APITestCase):
             self.token = utils.createToken(user=self.owner)
             self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
 
+    def tearDown(self):
+        httpretty.reset()
+        httpretty.disable()
+
     def testCanDeleteOwnedDynDomain(self):
         httpretty.enable()
         httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
@@ -356,8 +375,14 @@ class AuthenticatedDynDomainTests(APITestCase):
 
         url = reverse('domain-list')
         for i in range(settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT-2):
-            data = {'name': utils.generateDynDomainname()}
-            response = self.client.post(url, data)
+            name = utils.generateDynDomainname()
+
+            httpretty.register_uri(httpretty.GET,
+                                   settings.NSLORD_PDNS_API + '/zones/' + name + '.',
+                                   body='{"rrsets": []}',
+                                   content_type="application/json")
+
+            response = self.client.post(url, {'name': name})
             self.assertEqual(response.status_code, status.HTTP_201_CREATED)
             self.assertEqual(len(mail.outbox), outboxlen+i+1)
 

+ 19 - 4
api/desecapi/tests/testdyndns12update.py

@@ -33,9 +33,14 @@ class DynDNS12UpdateTest(APITestCase):
         httpretty.HTTPretty.allow_net_connect = False
         httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones')
         httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.')
+        httpretty.register_uri(httpretty.GET,
+                               settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.',
+                               body='{"rrsets": []}',
+                               content_type="application/json")
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.domain + './notify')
 
     def tearDown(self):
+        httpretty.reset()
         httpretty.disable()
 
     def assertIP(self, ipv4=None, ipv6=None):
@@ -118,10 +123,15 @@ class DynDNS12UpdateTest(APITestCase):
         # To force identification by the provided username (which is the domain name)
         # we add a second domain for the current user.
 
+        name = 'second-' + self.domain
+        httpretty.register_uri(httpretty.GET,
+                               settings.NSLORD_PDNS_API + '/zones/' + name + '.',
+                               body='{"rrsets": []}',
+                               content_type="application/json")
+
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         url = reverse('domain-list')
-        data = {'name': 'second-' + self.domain}
-        response = self.client.post(url, data)
+        response = self.client.post(url, {'name': name})
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
         self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((self.username + ':' + self.password).encode()).decode())
@@ -147,10 +157,15 @@ class DynDNS12UpdateTest(APITestCase):
         # Now make sure we get a conflict when the user has multiple domains. Thus,
         # we add a second domain for the current user.
 
+        name = 'second-' + self.domain
+        httpretty.register_uri(httpretty.GET,
+                               settings.NSLORD_PDNS_API + '/zones/' + name + '.',
+                               body='{"rrsets": []}',
+                               content_type="application/json")
+
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         url = reverse('domain-list')
-        data = {'name': 'second-' + self.domain}
-        response = self.client.post(url, data)
+        response = self.client.post(url, {'name': name})
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
         url = reverse('dyndns12update')

+ 4 - 0
api/desecapi/tests/testdynupdateauthentication.py

@@ -34,6 +34,10 @@ class DynUpdateAuthenticationTests(APITestCase):
             httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.')
             httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.domain + './notify')
 
+    def tearDown(self):
+        httpretty.reset()
+        httpretty.disable()
+
     def testSuccessfulAuthentication(self):
         response = self.client.get(self.url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)

+ 417 - 0
api/desecapi/tests/testrrsets.py

@@ -0,0 +1,417 @@
+from django.core.urlresolvers import reverse
+from rest_framework import status
+from rest_framework.test import APITestCase
+from .utils import utils
+import httpretty
+from django.conf import settings
+import json
+from django.core.management import call_command
+
+
+class UnauthenticatedDomainTests(APITestCase):
+    def testExpectUnauthorizedOnGet(self):
+        url = reverse('rrsets', args=('example.com',))
+        response = self.client.get(url, format='json')
+        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+    def testExpectUnauthorizedOnPost(self):
+        url = reverse('rrsets', args=('example.com',))
+        response = self.client.post(url, format='json')
+        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+    def testExpectUnauthorizedOnPut(self):
+        url = reverse('rrsets', args=('example.com',))
+        response = self.client.put(url, format='json')
+        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+    def testExpectUnauthorizedOnDelete(self):
+        url = reverse('rrsets', args=('example.com',))
+        response = self.client.delete(url, format='json')
+        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+
+class AuthenticatedRRsetTests(APITestCase):
+    restricted_types = ('SOA', 'RRSIG', 'DNSKEY', 'NSEC3PARAM')
+
+    def setUp(self):
+        httpretty.reset()
+        httpretty.disable()
+
+        if not hasattr(self, 'owner'):
+            self.owner = utils.createUser()
+            self.ownedDomains = [utils.createDomain(self.owner), utils.createDomain(self.owner)]
+            self.token = utils.createToken(user=self.owner)
+            self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+
+            self.otherOwner = utils.createUser()
+            self.otherDomains = [utils.createDomain(self.otherOwner), utils.createDomain()]
+            self.otherToken = utils.createToken(user=self.otherOwner)
+
+    def testCanGetOwnRRsets(self):
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(len(response.data), 1) # don't forget NS RRset
+
+    def testCantGetForeignRRsets(self):
+        url = reverse('rrsets', args=(self.otherDomains[1].name,))
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def testCanGetOwnRRsetsEmptySubname(self):
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        response = self.client.get(url + '?subname=')
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(len(response.data), 1) # don't forget NS RRset
+
+    def testCanGetOwnRRsetsFromSubname(self):
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+
+        data = {'records': ['1.2.3.4'], 'ttl': 120, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        data = {'records': ['2.2.3.4'], 'ttl': 120, 'type': 'A', 'subname': 'test'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        data = {'records': ['"test"'], 'ttl': 120, 'type': 'TXT', 'subname': 'test'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(len(response.data), 3 + 1) # don't forget NS RRset
+
+        response = self.client.get(url + '?subname=test')
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(len(response.data), 2)
+
+    def testCantGetForeignRRsetsFromSubname(self):
+        url = reverse('rrsets', args=(self.otherDomains[1].name,))
+        response = self.client.get(url + '?subname=test')
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def testCanGetOwnRRsetsFromType(self):
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+
+        data = {'records': ['1.2.3.4'], 'ttl': 120, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        data = {'records': ['2.2.3.4'], 'ttl': 120, 'type': 'A', 'subname': 'test'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        data = {'records': ['"test"'], 'ttl': 120, 'type': 'TXT', 'subname': 'test'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(len(response.data), 3 + 1) # don't forget NS RRset
+
+        response = self.client.get(url + '?type=A')
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(len(response.data), 2)
+
+    def testCantGetForeignRRsetsFromType(self):
+        url = reverse('rrsets', args=(self.otherDomains[1].name,))
+        response = self.client.get(url + '?test=A')
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def testCanPostOwnRRsets(self):
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(len(response.data), 1 + 1) # don't forget NS RRset
+
+        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['records'][0], '1.2.3.4')
+
+    def testCantPostRestrictedTypes(self):
+        for type_ in self.restricted_types:
+            url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+            data = {'records': ['ns1.desec.io. peter.desec.io. 2584 10800 3600 604800 60'], 'ttl': 60, 'type': type_}
+            response = self.client.post(url, json.dumps(data), content_type='application/json')
+            self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+    def testCantPostForeignRRsets(self):
+        url = reverse('rrsets', args=(self.otherDomains[1].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def testCanGetOwnRRset(self):
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['records'][0], '1.2.3.4')
+        self.assertEqual(response.data['ttl'], 60)
+
+    def testCantGetRestrictedTypes(self):
+        for type_ in self.restricted_types:
+            url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+            response = self.client.get(url + '?type=%s' % type_)
+            self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+            url = reverse('rrset', args=(self.ownedDomains[1].name, '', type_,))
+            response = self.client.get(url)
+            self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+    def testCantGetForeignRRset(self):
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
+        url = reverse('rrsets', args=(self.otherDomains[0].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+        url = reverse('rrset', args=(self.otherDomains[0].name, '', 'A',))
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def testCanGetOwnRRsetWithSubname(self):
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+
+        data = {'records': ['1.2.3.4'], 'ttl': 120, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        data = {'records': ['2.2.3.4'], 'ttl': 120, 'type': 'A', 'subname': 'test'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        data = {'records': ['"test"'], 'ttl': 120, 'type': 'TXT', 'subname': 'test'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(len(response.data), 3 + 1) # don't forget NS RRset
+
+        url = reverse('rrset', args=(self.ownedDomains[1].name, 'test', 'A',))
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['records'][0], '2.2.3.4')
+        self.assertEqual(response.data['ttl'], 120)
+        self.assertEqual(response.data['name'], 'test.' + self.ownedDomains[1].name + '.')
+
+    def testCanGetOwnRRsetWithWildcard(self):
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+
+        data = {'records': ['"barfoo"'], 'ttl': 120, 'type': 'TXT', 'subname': '*.foobar'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        response1 = self.client.get(url + '?subname=*.foobar')
+        self.assertEqual(response1.status_code, status.HTTP_200_OK)
+        self.assertEqual(response1.data[0]['records'][0], '"barfoo"')
+        self.assertEqual(response1.data[0]['ttl'], 120)
+        self.assertEqual(response1.data[0]['name'], '*.foobar.' + self.ownedDomains[1].name + '.')
+
+        url = reverse('rrset', args=(self.ownedDomains[1].name, '*.foobar', 'TXT',))
+        response2 = self.client.get(url)
+        self.assertEqual(response2.data, response1.data[0])
+
+    def testCanPutOwnRRset(self):
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        data = {'records': ['2.2.3.4'], 'ttl': 30, 'type': 'A'}
+        response = self.client.put(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['records'][0], '2.2.3.4')
+        self.assertEqual(response.data['ttl'], 30)
+
+    def testCanPatchOwnRRset(self):
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        data = {'records': ['3.2.3.4'], 'ttl': 32}
+        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['records'][0], '3.2.3.4')
+        self.assertEqual(response.data['ttl'], 32)
+
+    def testCantPatchOForeignRRset(self):
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
+        url = reverse('rrsets', args=(self.otherDomains[0].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+        url = reverse('rrset', args=(self.otherDomains[0].name, '', 'A',))
+        data = {'records': ['3.2.3.4'], 'ttl': 32}
+        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def testCantPutForeignRRset(self):
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
+        url = reverse('rrsets', args=(self.otherDomains[0].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+        url = reverse('rrset', args=(self.otherDomains[0].name, '', 'A',))
+        data = {'records': ['3.2.3.4'], 'ttl': 30, 'type': 'A'}
+        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def testCantChangeEssentialProperties(self):
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A', 'subname': 'test1'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        # Changing the type is expected to cause an error
+        url = reverse('rrset', args=(self.ownedDomains[1].name, 'test1', 'A',))
+        data = {'records': ['3.2.3.4'], 'ttl': 120, 'subname': 'test2'}
+        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
+
+        # Changing the subname is expected to cause an error
+        data = {'records': ['3.2.3.4'], 'ttl': 120, 'type': 'TXT'}
+        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
+
+        # Check that nothing changed
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['records'][0], '1.2.3.4')
+        self.assertEqual(response.data['ttl'], 60)
+        self.assertEqual(response.data['name'], 'test1.' + self.ownedDomains[1].name + '.')
+        self.assertEqual(response.data['subname'], 'test1')
+        self.assertEqual(response.data['type'], 'A')
+
+        # This is expected to work, but the fields are ignored
+        data = {'records': ['3.2.3.4'], 'name': 'example.com.', 'domain': 'example.com'}
+        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['records'][0], '3.2.3.4')
+        self.assertEqual(response.data['domain'], self.ownedDomains[1].name)
+        self.assertEqual(response.data['name'], 'test1.' + self.ownedDomains[1].name + '.')
+
+    def testCanDeleteOwnRRset(self):
+        # Try PATCH with empty records
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        data = {'records': []}
+        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+        # Try DELETE
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        response = self.client.delete(url)
+        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def testCantDeleteForeignRRset(self):
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
+        url = reverse('rrsets', args=(self.otherDomains[0].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+        url = reverse('rrset', args=(self.otherDomains[0].name, '', 'A',))
+
+        # Try PATCH with empty records
+        data = {'records': []}
+        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+        # Try DELETE
+        response = self.client.delete(url)
+        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+    def testPostCausesPdnsAPICall(self):
+        httpretty.enable()
+        httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
+        httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + './notify')
+
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+
+        result = json.loads(httpretty.httpretty.latest_requests[-2].parsed_body)
+        self.assertEqual(result['rrsets'][0]['name'], self.ownedDomains[1].name + '.')
+        self.assertEqual(result['rrsets'][0]['records'][0]['content'], '1.2.3.4')
+
+    def testDeleteCausesPdnsAPICall(self):
+        httpretty.enable()
+        httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
+        httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + './notify')
+
+        # Create record, should cause a pdns PATCH request and a notify
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+
+        # Delete record, should cause a pdns PATCH request and a notify
+        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        response = self.client.delete(url)
+        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+        # Check pdns requests from creation
+        result = json.loads(httpretty.httpretty.latest_requests[-4].parsed_body)
+        self.assertEqual(result['rrsets'][0]['name'], self.ownedDomains[1].name + '.')
+        self.assertEqual(result['rrsets'][0]['records'][0]['content'], '1.2.3.4')
+        self.assertEqual(httpretty.httpretty.latest_requests[-3].method, 'PUT')
+
+        # Check pdns requests from deletion
+        result = json.loads(httpretty.httpretty.latest_requests[-2].parsed_body)
+        self.assertEqual(result['rrsets'][0]['name'], self.ownedDomains[1].name + '.')
+        self.assertEqual(result['rrsets'][0]['records'], [])
+        self.assertEqual(httpretty.httpretty.latest_requests[-1].method, 'PUT')
+
+    def testImportRRsets(self):
+        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        # Not checking anything here; errors will raise an exception
+        call_command('sync-from-pdns', self.ownedDomains[1].name)

+ 3 - 1
api/desecapi/urls.py

@@ -9,7 +9,9 @@ apiurls = [
     url(r'^$', Root.as_view(), name='root'),
     url(r'^domains/$', DomainList.as_view(), name='domain-list'),
     url(r'^domains/(?P<pk>[0-9]+)/$', DomainDetail.as_view(), name='domain-detail'),
-    url(r'^domains/(?P<name>[a-zA-Z\.\-0-9]+)/$', DomainDetailByName.as_view(), name='domain-detail/byName'),
+    url(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/$', DomainDetailByName.as_view(), name='domain-detail/byName'),
+    url(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/rrsets/$', RRsetList.as_view(), name='rrsets'),
+    url(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/rrsets/(?P<subname>(\*\.)?[a-zA-Z\.\-_0-9=]*)\.\.\./(?P<type>[A-Z][A-Z0-9]*)/$', RRsetDetail.as_view(), name='rrset'),
     url(r'^dns$', DnsQuery.as_view(), name='dns-query'),
     url(r'^dyndns/update$', DynDNS12Update.as_view(), name='dyndns12update'),
     url(r'^donation/', DonationList.as_view(), name='donation'),

+ 115 - 13
api/desecapi/views.py

@@ -1,27 +1,31 @@
 from __future__ import unicode_literals
 from django.core.mail import EmailMessage
-from desecapi.models import Domain, User
-from desecapi.serializers import DomainSerializer, DonationSerializer
+from desecapi.models import Domain, User, RRset
+from desecapi.serializers import (
+    DomainSerializer, RRsetSerializer, DonationSerializer)
 from rest_framework import generics
-from desecapi.permissions import IsOwner
+from desecapi.permissions import IsOwner, IsDomainOwner
 from rest_framework import permissions
 from django.http import Http404
 from rest_framework.views import APIView
 from rest_framework.response import Response
 from rest_framework.reverse import reverse
-from rest_framework.authentication import TokenAuthentication, get_authorization_header
+from rest_framework.authentication import (
+    TokenAuthentication, get_authorization_header)
 from rest_framework.renderers import StaticHTMLRenderer
 from dns import resolver
 from django.template.loader import get_template
 from django.template import Context
-from desecapi.authentication import BasicTokenAuthentication, URLParamAuthentication
+from desecapi.authentication import (
+    BasicTokenAuthentication, URLParamAuthentication)
 import base64
 from desecapi import settings
-from rest_framework.exceptions import ValidationError
+from rest_framework.exceptions import (
+    APIException, MethodNotAllowed, PermissionDenied, ValidationError)
 import django.core.exceptions
 from djoser import views, signals
 from rest_framework import status
-from datetime import datetime, timedelta
+from datetime import timedelta
 from django.utils import timezone
 from desecapi.forms import UnlockForm
 from django.shortcuts import render
@@ -30,8 +34,8 @@ from desecapi.emails import send_account_lock_email
 import re
 
 # TODO Generalize?
-patternDyn = re.compile(r'^[A-Za-z][A-Za-z0-9-]*\.dedyn\.io$')
-patternNonDyn = re.compile(r'^([A-Za-z][A-Za-z0-9-]*\.)+[A-Za-z]+$')
+patternDyn = re.compile(r'^[A-Za-z-][A-Za-z0-9_-]*\.dedyn\.io$')
+patternNonDyn = re.compile(r'^([A-Za-z-][A-Za-z0-9_-]*\.)+[A-Za-z]+$')
 
 
 def get_client_ip(request):
@@ -47,7 +51,7 @@ class DomainList(generics.ListCreateAPIView):
 
     def perform_create(self, serializer):
         pattern = patternDyn if self.request.user.dyn else patternNonDyn
-        if pattern.match(serializer.validated_data['name']) is None or "--" in serializer.validated_data['name']:
+        if pattern.match(serializer.validated_data['name']) is None:
             ex = ValidationError(detail={"detail": "This domain name is not well-formed, by policy.", "code": "domain-illformed"})
             ex.status_code = status.HTTP_409_CONFLICT
             raise ex
@@ -66,7 +70,7 @@ class DomainList(generics.ListCreateAPIView):
         try:
             obj = serializer.save(owner=self.request.user)
         except Exception as e:
-            if str(e).endswith(' already exists"}'):
+            if str(e).endswith(' already exists'):
                 ex = ValidationError(detail={"detail": "This domain name is unavailable.", "code": "domain-unavailable"})
                 ex.status_code = status.HTTP_409_CONFLICT
                 raise ex
@@ -120,6 +124,103 @@ class DomainDetailByName(DomainDetail):
     lookup_field = 'name'
 
 
+class RRsetDetail(generics.RetrieveUpdateDestroyAPIView):
+    lookup_field = 'type'
+    serializer_class = RRsetSerializer
+    permission_classes = (permissions.IsAuthenticated, IsDomainOwner,)
+    restricted_types = ('SOA', 'RRSIG', 'DNSKEY', 'NSEC3PARAM')
+
+    def delete(self, request, *args, **kwargs):
+        try:
+            super().delete(request, *args, **kwargs)
+        except Http404:
+            pass
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
+    def get_queryset(self):
+        name = self.kwargs['name']
+        subname = self.kwargs['subname'].replace('=2F', '/')
+        type_ = self.kwargs['type']
+
+        if type_ in self.restricted_types:
+            raise PermissionDenied("You cannot tinker with the %s RRset." % type_)
+
+        return RRset.objects.filter(
+            domain__owner=self.request.user.pk,
+            domain__name=name, subname=subname, type=type_)
+
+    def update(self, request, *args, **kwargs):
+        if request.data.get('records') == []:
+            return self.delete(request, *args, **kwargs)
+
+        try:
+            return super().update(request, *args, **kwargs)
+        except django.core.exceptions.ValidationError as e:
+            ex = ValidationError(detail=e.message_dict)
+            ex.status_code = status.HTTP_409_CONFLICT
+            raise ex
+
+
+class RRsetList(generics.ListCreateAPIView):
+    serializer_class = RRsetSerializer
+    permission_classes = (permissions.IsAuthenticated, IsDomainOwner,)
+
+    def get_queryset(self):
+        rrsets = RRset.objects.filter(domain__owner=self.request.user.pk,
+                                      domain__name=self.kwargs['name'])
+
+        for filter_field in ('subname', 'type'):
+            value = self.request.query_params.get(filter_field)
+
+            if value is not None:
+                if filter_field == 'type' and value in RRsetDetail.restricted_types:
+                    raise PermissionDenied("You cannot tinker with the %s RRset." % value)
+
+                rrsets = rrsets.filter(**{'%s__exact' % filter_field: value})
+
+        return rrsets
+
+    def create(self, request, *args, **kwargs):
+        type_ = request.data.get('type', '')
+        if type_ in RRsetDetail.restricted_types:
+            raise PermissionDenied("You cannot tinker with the %s RRset." % type_)
+
+        try:
+            return super().create(request, *args, **kwargs)
+        except Domain.DoesNotExist:
+            raise Http404
+        except django.core.exceptions.ValidationError as e:
+            ex = ValidationError(detail=e.message_dict)
+            ex.status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
+            raise ex
+
+    def perform_create(self, serializer):
+        # Associate RRset with proper domain
+        domain = Domain.objects.get(name=self.kwargs['name'],
+                                    owner=self.request.user.pk)
+        kwargs = {'domain': domain}
+
+        # If this RRset is new and a subname has not been given, set it empty
+        #
+        # Notes:
+        # - We don't use default='' in the serializer so that during PUT, the
+        #   subname value is retained if omitted.)
+        # - Don't use kwargs['subname'] = self.request.data.get('subname', ''),
+        #   giving preference to what's in serializer.validated_data at this point
+        if self.request.method == 'POST' and self.request.data.get('subname') is None:
+            kwargs['subname'] = ''
+
+        serializer.save(**kwargs)
+
+    def get(self, request, *args, **kwargs):
+        name = self.kwargs['name']
+
+        if not Domain.objects.filter(name=name, owner=self.request.user.pk):
+            raise Http404
+
+        return super().get(request, *args, **kwargs)
+
+
 class Root(APIView):
     def get(self, request, format=None):
         if self.request.user and self.request.user.is_authenticated():
@@ -134,6 +235,7 @@ class Root(APIView):
                 'register': reverse('register', request=request, format=format),
             })
 
+
 class DnsQuery(APIView):
     def get(self, request, format=None):
         desecio = resolver.Resolver()
@@ -143,10 +245,10 @@ class DnsQuery(APIView):
 
         domain = str(request.GET['domain'])
 
-        def getRecords(domain, type):
+        def getRecords(domain, type_):
             records = []
             try:
-                for ip in desecio.query(domain, type):
+                for ip in desecio.query(domain, type_):
                     records.append(str(ip))
             except resolver.NoAnswer:
                 return []

+ 3 - 2
api/requirements.txt

@@ -5,8 +5,8 @@ argparse==1.2.1
 cffi==1.9.1
 coverage==3.7.1
 cryptography==1.5.3
-djangorestframework==3.5.3
-djoser==0.5.1
+djangorestframework==3.5.4
+djoser==0.5.2
 dnspython==1.15.0
 enum34==1.1.6
 httpretty==0.8.14
@@ -19,3 +19,4 @@ requests==2.7.0
 six==1.9.0
 uwsgi==2.0.14
 django-nocaptcha-recaptcha==0.0.19
+sqlparse==0.2.3

+ 1 - 1
dbapi/initdb.d/00-init.sql.var

@@ -1,5 +1,5 @@
 -- deSEC user and domain database
-CREATE DATABASE desec;
+CREATE DATABASE desec CHARACTER SET utf8mb4;
 CREATE USER 'desec'@'${DESECSTACK_IPV4_REAR_PREFIX16}.5.%' IDENTIFIED BY '${DESECSTACK_DBAPI_PASSWORD_desec}';
 GRANT SELECT, INSERT, UPDATE, DELETE, REFERENCES, INDEX, CREATE, ALTER, DROP ON desec.* TO 'desec'@'${DESECSTACK_IPV4_REAR_PREFIX16}.5.%';
 

+ 1 - 0
docs/.gitignore

@@ -0,0 +1 @@
+*.html

+ 3 - 0
docs/README

@@ -0,0 +1,3 @@
+To generate the documentation, run
+
+	rst2html5.py -d --stylesheet-path=minimal.css,plain.css,theme.css index.rst > index.html

+ 175 - 0
docs/domains.rst

@@ -0,0 +1,175 @@
+Domain Management
+-----------------
+
+Domain management is done through the ``/api/v1/domains/`` endpoint.  The
+following sections describe how to create, list, modify, and delete domains
+using JSON objects.  The structure of the JSON objects is detailed in the next
+section.
+
+
+.. _`domain object`:
+
+Domain Field Reference
+~~~~~~~~~~~~~~~~~~~~~~
+
+A JSON object representing a domain has the following structure::
+
+    {
+        "name": "example.com",
+        "owner": "admin@example.com",
+        "arecord": "192.0.2.1",             # or null
+        "aaaarecord": "2001:db8::deec:1",   # or null
+        "acme_challenge": ""
+    }
+
+Field details:
+
+``aaaarecord``
+    :Access mode: read, write
+    :Notice: this field is deprecated
+
+    String with an IPv6 address that will be written to the ``AAAA`` RRset of
+    the zone apex, or ``null``.  If ``null``, the RRset is removed.
+
+    This was originally introduced to set an IPv6 address for deSEC's dynamic
+    DNS service dedyn.io.  However, it has some drawbacks (redundancy with
+    `Modifying an RRset`_ as well as inability to set multiple addresses).
+
+    *Do not rely on this field; it may be removed in the future.*
+
+``acme_challenge``
+    :Access mode: read, write
+    :Notice: this field is deprecated
+
+    String to be written to the ``TXT`` RRset of ``_acme-challenge.{name}``.
+    To set an empty challenge, use ``""``.  The maximum length is 255.
+
+    This was originally introduced to set an ACME challenge to allow obtaining
+    certificates from Let's Encrypt using deSEC's dynamic DNS service
+    dedyn.io.  However, it is redundant with `Modifying an RRset`_.
+
+    *Do not rely on this field; it may be removed in the future.*
+
+``arecord``
+    :Access mode: read, write
+    :Notice: this field is deprecated
+
+    String with an IPv4 address that will be written to the ``A`` RRset of the
+    zone apex, or ``null``.  If ``null``, the RRset is removed.
+
+    This was originally introduced to set an IPv4 address for deSEC's dynamic
+    DNS service dedyn.io.  However, it has some drawbacks (redundancy with
+    `Modifying an RRset`_ as well as inability to set multiple addresses).
+
+    *Do not rely on this field; it may be removed in the future.*
+
+``name``
+    :Access mode: read, write-once (upon domain creation)
+
+    Domain name.  Restrictions on what is a valid domain name apply on a
+    per-user basis.  In general, a domain name consists of alphanumeric
+    characters as well as hyphens ``-`` and underscores ``_`` (except at the
+    beginning of the name).  The maximum length is 191.
+
+``owner``
+    :Access mode: read-only
+
+    Email address of the user owning the domain.
+
+
+Creating a Domain
+~~~~~~~~~~~~~~~~~
+
+To create a new domain, issue a ``POST`` request to the ``/api/v1/domains/``
+endpoint, like this::
+
+    http POST \
+        https://desec.io/api/v1/domains/ \
+        Authorization:"Token {token}" \
+        name:='"example.com"'
+
+Only the ``name`` field is mandatory; ``arecord``, ``acme_challenge``, and
+``aaaarecord`` are optional and deprecated.
+
+Upon success, the response status code will be ``201 Created``, with the
+domain object contained in the response body.  ``400 Bad Request`` is returned
+if the request contained malformed data such as syntactically invalid field
+contents for ``arecord`` or ``aaaarecord``.  If the object could not be
+created although the request was wellformed, the API responds with ``403
+Forbidden`` if the maximum number of domains for this user has been reached,
+and with ``409 Conflict`` otherwise.  This can happen, for example, if there
+already is a domain with the same name or if the domain name is considered
+invalid for policy reasons.
+
+Restrictions on what is a valid domain name apply on a per-user basis.  The
+response body *may* provide further, human-readable information on the policy
+violation that occurred.
+
+
+Listing Domains
+~~~~~~~~~~~~~~~
+
+The ``/api/v1/domains/`` endpoint reponds to ``GET`` requests with an array of
+`domain object`_\ s. For example, you may issue the following command::
+
+    http GET \
+        https://desec.io/api/v1/domains/ \
+        Authorization:"Token {token}"
+
+to retrieve an overview of the domains you own.
+
+The response status code is ``200 OK``.  This is true also if you do not own
+any domains; in this case, the response body will be an empty JSON array.
+
+
+Retrieving a Specific Domain
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To retrieve a domain with a specific name, issue a ``GET`` request with the
+``name`` appended to the ``domains/`` endpoint, like this::
+
+    http GET \
+        https://desec.io/api/v1/domains/{name}/ \
+        Authorization:"Token {token}"
+
+This will return only one domain (i.e., the response is not a JSON array).
+
+If you own a domain with that name, the API responds with ``200 OK`` and
+returns the domain object in the reponse body.  Otherwise, the return status
+code is ``404 Not Found``.
+
+
+Modifying a Domain (deprecated)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To modify a domain, use the endpoint that you would also use to retrieve that
+specific domain.  The API allows changing the values of the ``arecord``,
+``acme_challenge``, and ``aaaarecord`` fields using the ``PATCH`` method.
+Only the field(s) provided in the request will be modified, with everything
+else untouched.  Examples::
+
+    # Set AAAA record
+    http PATCH \
+        https://desec.io/api/v1/domains/{name}/ \
+        Authorization:"Token {token}" \
+        aaaarecord:='"2001:db8::deec:1"'
+
+    # Remove A record and set empty ACME challenge
+    http PATCH \
+        https://desec.io/api/v1/domains/{name}/ \
+        Authorization:"Token {token}" \
+        acme_challenge:='""' arecord:='null'
+
+If the domain was updated successfully, the response status code is ``200 OK``
+and the updated domain object is returned in the response body.  In case of
+malformed request data such as syntactically invalid field contents for
+``arecord`` or ``aaaarecord``, ``400 Bad Request`` is returned.  If the domain
+does not exist or you don't own it, the status code is ``404 Not Found``.
+
+
+Deleting a Domain
+~~~~~~~~~~~~~~~~~
+
+To delete a domain, send a ``DELETE`` request to the endpoint representing the
+domain.  Upon success or if the domain did not exist or was not yours in the
+first place, the response status code is ``204 No Content``.

+ 34 - 0
docs/endpoint-reference.rst

@@ -0,0 +1,34 @@
+Endpoint Reference
+------------------
+
+Endpoints related to `User Registration and Management`_ are described in the
+`djoser endpoint documentation`_.  The following table summarizes basic
+information about the deSEC API endpoints used for `Domain Management`_ and
+`Retrieving and Manipulating DNS Information`_.
+
++------------------------------------------------+------------+---------------------------------------------+
+| Endpoint ``/api/v1/domains``...                | Methods    | Use case                                    |
++================================================+============+=============================================+
+| ...\ ``/``                                     | ``GET``    | Retrieve all domains you own                |
+|                                                +------------+---------------------------------------------+
+|                                                | ``POST``   | Create a domain                             |
++------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/{domain}/``                            | ``GET``    | Retrieve a specific domain                  |
+|                                                +------------+---------------------------------------------+
+|                                                | ``PATCH``  | Modify a domain (deprecated)                |
+|                                                +------------+---------------------------------------------+
+|                                                | ``DELETE`` | Delete a domain                             |
++------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/{domain}/rrsets/``                     | ``GET``    | Retrieve all RRsets from ``domain``, filter |
+|                                                |            | by ``subname`` or ``type`` query parameter  |
+|                                                +------------+---------------------------------------------+
+|                                                | ``POST``   | Create an RRset                             |
++------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/{domain}/rrsets/{subname}.../{type}/`` | ``GET``    | Retrieve a specific RRset                   |
+|                                                +------------+---------------------------------------------+
+|                                                | ``PATCH``  | Modify an RRset                             |
+|                                                +------------+---------------------------------------------+
+|                                                | ``PUT``    | Replace an RRset                            |
+|                                                +------------+---------------------------------------------+
+|                                                | ``DELETE`` | Delete an RRset                             |
++------------------------------------------------+------------+---------------------------------------------+

+ 23 - 0
docs/index.rst

@@ -0,0 +1,23 @@
+deSEC DNS API Documentation
+===========================
+
+.. contents:: Table of Contents
+
+.. include:: introduction.rst
+.. include:: domains.rst
+.. include:: rrsets.rst
+.. include:: endpoint-reference.rst
+
+Getting Help
+------------
+
+There are several ways of getting help:
+
+- Check out the documentation and usage examples at https://desec.io/
+- This documentation
+- Shoot us an email at support@desec.io
+
+About this document
+-------------------
+To add to our documentation or fix a mistake, please submit a Pull Request
+at https://github.com/desec-io/desec-stack.

+ 27 - 0
docs/introduction.rst

@@ -0,0 +1,27 @@
+Introduction
+------------
+
+The deSEC DNS API is a REST interface that allows easy management of DNS
+information. The interface design aims for simplicity so that tasks such as
+creating domains and manipulating DNS records can be handled with ease and in
+an intuitive fashion.
+
+We recommend using `HTTPie`_ for communication with the API, but ``curl`` or
+any other decent HTTP client will work as well.
+
+.. _HTTPie: https://httpie.org/
+
+
+User Registration and Management
+--------------------------------
+
+User management is handled via Django's djoser library.  For usage, please
+check the `djoser endpoint documentation`_.
+
+.. _djoser endpoint documentation:
+    https://djoser.readthedocs.io/en/latest/endpoints.html
+
+Most operations require authentication of the domain owner using a token that
+is returned by djoser's ``login/`` endpoint.  To authenticate, this token is
+transmitted via the HTTP ``Authorization`` header, as shown in the examples in
+this document.

+ 351 - 0
docs/rrsets.rst

@@ -0,0 +1,351 @@
+Retrieving and Manipulating DNS Information
+-------------------------------------------
+
+All DNS information is composed of so-called *Resource Record Sets*
+(*RRsets*).  An RRset is the set of all Resource Records of a given record
+type for a given name.  For example, the name ``example.com`` may have an
+RRset of type ``A``, denoting the set of IPv4 addresses associatd with this
+name.  In the traditional Bind zone file format, the RRset would be written
+as::
+
+    <name>  IN  A 127.0.0.1
+    <name>  IN  A 127.0.0.2
+    ...
+
+Each of these lines is a Resource Record, and together they form an RRset.
+
+The basic units accessible through the API are RRsets, each represented by a
+JSON object.  The object structure is detailed in the next section.
+
+The relevant endpoints all reside under ``/api/v1/domains/{domain}/rrsets/``,
+where ``{domain}`` is the name of a domain you own.  When operating on domains
+that don't exist or you don't own, the API responds with a ``404 Not Found``
+status code.  For a quick overview of the available endpoints, methods, and
+operations, see `Endpoint Reference`_.
+
+
+.. _`RRset object`:
+
+RRset Field Reference
+~~~~~~~~~~~~~~~~~~~~~
+
+A JSON object representing an RRset has the following structure::
+
+    {
+        "domain": "example.com",
+        "subname": "www",
+        "name": "www.example.com.",
+        "type": "A",
+        "records": [
+            "127.0.0.1",
+            "127.0.0.2"
+        ],
+        "ttl": 3600
+    }
+
+Field details:
+
+``domain``
+    :Access mode: read-only
+
+    Name of the zone to which the RRset belongs.
+
+    Note that the zone name does not follow immediately from the RRset name.
+    For example, the ``com`` zone contains an RRset of type ``NS`` for the
+    name ``example.com.``, in order to set up the delegation to
+    ``example.com``'s DNS operator.  The DNS operator's nameserver again
+    has a similar ``NS`` RRset which, this time however, belongs to the
+    ``example.com`` zone.
+
+``name``
+    :Access mode: read-only
+
+    The full DNS name of the RRset.  If ``subname`` is empty, this is equal to
+    ``{domain}.``, otherwise it is equal to ``{subname}.{domain}.``.
+
+``records``
+    :Access mode: read, write
+
+    Array of record content strings.  The maximum number of array elements is
+    4091, and the maximum length of the array is 64,000 (after JSON encoding).
+    Note the `caveat on the priority field`_.
+
+``subname``
+    :Access mode: read, write-once (upon RRset creation)
+
+    Subdomain string which, together with ``domain``, defines the RRset name.
+    Typical examples are ``www`` or ``_443._tcp``.  In general, a subname
+    consists of alphanumeric characters as well as hyphens ``-``, underscores
+    ``_``, dots ``.``, and slashes ``/``.  Wildcard name components are
+    denoted by ``*``; this is allowed only once at the beginning of the name
+    (see RFC 4592 for details).  The maximum length is 178.  Further
+    restrictions may apply on a per-user basis.
+
+    If a ``subname`` contains slashes ``/`` and you are using it in the URL
+    path (e.g. when `retrieving a specific RRset`_), it is required to escape
+    them by replacing them with ``=2F``, to resolve the ambiguity that
+    otherwise arises.  (This escape mechanism does not apply to query strings
+    or inside JSON documents.)
+
+``ttl``
+    :Access mode: read, write
+
+    TTL (time-to-live) value, which dictates for how long resolvers may cache
+    this RRset, measured in seconds.  Only positive integer values are allowed.
+    Additional restrictions may apply.
+
+``type``
+    :Access mode: read, write-once (upon RRset creation)
+
+    RRset type (uppercase).  We support all `RRset types supported by
+    PowerDNS`_, with the exception of DNSSEC-related types (the backend
+    automagically takes care of setting those records properly).  You also
+    cannot access the ``SOA``, see `SOA caveat`_.
+
+.. _RRset types supported by PowerDNS: https://doc.powerdns.com/md/types/
+
+
+Creating an RRset
+~~~~~~~~~~~~~~~~~
+
+To create a new RRset, simply issue a ``POST`` request to the
+``/api/v1/domains/{domain}/rrsets/`` endpoint, like this::
+
+    http POST \
+        https://desec.io/api/v1/domains/{domain}/rrsets/ \
+        Authorization:"Token {token}" \
+        subname:='"www"' type:='"A"' records:='["127.0.0.1","127.0.0.2"]' ttl:=3600
+
+``type``, ``records``, and ``ttl`` are mandatory, whereas the ``subname``
+field is optional.
+
+Upon success, the response status code will be ``201 Created``, with the RRset
+contained in the response body.  If the ``records`` value was semantically
+invalid or an invalid ``type`` was provided, ``422 Unprocessable Entity`` is
+returned.  If the RRset could not be created for another reason (for example
+because another RRset with the same name and type exists already, or because
+not all required fields were provided), the API responds with ``400 Bad
+Request``.
+
+
+Retrieving all RRsets in a Zone
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The ``/api/v1/domains/{domain}/rrsets/`` endpoint reponds to ``GET`` requests
+with an array of `RRset object`_\ s. For example, you may issue the following
+command::
+
+    http GET \
+        https://desec.io/api/v1/domains/{domain}/rrsets/ \
+        Authorization:"Token {token}"
+
+to retrieve the contents of a zone that you own.
+
+The response status code is ``200 OK``.  This is true also if there are no
+RRsets in the zone; in this case, the response body will be an empty JSON
+array.
+
+
+Filtering by Record Type
+````````````````````````
+
+To retrieve an array of all RRsets from your zone that have a specific type
+(e.g. all ``A`` records, regardless of ``subname``), augment the previous
+``GET`` request with a ``type`` query parameter carrying the desired RRset type
+like::
+
+    http GET \
+        https://desec.io/api/v1/domains/{domain}/rrsets/?type={type} \
+        Authorization:"Token {token}"
+
+
+Filtering by Subname
+````````````````````
+
+To filter the RRsets array by subname (e.g. to retrieve all records in the
+``www`` subdomain, regardless of their type), use the ``subname`` query
+parameter, like this::
+
+    http GET \
+        https://desec.io/api/v1/domains/{domain}/rrsets/?subname={subname} \
+        Authorization:"Token {token}"
+
+This approach also allows to retrieve all records associated with the zone
+apex (i.e. ``example.com`` where ``subname`` is empty), by querying
+``rrsets/?subname=``.
+
+Note the three dots after ``{subname}``.  You can think of them as
+abbreviating the rest of the DNS name.  This approach also allows to retrieve
+all records associated with the zone apex (i.e. ``example.com`` where
+``subname`` is empty), by simply using the ``rrsets/.../``.
+
+
+Retrieving a Specific RRset
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To retrieve an RRset with a specific name and type from your zone (e.g. the
+``A`` record for the ``www`` subdomain), issue a ``GET`` request with the
+``subname`` information and the type appended to the ``rrsets/`` endpoint,
+like this::
+
+    http GET \
+        https://desec.io/api/v1/domains/{domain}/rrsets/{subname}.../{type}/ \
+        Authorization:"Token {token}"
+
+Note the three dots after ``{subname}``; you can think of them as abbreviating
+the rest of the DNS name.  This will only return one RRset (i.e., the response
+is not a JSON array).
+
+The response status code is ``200 OK`` if the requested RRset exists, and
+``404 Not Found`` otherwise.
+
+
+Modifying an RRset
+~~~~~~~~~~~~~~~~~~
+
+To modify an RRset, use the endpoint that you would also use to retrieve that
+specific RRset.  The API allows changing the values of ``records`` and
+``ttl``.  When using the ``PATCH`` method, only fields you would like to modify
+need to be provided, where the ``PUT`` method requires specification of both
+fields.  Examples::
+
+    http PUT \
+        https://desec.io/api/v1/domains/{domain}/rrsets/{subname}.../{type}/ \
+        Authorization:"Token {token}" records:='["127.0.0.1"]' ttl:=3600
+
+    http PATCH \
+        https://desec.io/api/v1/domains/{domain}/rrsets/{subname}.../{type}/ \
+        Authorization:"Token {token}" ttl:=86400
+
+If the RRset was updated successfully, the API returns ``200 OK`` with the
+updated RRset in the reponse body.  If not all required fields were provided,
+the API responds with ``400 Bad Request``.  If the ``records`` value was
+semantically invalid, ``422 Unprocessable Entity`` is returned.  If the RRset
+does not exist, ``404 Not Found`` is returned.
+
+
+Deleting an RRset
+~~~~~~~~~~~~~~~~~
+
+To delete an RRset, you can send a ``DELETE`` request to the endpoint
+representing the RRset. Alternatively, you can modify it and provide an empty
+array for the ``records`` field (``[]``).
+
+Upon success or if the RRset did not exist in the first place, the response
+status code is ``204 No Content``.
+
+
+General Notes
+~~~~~~~~~~~~~
+
+- All operations are performed on RRsets, not on the individual Resource
+  Records.
+
+- The TTL (time-to-live: time for which resolvers may cache DNS information)
+  is a property of an RRset (and not of a record).  Thus, all records in an
+  RRset share the record type and also the TTL.  (This is actually a
+  requirement of the DNS specification and not an API design choice.)
+
+- We have not done extensive testing for reverse DNS, but things should work in
+  principle.  If you encounter any problems, please let us know.
+
+
+Notes on Certain Record Types
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Generally, the API supports all `RRset types supported by PowerDNS`_, with a
+few exceptions for such record types that the backend manages automatically.
+Thus, these restrictions are not limitations from a practical point of view.
+Furthermore, special care needs to be taken with some types of records, as
+explained below.
+
+.. _RRset types supported by PowerDNS: https://doc.powerdns.com/md/types/
+
+
+Restricted Types
+````````````````
+**Note:**  Some record types are supported by the API, but not currently
+served by our nameservers (such as ``ALIAS`` or ``DNAME``).  If you wish to
+use such record types, shoot us an email.  In most cases, it should not be a
+problem to enable such functionality.
+
+``DNSKEY``, ``NSEC3PARAM``, ``RRSIG``
+    These record types are meant to provide DNSSEC-related information in
+    order to secure the data stored in your zones.  RRsets of this type are
+    generated and served automatically by our nameservers.  However, you can
+    neither read nor manipulate these RRsets through the API.  When attempting
+    such operations, ``403 Forbidden`` is returned.
+
+.. _`SOA caveat`:
+
+``SOA`` record
+    The ``SOA`` record cannot be read or written through this interface.  When
+    attempting to create, modify or otherwise access an ``SOA`` record, ``403
+    Forbidden`` is returned.
+
+    The rationale behind this is that the content of the ``SOA`` record is
+    entirely determined by the DNS operator, and users should not have to bother
+    with this kind of metadata.  Upon zone changes, the backend automatically
+    takes care of updating the ``SOA`` record accordingly.
+
+    If you are interested in the value of the ``SOA`` record, you can retrieve
+    it using a standard DNS query.
+
+
+Caveats
+```````
+
+.. _`caveat on the priority field`:
+
+Record types with priority field
+    The deSEC DNS API does not explicitly support priority fields (as used for
+    ``MX`` or ``SRV`` records and the like).
+
+    Instead, the priority is expected to be specified at the beginning of the
+    record content, separated from the rest of it by a space.
+
+``CNAME`` record
+    - The record value must be terminated by a dot ``.`` (as in
+      ``example.com.``).
+
+    - If you create a ``CNAME`` record, its presence will cause other RRsets of
+      the same name to be hidden ("occluded") from the public (i.e. in
+      responses to DNS queries).  This is per RFC 1912.
+
+      However, as far as the API is concerned, you can still retrieve and
+      manipulate those additional RRsets.  In other words, ``CNAME``-induced
+      hiding of additional RRsets does not apply when looking at the zone
+      through the API.
+
+    - It is currently possible to create a ``CNAME`` RRset with several
+      records.  However, this is not legal, and the response to queries for
+      such RRsets is undefined.  In short, don't do it.
+
+    - Similarly, you are discouraged from creating a ``CNAME`` RRset for the
+      zone apex (main domain name, empty ``subname``).  Doing so will most
+      likely break your domain (for example, any ``NS`` records that are
+      present will disappear from DNS responses), and other undefined behavior
+      may occur.  In short, don't do it.  If you are interested in aliasing
+      the zone apex, consider using an ``ALIAS`` RRset.
+
+``MX`` record
+    The ``MX`` record value consists of the priority value and a mail server
+    name, which must be terminated by a dot ``.``.  Example: ``10
+    mail.a4a.de.``
+
+``NS`` record
+    The use of wildcard RRsets (with one component of ``subname`` being equal
+    to ``*``) of type ``NS`` is **discouraged**.  This is because the behavior
+    of wildcard ``NS`` records in conjunction with DNSSEC is undefined, per
+    RFC 4592, Sec. 4.2.
+
+``TXT`` record
+    The contents of the ``TXT`` record must be enclosed in double quotes.
+    Thus, when ``POST``\ ing to the API, make sure to do proper escaping etc.
+    as required by the client you are using.  Here's an example of how to
+    create a ``TXT`` RRset with HTTPie::
+
+        http POST \
+            https://desec.io/api/v1/domains/{domain}/rrsets/ \
+            Authorization:"Token {token}" \
+            type:='"TXT"' records:='["\"test value1\"","\"value2\""]' ttl:=3600

+ 17 - 0
docs/theme.css

@@ -0,0 +1,17 @@
+body {
+    background: #eee;
+    font-family: sans-serif;
+}
+
+:not(.field-list) > dt {
+    font-weight: bold;
+}
+
+.literal, .literal-block {
+    background: #fff;
+    border: 1px solid #ddd;
+}
+
+td {
+    vertical-align: top;
+}