Browse Source

refactor(api): switch dynDNS handling from Domain model to RRset model

Enables http on api and allows nslord to list/create RRsets for dedyn.io.

We now manage dedyn.io locally (i.e. in desec database).  It is required
now that dedyn.io is known to the API, i.e. it needs to be owned by some
API user.  nslord authenticates itself to the API based on its IP address
and impersonates the dedyn.io domain owner to set DS records.

This commit also removes the deprecated Domain fields arecord,
aaaarecord, and acme_challenge.
Peter Thomassen 7 năm trước cách đây
mục cha
commit
3fdd2012b7

+ 23 - 10
api/desecapi/authentication.py

@@ -1,6 +1,5 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
-import base64
-
+import base64, os
 from rest_framework import exceptions, HTTP_HEADER_ENCODING
 from rest_framework import exceptions, HTTP_HEADER_ENCODING
 from rest_framework.authtoken.models import Token
 from rest_framework.authtoken.models import Token
 from rest_framework.authentication import BaseAuthentication, get_authorization_header, authenticate
 from rest_framework.authentication import BaseAuthentication, get_authorization_header, authenticate
@@ -20,13 +19,11 @@ class BasicTokenAuthentication(BaseAuthentication):
     For username "username" and password "token".
     For username "username" and password "token".
     """
     """
 
 
+    # A custom token model may be used, but must have the following properties.
+    #
+    # * key -- The string identifying the token
+    # * user -- The user to which the token belongs
     model = Token
     model = Token
-    """
-    A custom token model may be used, but must have the following properties.
-
-    * key -- The string identifying the token
-    * user -- The user to which the token belongs
-    """
 
 
     def authenticate(self, request):
     def authenticate(self, request):
         auth = get_authorization_header(request).split()
         auth = get_authorization_header(request).split()
@@ -69,7 +66,6 @@ class URLParamAuthentication(BaseAuthentication):
     """
     """
     Authentication against username/password as provided in URL parameters.
     Authentication against username/password as provided in URL parameters.
     """
     """
-
     model = Token
     model = Token
 
 
     def authenticate(self, request):
     def authenticate(self, request):
@@ -88,7 +84,6 @@ class URLParamAuthentication(BaseAuthentication):
         return self.authenticate_credentials(request.query_params['username'], request.query_params['password'])
         return self.authenticate_credentials(request.query_params['username'], request.query_params['password'])
 
 
     def authenticate_credentials(self, userid, key):
     def authenticate_credentials(self, userid, key):
-
         try:
         try:
             token = self.model.objects.get(key=key)
             token = self.model.objects.get(key=key)
         except self.model.DoesNotExist:
         except self.model.DoesNotExist:
@@ -98,3 +93,21 @@ class URLParamAuthentication(BaseAuthentication):
             raise exceptions.AuthenticationFailed('User inactive or deleted')
             raise exceptions.AuthenticationFailed('User inactive or deleted')
 
 
         return token.user, token
         return token.user, token
+
+
+class IPAuthentication(BaseAuthentication):
+    """
+    Authentication against remote IP address for dedyn.io management by nslord
+    """
+    def authenticate(self, request):
+        nslord = '%s.1.11' % os.environ['DESECSTACK_IPV4_REAR_PREFIX16']
+
+        # Make sure this is dual-stack safe
+        if request.META.get('REMOTE_ADDR') in [nslord, '::ffff:%s' % nslord]:
+            try:
+                domain = Domain.objects.get(name='dedyn.io')
+                return (domain.owner, None)
+            except Domain.DoesNotExist:
+                return None
+
+        return None

+ 1 - 0
api/desecapi/migrations/0017_rr_model.py

@@ -17,6 +17,7 @@ class Migration(migrations.Migration):
             name='RR',
             name='RR',
             fields=[
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created', models.DateTimeField(auto_now_add=True)),
                 ('content', models.CharField(max_length=4092)),
                 ('content', models.CharField(max_length=4092)),
             ],
             ],
         ),
         ),

+ 31 - 0
api/desecapi/migrations/0018_prune_domain_fields.py

@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.4 on 2017-08-26 22:55
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0017_rr_model'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='domain',
+            name='aaaarecord',
+        ),
+        migrations.RemoveField(
+            model_name='domain',
+            name='acme_challenge',
+        ),
+        migrations.RemoveField(
+            model_name='domain',
+            name='arecord',
+        ),
+        migrations.RemoveField(
+            model_name='domain',
+            name='updated',
+        ),
+    ]

+ 157 - 90
api/desecapi/models.py

@@ -7,6 +7,7 @@ from desecapi import pdns, mixins
 import datetime
 import datetime
 from django.core.validators import MinValueValidator
 from django.core.validators import MinValueValidator
 from rest_framework.authtoken.models import Token
 from rest_framework.authtoken.models import Token
+from collections import Counter
 
 
 
 
 class MyUserManager(BaseUserManager):
 class MyUserManager(BaseUserManager):
@@ -93,20 +94,17 @@ class User(AbstractBaseUser):
     def unlock(self):
     def unlock(self):
         self.captcha_required = False
         self.captcha_required = False
         for domain in self.domains.all():
         for domain in self.domains.all():
-            domain.pdns_resync()
+            domain.sync_to_pdns()
         self.save()
         self.save()
 
 
 
 
 class Domain(models.Model, mixins.SetterMixin):
 class Domain(models.Model, mixins.SetterMixin):
     created = models.DateTimeField(auto_now_add=True)
     created = models.DateTimeField(auto_now_add=True)
-    updated = models.DateTimeField(null=True)
     name = models.CharField(max_length=191, unique=True)
     name = models.CharField(max_length=191, unique=True)
-    arecord = models.GenericIPAddressField(protocol='IPv4', blank=False, null=True)
-    aaaarecord = models.GenericIPAddressField(protocol='IPv6', blank=False, null=True)
     owner = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='domains')
     owner = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='domains')
-    acme_challenge = models.CharField(max_length=255, blank=True)
     _dirtyName = False
     _dirtyName = False
-    _dirtyRecords = False
+    _ns_records_data = [{'content': 'ns1.desec.io.'},
+                        {'content': 'ns2.desec.io.'}]
 
 
     def setter_name(self, val):
     def setter_name(self, val):
         if val != self.name:
         if val != self.name:
@@ -114,24 +112,6 @@ class Domain(models.Model, mixins.SetterMixin):
 
 
         return val
         return val
 
 
-    def setter_arecord(self, val):
-        if val != self.arecord:
-            self._dirtyRecords = True
-
-        return val
-
-    def setter_aaaarecord(self, val):
-        if val != self.aaaarecord:
-            self._dirtyRecords = True
-
-        return val
-
-    def setter_acme_challenge(self, val):
-        if val != self.acme_challenge:
-            self._dirtyRecords = True
-
-        return val
-
     def clean(self):
     def clean(self):
         if self._dirtyName:
         if self._dirtyName:
             raise ValidationError('You must not change the domain name')
             raise ValidationError('You must not change the domain name')
@@ -156,69 +136,138 @@ class Domain(models.Model, mixins.SetterMixin):
 
 
         return name
         return name
 
 
-    def pdns_resync(self):
+    # When this is made a property, looping over Domain.rrsets breaks
+    def get_rrsets(self):
+        return RRset.objects.filter(domain=self)
+
+    def _create_pdns_zone(self):
+        """
+        Create zone on pdns.  This will also import any RRsets that may have
+        been created already.
+        """
+        pdns.create_zone(self, settings.DEFAULT_NS)
+
+        # Import RRsets that may have been created (e.g. during captcha lock).
+        # Don't perform if we do not know of any RRsets (it would delete all
+        # existing records from pdns).
+        rrsets = self.get_rrsets()
+        if rrsets:
+            pdns.set_rrsets(self, rrsets)
+
+        # Make our RRsets consistent with pdns (specifically, NS may exist)
+        self.sync_from_pdns()
+
+    def sync_to_pdns(self):
         """
         """
         Make sure that pdns gets the latest information about this domain/zone.
         Make sure that pdns gets the latest information about this domain/zone.
         Re-Syncing is relatively expensive and should not happen routinely.
         Re-Syncing is relatively expensive and should not happen routinely.
         """
         """
-
-        # Create zone if it does not exist yet
+        # Try to create zone, in case it does not exist yet
         try:
         try:
-            pdns.create_zone(self)
+            self._create_pdns_zone()
         except pdns.PdnsException as e:
         except pdns.PdnsException as e:
-            if not (e.status_code == 422 and e.detail.endswith(' already exists')):
+            if (e.status_code == 422 and e.detail.endswith(' already exists')):
+                # Zone exists, purge it by deleting all RRsets and sync
+                pdns.set_rrsets(self, [], notify=False)
+                pdns.set_rrsets(self, self.get_rrsets())
+            else:
                 raise e
                 raise e
 
 
-        # update zone to latest information
-        pdns.set_dyn_records(self)
+    @transaction.atomic
+    def sync_from_pdns(self):
+        RRset.objects.filter(domain=self).delete()
+        rrset_datas = [rrset_data for rrset_data in pdns.get_rrset_datas(self)
+                       if rrset_data['type'] not in RRset.RESTRICTED_TYPES]
+        # Can't do bulk create because we need records creation in RRset.save()
+        for rrset_data in rrset_datas:
+            RRset(**rrset_data).save(sync=False)
 
 
-    def pdns_sync(self, new_domain):
+    @transaction.atomic
+    def set_rrsets(self, rrsets):
         """
         """
-        Command pdns updates as indicated by the local changes.
+        Writes the provided RRsets to the database, overriding any existing
+        RRsets of the same subname and type.  If the user account is not locked
+        for captcha, also inform pdns about the new RRsets.
         """
         """
-
-        if self.owner.captcha_required:
-            # suspend all updates
-            return
-
-        # if this zone is new, create it and set dirty flag if necessary
-        if new_domain:
-            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)
-
-        self._dirtyRecords = False
+        for rrset in rrsets:
+            if rrset.domain != self:
+                raise ValueError(
+                    'Cannot set RRset for domain %s on domain %s.' % (
+                    rrset.domain.name, self.name))
+            if rrset.type in RRset.RESTRICTED_TYPES:
+                raise ValueError(
+                    'You cannot tinker with the %s RRset.' % rrset.type)
+
+        pdns_rrsets = []
+        for rrset in rrsets:
+            # Look up old RRset to see if it needs updating.  If exists and
+            # outdated, delete it so that we can bulk-create it later.
+            try:
+                old_rrset = self.rrset_set.get(subname=rrset.subname,
+                                               type=rrset.type)
+                old_rrset.ttl = rrset.ttl
+                old_rrset.records_data = rrset.records_data
+                rrset = old_rrset
+            except RRset.DoesNotExist:
+                pass
+
+            # At this point, rrset is an RRset to be created or possibly to be
+            # updated.  RRset.save() will decide what to write to the database.
+            if rrset.pk is None or 'records' in rrset.get_dirties():
+                pdns_rrsets.append(rrset)
+
+            rrset.save(sync=False)
+
+        if not self.owner.captcha_required:
+            pdns.set_rrsets(self, pdns_rrsets)
 
 
     @transaction.atomic
     @transaction.atomic
-    def sync_from_pdns(self):
-        RRset.objects.filter(domain=self).delete()
-        for rrset in pdns.get_rrsets(self):
-            if rrset['type'] not in RRset.RESTRICTED_TYPES:
-                RRset(**rrset).save(pdns=False)
-
     def delete(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
-        super(Domain, self).delete(*args, **kwargs)
+        # Delete delegation for dynDNS domains (direct child of dedyn.io)
+        subname, parent_pdns_id = self.pdns_id.split('.', 1)
+        if parent_pdns_id == 'dedyn.io.':
+            parent = Domain.objects.filter(name='dedyn.io').first()
+
+            if parent:
+                rrsets = RRset.objects.filter(domain=parent, subname=subname,
+                                              type__in=['NS', 'DS']).all()
+                for rrset in rrsets:
+                    rrset.records_data = []
 
 
+                parent.set_rrsets(rrsets)
+
+        # Delete domain
+        super().delete(*args, **kwargs)
         pdns.delete_zone(self)
         pdns.delete_zone(self)
-        if self.name.endswith('.dedyn.io'):
-            pdns.set_rrset_in_parent(self, 'DS', '')
-            pdns.set_rrset_in_parent(self, 'NS', '')
 
 
-    @transaction.atomic
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
-        # Record here if this is a new domain (self.pk is only None until we call super.save())
-        new_domain = self.pk is None
+        with transaction.atomic():
+            new = self.pk is None
+            self.clean()
+            super().save(*args, **kwargs)
 
 
-        self.updated = timezone.now()
-        self.clean()
-        super(Domain, self).save(*args, **kwargs)
+            if new and not self.owner.captcha_required:
+                self._create_pdns_zone()
 
 
-        self.pdns_sync(new_domain)
+        if not new:
+            return
+
+        # If the domain is a direct subdomain of dedyn.io, set NS records in
+        # parent. Don't notify slaves (we first have to enable DNSSEC).
+        subname, parent_pdns_id = self.pdns_id.split('.', 1)
+        if parent_pdns_id == 'dedyn.io.':
+            parent = Domain.objects.filter(name='dedyn.io').first()
+            if parent:
+                records_data = [('content', x) for x in settings.DEFAULT_NS]
+                rrset = RRset(domain=parent, subname=subname, type='NS',
+                              ttl=60, records_data=records_data)
+                rrset.save(notify=False)
 
 
     def __str__(self):
     def __str__(self):
+        """
+        Return domain name.  Needed for serialization via StringRelatedField.
+        (Must be unique.)
+        """
         return self.name
         return self.name
 
 
     class Meta:
     class Meta:
@@ -238,7 +287,6 @@ def get_default_value_mref():
 
 
 
 
 class Donation(models.Model):
 class Donation(models.Model):
-
     created = models.DateTimeField(default=get_default_value_created)
     created = models.DateTimeField(default=get_default_value_created)
     name = models.CharField(max_length=255)
     name = models.CharField(max_length=255)
     iban = models.CharField(max_length=34)
     iban = models.CharField(max_length=34)
@@ -249,7 +297,6 @@ class Donation(models.Model):
     mref = models.CharField(max_length=32,default=get_default_value_mref)
     mref = models.CharField(max_length=32,default=get_default_value_mref)
     email = models.EmailField(max_length=255, blank=True)
     email = models.EmailField(max_length=255, blank=True)
 
 
-
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         self.iban = self.iban[:6] + "xxx" # do NOT save account details
         self.iban = self.iban[:6] + "xxx" # do NOT save account details
         super().save(*args, **kwargs) # Call the "real" save() method.
         super().save(*args, **kwargs) # Call the "real" save() method.
@@ -281,8 +328,8 @@ class RRset(models.Model, mixins.SetterMixin):
     class Meta:
     class Meta:
         unique_together = (("domain","subname","type"),)
         unique_together = (("domain","subname","type"),)
 
 
-    def __init__(self, *args, **kwargs):
-        self.records_data = kwargs.pop('records_data', [])
+    def __init__(self, *args, records_data=None, **kwargs):
+        self.records_data = records_data
         self._dirties = set()
         self._dirties = set()
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
@@ -310,54 +357,74 @@ class RRset(models.Model, mixins.SetterMixin):
 
 
     def setter_ttl(self, val):
     def setter_ttl(self, val):
         if val != self.ttl:
         if val != self.ttl:
-            self._dirty = True
+            self._dirties.add('ttl')
 
 
         return val
         return val
 
 
     def clean(self):
     def clean(self):
         errors = {}
         errors = {}
-        for field in self._dirties:
+        for field in (self._dirties & {'domain', 'subname', 'type'}):
             errors[field] = ValidationError(
             errors[field] = ValidationError(
                 'You cannot change the `%s` field.' % field)
                 'You cannot change the `%s` field.' % field)
 
 
         if errors:
         if errors:
             raise ValidationError(errors)
             raise ValidationError(errors)
 
 
+    def get_dirties(self):
+        if self.records_data is not None and 'records' not in self._dirties \
+            and (self.pk is None
+                or Counter([x['content'] for x in self.records_data])
+                    != Counter(self.records.values_list('content', flat=True))
+                ):
+            self._dirties.add('records')
+
+        return self._dirties
+
     @property
     @property
     def name(self):
     def name(self):
         return '.'.join(filter(None, [self.subname, self.domain.name])) + '.'
         return '.'.join(filter(None, [self.subname, self.domain.name])) + '.'
 
 
-    def update_pdns(self):
-        pdns.set_rrset(self)
-        pdns.notify_zone(self.domain)
-
     @transaction.atomic
     @transaction.atomic
     def delete(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
         super().delete(*args, **kwargs)
         super().delete(*args, **kwargs)
-        self.update_pdns()
+        pdns.set_rrset(self)
+        self.records_data = None
+        self._dirties = {}
 
 
     @transaction.atomic
     @transaction.atomic
-    def save(self, pdns=True, *args, **kwargs):
+    def save(self, sync=True, notify=True, *args, **kwargs):
         new = self.pk is None
         new = self.pk is None
-        self.updated = timezone.now()
-        self.full_clean()
-        super().save(*args, **kwargs)
 
 
-        records = self.records.all()
-        if self.records_data and self.records_data != [{'content': x.content}
-                                                       for x in records]:
-            self._dirty = True
-            records.delete()
-            while self.records_data:
-                self.records.create(**self.records_data.pop())
+        # Empty records data means deletion
+        if self.records_data == []:
+            if not new:
+                self.delete()
+            return
+
+        # The only thing that can change is the TTL
+        if new or 'ttl' in self.get_dirties():
+            self.updated = timezone.now()
+            self.full_clean()
+            super().save(*args, **kwargs)
+
+        # Create RRset contents
+        if 'records' in self.get_dirties():
+            self.records.all().delete()
+            records = [RR(rrset=self, **data) for data in self.records_data]
+            self.records.bulk_create(records)
+            self.records_data = None
 
 
-        if pdns and (self._dirty or new):
-            self.update_pdns()
+        # Sync to pdns if new or anything is dirty
+        if sync and not self.domain.owner.captcha_required \
+                and (new or self.get_dirties()):
+            pdns.set_rrset(self, notify=notify)
 
 
-        self._dirty = False
+        self._dirties = {}
 
 
 
 
 class RR(models.Model):
 class RR(models.Model):
+    created = models.DateTimeField(auto_now_add=True)
     rrset = models.ForeignKey(RRset, on_delete=models.CASCADE, related_name='records')
     rrset = models.ForeignKey(RRset, on_delete=models.CASCADE, related_name='records')
+    # max_length is determined based on the calculation in
     # https://lists.isc.org/pipermail/bind-users/2008-April/070148.html
     # https://lists.isc.org/pipermail/bind-users/2008-April/070148.html
     content = models.CharField(max_length=4092)
     content = models.CharField(max_length=4092)

+ 16 - 84
api/desecapi/pdns.py

@@ -68,56 +68,18 @@ def _pdns_put(url):
     return r
     return r
 
 
 
 
-def _delete_or_replace_rrset(name, rr_type, value, ttl=60):
+def create_zone(domain, nameservers, kind='NATIVE'):
     """
     """
-    Return pdns API json to either replace or delete a record set, depending on whether value is empty or not.
-    """
-    if value:
-        return \
-            {
-                "records": [
-                    {
-                        "type": rr_type,
-                        "name": name,
-                        "disabled": False,
-                        "content": value,
-                    }
-                ],
-                "ttl": ttl,
-                "changetype": "REPLACE",
-                "type": rr_type,
-                "name": name,
-            }
-    else:
-        return \
-            {
-                "changetype": "DELETE",
-                "type": rr_type,
-                "name": name
-            }
-
-
-def create_zone(domain, kind='NATIVE'):
-    """
-    Commands pdns to create a zone with the given name.
+    Commands pdns to create a zone with the given name and nameservers.
     """
     """
     name = domain.name
     name = domain.name
     if not name.endswith('.'):
     if not name.endswith('.'):
         name += '.'
         name += '.'
 
 
-    payload = {
-        "name": name,
-        "kind": kind.upper(),
-        "masters": [],
-        "nameservers": [
-            "ns1.desec.io.",
-            "ns2.desec.io."
-        ]
-    }
+    payload = {'name': name, 'kind': kind.upper(), 'masters': [],
+               'nameservers': nameservers}
     _pdns_post('/zones', payload)
     _pdns_post('/zones', payload)
 
 
-    # Don't forget to import automatically generated RRsets (specifically, NS)
-    domain.sync_from_pdns()
 
 
 def delete_zone(domain):
 def delete_zone(domain):
     """
     """
@@ -128,12 +90,13 @@ def delete_zone(domain):
 
 
 def get_keys(domain):
 def get_keys(domain):
     """
     """
-    Retrieves a JSON representation of the DNSSEC key information
+    Retrieves a dict representation of the DNSSEC key information
     """
     """
     try:
     try:
         r = _pdns_get('/zones/%s/cryptokeys' % domain.pdns_id)
         r = _pdns_get('/zones/%s/cryptokeys' % domain.pdns_id)
         keys = [{k: key[k] for k in ('dnskey', 'ds', 'flags', 'keytype')}
         keys = [{k: key[k] for k in ('dnskey', 'ds', 'flags', 'keytype')}
-                for key in r.json() if key['active'] and key['keytype'] in ['csk', 'ksk']]
+                for key in r.json()
+                if key['active'] and key['keytype'] in ['csk', 'ksk']]
     except:
     except:
         keys = []
         keys = []
 
 
@@ -142,16 +105,16 @@ def get_keys(domain):
 
 
 def get_zone(domain):
 def get_zone(domain):
     """
     """
-    Retrieves a JSON representation of the zone from pdns
+    Retrieves a dict representation of the zone from pdns
     """
     """
     r = _pdns_get('/zones/' + domain.pdns_id)
     r = _pdns_get('/zones/' + domain.pdns_id)
 
 
     return r.json()
     return r.json()
 
 
 
 
-def get_rrsets(domain):
+def get_rrset_datas(domain):
     """
     """
-    Retrieves a JSON representation of the RRsets in a given zone, optionally restricting to a name and RRset type 
+    Retrieves a dict representation of the RRsets in a given zone
     """
     """
     return [{'domain': domain,
     return [{'domain': domain,
              'subname': rrset['name'][:-(len(domain.name) + 2)],
              'subname': rrset['name'][:-(len(domain.name) + 2)],
@@ -162,11 +125,11 @@ def get_rrsets(domain):
             for rrset in get_zone(domain)['rrsets']]
             for rrset in get_zone(domain)['rrsets']]
 
 
 
 
-def set_rrset(rrset):
-    return set_rrsets(rrset.domain, [rrset])
+def set_rrset(rrset, notify=True):
+    return set_rrsets(rrset.domain, [rrset], notify=notify)
 
 
 
 
-def set_rrsets(domain, rrsets):
+def set_rrsets(domain, rrsets, notify=True):
     data = {'rrsets':
     data = {'rrsets':
         [{'name': rrset.name, 'type': rrset.type, 'ttl': rrset.ttl,
         [{'name': rrset.name, 'type': rrset.type, 'ttl': rrset.ttl,
           'changetype': 'REPLACE',
           'changetype': 'REPLACE',
@@ -177,43 +140,12 @@ def set_rrsets(domain, rrsets):
     }
     }
     _pdns_patch('/zones/' + domain.pdns_id, data)
     _pdns_patch('/zones/' + domain.pdns_id, data)
 
 
+    if notify:
+        notify_zone(domain)
+
 
 
 def notify_zone(domain):
 def notify_zone(domain):
     """
     """
     Commands pdns to notify the zone to the pdns slaves.
     Commands pdns to notify the zone to the pdns slaves.
     """
     """
     _pdns_put('/zones/%s/notify' % domain.pdns_id)
     _pdns_put('/zones/%s/notify' % domain.pdns_id)
-
-
-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.
-    """
-    _pdns_patch('/zones/' + domain.pdns_id, {
-        "rrsets": [
-            _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),
-        ]
-    })
-
-    # Don't forget to import the updated RRsets
-    domain.sync_from_pdns()
-
-    notify_zone(domain)
-
-
-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.
-    """
-    parent_id = domain.pdns_id.split('.', 1)[1]
-
-    _pdns_patch('/zones/' + parent_id, {
-        "rrsets": [
-            _delete_or_replace_rrset(domain.name + '.', rr_type, value),
-        ]
-    })

+ 2 - 2
api/desecapi/serializers.py

@@ -41,7 +41,7 @@ class RRsetSerializer(serializers.ModelSerializer):
         return super().update(instance, validated_data)
         return super().update(instance, validated_data)
 
 
     def get_records(self, obj):
     def get_records(self, obj):
-        return [x for x in obj.records.values_list('content', flat=True)]
+        return list(obj.records.values_list('content', flat=True))
 
 
     def validate_type(self, value):
     def validate_type(self, value):
         if value in RRset.RESTRICTED_TYPES:
         if value in RRset.RESTRICTED_TYPES:
@@ -56,7 +56,7 @@ class DomainSerializer(serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Domain
         model = Domain
-        fields = ('name', 'owner', 'arecord', 'aaaarecord', 'acme_challenge', 'keys')
+        fields = ('name', 'owner', 'keys')
 
 
 
 
 class DonationSerializer(serializers.ModelSerializer):
 class DonationSerializer(serializers.ModelSerializer):

+ 4 - 0
api/desecapi/settings.py

@@ -26,6 +26,7 @@ if os.environ.get('DESECSTACK_API_DEBUG', "").upper() == "TRUE":
     DEBUG = True
     DEBUG = True
 
 
 ALLOWED_HOSTS = [
 ALLOWED_HOSTS = [
+    'api',
     'desec.%s' % os.environ['DESECSTACK_DOMAIN'],
     'desec.%s' % os.environ['DESECSTACK_DOMAIN'],
     'update.dedyn.%s' % os.environ['DESECSTACK_DOMAIN'],
     'update.dedyn.%s' % os.environ['DESECSTACK_DOMAIN'],
     'update6.dedyn.%s' % os.environ['DESECSTACK_DOMAIN'],
     'update6.dedyn.%s' % os.environ['DESECSTACK_DOMAIN'],
@@ -148,6 +149,9 @@ ADMINS = [(address.split("@")[0], address) for address in os.environ['DESECSTACK
 # use our own user model
 # use our own user model
 AUTH_USER_MODEL = 'desecapi.User'
 AUTH_USER_MODEL = 'desecapi.User'
 
 
+# default NS records
+DEFAULT_NS = ['ns1.desec.io.', 'ns2.desec.io.']
+
 # PowerDNS API access
 # PowerDNS API access
 NSLORD_PDNS_API = 'http://nslord:8081/api/v1/servers/localhost'
 NSLORD_PDNS_API = 'http://nslord:8081/api/v1/servers/localhost'
 NSLORD_PDNS_API_TOKEN = os.environ['DESECSTACK_NSLORD_APIKEY']
 NSLORD_PDNS_API_TOKEN = os.environ['DESECSTACK_NSLORD_APIKEY']

+ 9 - 145
api/desecapi/tests/testdomains.py

@@ -100,23 +100,11 @@ class AuthenticatedDomainTests(APITestCase):
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
 
-    def testCanPutOwnedDomain(self):
-        url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
-        response = self.client.get(url)
-        response.data['arecord'] = '1.2.3.4'
-        response = self.client.put(url, json.dumps(response.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['arecord'], '1.2.3.4')
-
     def testCantChangeDomainName(self):
     def testCantChangeDomainName(self):
         url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
         url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
         response = self.client.get(url)
         response = self.client.get(url)
         newname = utils.generateDomainname()
         newname = utils.generateDomainname()
         response.data['name'] = newname
         response.data['name'] = newname
-        response.data['arecord'] = None
-        response.data['aaaarecord'] = None
         response = self.client.put(url, json.dumps(response.data), content_type='application/json')
         response = self.client.put(url, json.dumps(response.data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
         self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
         response = self.client.get(url)
         response = self.client.get(url)
@@ -194,38 +182,6 @@ class AuthenticatedDomainTests(APITestCase):
         response = self.client.post(url, data)
         response = self.client.post(url, data)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
-    def testCanUpdateARecord(self):
-        url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
-        response = self.client.get(url)
-        response.data['arecord'] = '10.13.3.7'
-        response.data['aaaarecord'] = None
-        response = self.client.put(url, json.dumps(response.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['arecord'], '10.13.3.7')
-
-    def testCanUpdateAAAARecord(self):
-        url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
-        response = self.client.get(url)
-        response.data['arecord'] = None
-        response.data['aaaarecord'] = 'fe80::a11:10ff:fee0:ff77'
-        response = self.client.put(url, json.dumps(response.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['aaaarecord'], 'fe80::a11:10ff:fee0:ff77')
-
-    def testCanUpdateAcmeChallenge(self):
-        url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
-        response = self.client.get(url)
-        response.data['acme_challenge'] = 'test_challenge'
-        response = self.client.put(url, json.dumps(response.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['acme_challenge'], 'test_challenge')
-
     def testPostingCausesPdnsAPICalls(self):
     def testPostingCausesPdnsAPICalls(self):
         name = utils.generateDomainname()
         name = utils.generateDomainname()
 
 
@@ -249,100 +205,6 @@ class AuthenticatedDomainTests(APITestCase):
         self.assertEqual(httpretty.httpretty.latest_requests[-2].method, 'GET')
         self.assertEqual(httpretty.httpretty.latest_requests[-2].method, 'GET')
         self.assertTrue((settings.NSLORD_PDNS_API + '/zones/' + name + '.').endswith(httpretty.httpretty.latest_requests[-2].path))
         self.assertTrue((settings.NSLORD_PDNS_API + '/zones/' + name + '.').endswith(httpretty.httpretty.latest_requests[-2].path))
 
 
-    def testPostingWithRecordsCausesPdnsAPIPatch(self):
-        name = utils.generateDomainname()
-
-        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')
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + name + './cryptokeys',
-                               body='[]',
-                               content_type="application/json")
-
-        url = reverse('domain-list')
-        data = {'name': name, 'arecord': '1.3.3.7', 'aaaarecord': 'dead::beef', 'acme_challenge': 'letsencrypt_ftw'}
-        self.client.post(url, data)
-
-        self.assertEqual(httpretty.httpretty.latest_requests[-4].method, 'PATCH')
-        self.assertTrue(data['name'] in httpretty.httpretty.latest_requests[-4].parsed_body)
-        self.assertTrue('1.3.3.7' in httpretty.httpretty.latest_requests[-4].parsed_body)
-        self.assertTrue('dead::beef' in httpretty.httpretty.latest_requests[-4].parsed_body)
-        self.assertTrue('letsencrypt_ftw' in httpretty.httpretty.latest_requests[-4].parsed_body)
-
-    def testPostDomainCausesPdnsAPIPatch(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")
-        httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + name + '.')
-        httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify')
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + name + './cryptokeys',
-                               body='[]',
-                               content_type="application/json")
-
-        url = reverse('domain-list')
-        data = {'name': name, 'acme_challenge': 'letsencrypt_ftw'}
-        self.client.post(url, data)
-
-        self.assertEqual(httpretty.httpretty.latest_requests[-4].method, 'PATCH')
-        self.assertTrue(data['name'] in httpretty.httpretty.latest_requests[-4].parsed_body)
-        self.assertTrue('letsencrypt_ftw' in httpretty.httpretty.latest_requests[-4].parsed_body)
-
-    def testUpdateingCausesPdnsAPIPatchCall(self):
-        url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
-        response = self.client.get(url)
-
-        httpretty.enable()
-        httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + response.data['name'] + '.')
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + response.data['name'] + '.',
-                               body='{"rrsets": []}',
-                               content_type="application/json")
-        httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + response.data['name'] + './notify')
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + response.data['name'] + './cryptokeys',
-                               body='[]',
-                               content_type="application/json")
-
-        response.data['arecord'] = '10.13.3.7'
-        self.client.put(url, json.dumps(response.data), content_type='application/json')
-
-        self.assertTrue('10.13.3.7' in httpretty.httpretty.latest_requests[-4].parsed_body)
-
-    def testUpdateingCausesPdnsAPINotifyCall(self):
-        url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
-        response = self.client.get(url)
-
-        httpretty.enable()
-        httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + response.data['name'] + '.')
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + response.data['name'] + '.',
-                               body='{"rrsets": []}',
-                               content_type="application/json")
-        httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + response.data['name'] + './notify')
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + response.data['name'] + './cryptokeys',
-                               body='[]',
-                               content_type="application/json")
-
-        response.data['arecord'] = '10.13.3.10'
-        self.client.put(url, json.dumps(response.data), content_type='application/json')
-
-        self.assertEqual(httpretty.httpretty.latest_requests[-4].method, 'PATCH')
-        self.assertTrue('10.13.3.10' in httpretty.httpretty.latest_requests[-4].parsed_body)
-        self.assertEqual(httpretty.httpretty.latest_requests[-2].method, 'PUT')
-
     def testDomainDetailURL(self):
     def testDomainDetailURL(self):
         url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
         url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
         urlByName = reverse('domain-detail/byName', args=(self.ownedDomains[1].name,))
         urlByName = reverse('domain-detail/byName', args=(self.ownedDomains[1].name,))
@@ -379,17 +241,15 @@ class AuthenticatedDynDomainTests(APITestCase):
     def testCanDeleteOwnedDynDomain(self):
     def testCanDeleteOwnedDynDomain(self):
         httpretty.enable()
         httpretty.enable()
         httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
-        httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.ownedDomains[1].name+ '.')
-        httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/dedyn.io.')
+        httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
 
 
         url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
         url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
         response = self.client.delete(url)
         response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
-        self.assertEqual(httpretty.last_request().method, 'PATCH')
-        self.assertEqual(httpretty.last_request().headers['Host'], 'nslord:8081')
-        self.assertTrue('"NS"' in httpretty.last_request().parsed_body)
-        self.assertTrue('"' + self.ownedDomains[1].name + '."' in httpretty.last_request().parsed_body)
-        self.assertTrue('"DELETE"' in httpretty.last_request().parsed_body)
+
+        # FIXME In this testing scenario, the parent domain dedyn.io does not
+        # have the proper NS and DS records set up, so we cannot test their
+        # deletion.
 
 
         httpretty.reset()
         httpretty.reset()
         httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
@@ -420,6 +280,10 @@ class AuthenticatedDynDomainTests(APITestCase):
         self.assertTrue(data['name'] in email)
         self.assertTrue(data['name'] in email)
         self.assertTrue(self.token in email)
         self.assertTrue(self.token in email)
 
 
+        # FIXME We also need to test that proper NS and DS records are set up
+        # in the parent zone dedyn.io.  Because this relies on the cron hook,
+        # it is currently not covered.
+
     def testCantPostNonDynDomains(self):
     def testCantPostNonDynDomains(self):
         url = reverse('domain-list')
         url = reverse('domain-list')
 
 

+ 39 - 31
api/desecapi/tests/testdyndns12update.py

@@ -47,16 +47,24 @@ class DynDNS12UpdateTest(APITestCase):
         httpretty.reset()
         httpretty.reset()
         httpretty.disable()
         httpretty.disable()
 
 
-    def assertIP(self, ipv4=None, ipv6=None):
+    def assertIP(self, ipv4=None, ipv6=None, name=None):
         old_credentials = self.client._credentials['HTTP_AUTHORIZATION']
         old_credentials = self.client._credentials['HTTP_AUTHORIZATION']
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.password)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.password)
-        url = reverse('domain-detail/byName', args=(self.username,))
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        name = name or self.username
+
         if ipv4 is not None:
         if ipv4 is not None:
-            self.assertEqual(response.data['arecord'], ipv4)
+            url = reverse('rrset', args=(name, '', 'A',))
+            response = self.client.get(url)
+            self.assertEqual(response.data['records'][0], ipv4)
+
         if ipv6 is not None:
         if ipv6 is not None:
-            self.assertEqual(response.data['aaaarecord'], ipv6)
+            url = reverse('rrset', args=(name, '', 'AAAA',))
+            response = self.client.get(url)
+            self.assertEqual(response.data['records'][0], ipv6)
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data['ttl'], 60)
+
         self.client.credentials(HTTP_AUTHORIZATION=old_credentials)
         self.client.credentials(HTTP_AUTHORIZATION=old_credentials)
 
 
     def testDynDNS1UpdateDDClientSuccess(self):
     def testDynDNS1UpdateDDClientSuccess(self):
@@ -201,13 +209,15 @@ class DynDNS12UpdateTest(APITestCase):
         self.owner.captcha_required = True
         self.owner.captcha_required = True
         self.owner.save()
         self.owner.save()
 
 
-        httpretty.reset()
-        httpretty.enable()
-        httpretty.HTTPretty.allow_net_connect = False
-
-        domain = self.owner.domains.all()[0]
-        domain.arecord = '10.1.1.1'
-        domain.save()
+        url = reverse('dyndns12update')
+        response = self.client.get(url,
+                                   {
+                                       'system': 'dyndns',
+                                       'hostname': self.domain,
+                                       'myip': '10.1.1.1'
+                                   })
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertIP(ipv4='10.1.1.1')
 
 
         httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones')
         httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones')
         httpretty.register_uri(httpretty.POST,
         httpretty.register_uri(httpretty.POST,
@@ -223,10 +233,10 @@ class DynDNS12UpdateTest(APITestCase):
 
 
         self.owner.unlock()
         self.owner.unlock()
 
 
-        self.assertEqual(httpretty.httpretty.latest_requests[-3].method, 'PATCH')
-        self.assertTrue((settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.').endswith(httpretty.httpretty.latest_requests[-3].path))
-        self.assertTrue(self.domain in httpretty.httpretty.latest_requests[-3].parsed_body)
-        self.assertTrue('10.1.1.1' in httpretty.httpretty.latest_requests[-3].parsed_body)
+        self.assertEqual(httpretty.httpretty.latest_requests[-2].method, 'PATCH')
+        self.assertTrue((settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.').endswith(httpretty.httpretty.latest_requests[-2].path))
+        self.assertTrue(self.domain in httpretty.httpretty.latest_requests[-2].parsed_body)
+        self.assertTrue('10.1.1.1' in httpretty.httpretty.latest_requests[-2].parsed_body)
 
 
     def testSuspendedUpdatesDomainCreation(self):
     def testSuspendedUpdatesDomainCreation(self):
         self.owner.captcha_required = True
         self.owner.captcha_required = True
@@ -235,36 +245,34 @@ class DynDNS12UpdateTest(APITestCase):
         url = reverse('domain-list')
         url = reverse('domain-list')
         newdomain = utils.generateDynDomainname()
         newdomain = utils.generateDynDomainname()
 
 
-        httpretty.reset()
-        httpretty.enable()
-        httpretty.HTTPretty.allow_net_connect = False
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + newdomain + './cryptokeys',
-                               body='[]',
-                               content_type="application/json")
-
-        data = {'name': newdomain, 'arecord': '10.2.2.2'}
+        data = {'name': newdomain}
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         response = self.client.post(url, data)
         response = self.client.post(url, data)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
-        domain = self.owner.domains.all()[0]
-        domain.arecord = '10.1.1.1'
-        domain.save()
+        url = reverse('dyndns12update')
+        response = self.client.get(url,
+                                   {
+                                       'system': 'dyndns',
+                                       'hostname': newdomain,
+                                       'myip': '10.2.2.2'
+                                   })
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertIP(name=newdomain, ipv4='10.2.2.2')
 
 
         httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones')
         httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones')
 
 
         httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + newdomain + '.')
         httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + newdomain + '.')
         httpretty.register_uri(httpretty.GET,
         httpretty.register_uri(httpretty.GET,
                                settings.NSLORD_PDNS_API + '/zones/' + newdomain + '.',
                                settings.NSLORD_PDNS_API + '/zones/' + newdomain + '.',
-                               body='{"rrsets": []}',
+                               body='{"rrsets": [{"comments": [], "name": "%s.", "records": [ { "content": "ns1.desec.io.", "disabled": false }, { "content": "ns2.desec.io.", "disabled": false } ], "ttl": 60, "type": "NS"}]}' % self.domain,
                                content_type="application/json")
                                content_type="application/json")
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + newdomain + './notify', status=200)
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + newdomain + './notify', status=200)
 
 
         httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.')
         httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.')
         httpretty.register_uri(httpretty.GET,
         httpretty.register_uri(httpretty.GET,
                                settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.',
                                settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.',
-                               body='{"rrsets": []}',
+                               body='{"rrsets": [{"comments": [], "name": "%s.", "records": [ { "content": "ns1.desec.io.", "disabled": false }, { "content": "ns2.desec.io.", "disabled": false } ], "ttl": 60, "type": "NS"}]}' % self.domain,
                                content_type="application/json")
                                content_type="application/json")
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.domain + './notify', status=200)
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.domain + './notify', status=200)
 
 

+ 2 - 1
api/desecapi/tests/testrrsets.py

@@ -386,11 +386,12 @@ class AuthenticatedRRsetTests(APITestCase):
 
 
         url = reverse('rrsets', args=(self.ownedDomains[1].name,))
         url = reverse('rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        self.client.post(url, json.dumps(data), content_type='application/json')
 
 
         result = json.loads(httpretty.httpretty.latest_requests[-2].parsed_body)
         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]['name'], self.ownedDomains[1].name + '.')
         self.assertEqual(result['rrsets'][0]['records'][0]['content'], '1.2.3.4')
         self.assertEqual(result['rrsets'][0]['records'][0]['content'], '1.2.3.4')
+        self.assertEqual(httpretty.last_request().method, 'PUT')
 
 
     def testDeleteCausesPdnsAPICall(self):
     def testDeleteCausesPdnsAPICall(self):
         httpretty.enable()
         httpretty.enable()

+ 21 - 10
api/desecapi/views.py

@@ -15,8 +15,7 @@ from rest_framework.authentication import (
 from rest_framework.renderers import StaticHTMLRenderer
 from rest_framework.renderers import StaticHTMLRenderer
 from dns import resolver
 from dns import resolver
 from django.template.loader import get_template
 from django.template.loader import get_template
-from desecapi.authentication import (
-    BasicTokenAuthentication, URLParamAuthentication)
+import desecapi.authentication as auth
 import base64
 import base64
 from desecapi import settings
 from desecapi import settings
 from rest_framework.exceptions import (
 from rest_framework.exceptions import (
@@ -33,7 +32,6 @@ from desecapi.emails import send_account_lock_email, send_token_email
 import re
 import re
 import ipaddress, os
 import ipaddress, os
 
 
-# TODO Generalize?
 patternDyn = re.compile(r'^[A-Za-z-][A-Za-z0-9_-]*\.dedyn\.io$')
 patternDyn = re.compile(r'^[A-Za-z-][A-Za-z0-9_-]*\.dedyn\.io$')
 patternNonDyn = re.compile(r'^([A-Za-z0-9-][A-Za-z0-9_-]*\.)+[A-Za-z]+$')
 patternNonDyn = re.compile(r'^([A-Za-z0-9-][A-Za-z0-9_-]*\.)+[A-Za-z]+$')
 
 
@@ -57,10 +55,15 @@ class DomainList(generics.ListCreateAPIView):
             raise ex
             raise ex
 
 
         # Generate a list containing this and all higher-level domain names
         # Generate a list containing this and all higher-level domain names
-        domain_parts = serializer.validated_data['name'].split('.')
-        domain_list = [ '.'.join(domain_parts[i:]) for i in range(len(domain_parts)) ]
+        domain_name = serializer.validated_data['name']
+        domain_parts = domain_name.split('.')
+        domain_list = {'.'.join(domain_parts[i:]) for i in range(1, len(domain_parts))}
 
 
-        queryset = Domain.objects.filter(Q(name=domain_list[0]) | (Q(name__in=domain_list[1:]) & ~Q(owner=self.request.user)))
+        # Remove public suffixes and then use this list to control registration
+        public_suffixes = {'dedyn.io'}
+        domain_list = domain_list - public_suffixes
+
+        queryset = Domain.objects.filter(Q(name=domain_name) | (Q(name__in=domain_list) & ~Q(owner=self.request.user)))
         if queryset.exists():
         if queryset.exists():
             ex = ValidationError(detail={"detail": "This domain name is unavailable.", "code": "domain-unavailable"})
             ex = ValidationError(detail={"detail": "This domain name is unavailable.", "code": "domain-unavailable"})
             ex.status_code = status.HTTP_409_CONFLICT
             ex.status_code = status.HTTP_409_CONFLICT
@@ -165,6 +168,7 @@ class RRsetDetail(generics.RetrieveUpdateDestroyAPIView):
 
 
 
 
 class RRsetList(generics.ListCreateAPIView):
 class RRsetList(generics.ListCreateAPIView):
+    authentication_classes = (TokenAuthentication, auth.IPAuthentication,)
     serializer_class = RRsetSerializer
     serializer_class = RRsetSerializer
     permission_classes = (permissions.IsAuthenticated, IsDomainOwner,)
     permission_classes = (permissions.IsAuthenticated, IsDomainOwner,)
 
 
@@ -280,7 +284,7 @@ class DnsQuery(APIView):
 
 
 
 
 class DynDNS12Update(APIView):
 class DynDNS12Update(APIView):
-    authentication_classes = (TokenAuthentication, BasicTokenAuthentication, URLParamAuthentication,)
+    authentication_classes = (TokenAuthentication, auth.BasicTokenAuthentication, auth.URLParamAuthentication,)
     renderer_classes = [StaticHTMLRenderer]
     renderer_classes = [StaticHTMLRenderer]
 
 
     def findDomain(self, request):
     def findDomain(self, request):
@@ -362,9 +366,16 @@ class DynDNS12Update(APIView):
         if domain is None:
         if domain is None:
             raise Http404
             raise Http404
 
 
-        domain.arecord = self.findIPv4(request)
-        domain.aaaarecord = self.findIPv6(request)
-        domain.save()
+        rrsets = []
+        datas = {'A': self.findIPv4(request), 'AAAA': self.findIPv6(request)}
+
+        for type_, ip in datas.items():
+            records_data = [{'content': ip}] if ip is not None else []
+            rrset = RRset(domain=domain, subname='', ttl=60, type=type_,
+                          records_data=records_data)
+            rrsets.append(rrset)
+
+        domain.set_rrsets(rrsets)
 
 
         return Response('good')
         return Response('good')
 
 

+ 4 - 0
api/uwsgi.ini

@@ -5,3 +5,7 @@ wsgi-file = desecapi/wsgi.py
 processes = 64
 processes = 64
 threads = 4
 threads = 4
 #stats = 127.0.0.1:9191
 #stats = 127.0.0.1:9191
+
+#master = true
+http = [::]:8080
+http-to = 127.0.0.1:3031

+ 1 - 0
docker-compose.yml

@@ -110,6 +110,7 @@ services:
     - DESECSTACK_API_EMAIL_PORT
     - DESECSTACK_API_EMAIL_PORT
     - DESECSTACK_API_SECRETKEY
     - DESECSTACK_API_SECRETKEY
     - DESECSTACK_DBAPI_PASSWORD_desec
     - DESECSTACK_DBAPI_PASSWORD_desec
+    - DESECSTACK_IPV4_REAR_PREFIX16
     - DESECSTACK_IPV6_SUBNET
     - DESECSTACK_IPV6_SUBNET
     - DESECSTACK_NSLORD_APIKEY
     - DESECSTACK_NSLORD_APIKEY
     - DESECSTACK_NSMASTER_APIKEY
     - DESECSTACK_NSMASTER_APIKEY

+ 3 - 75
docs/domains.rst

@@ -30,53 +30,11 @@ A JSON object representing a domain has the following structure::
                 "keytype": "csk"
                 "keytype": "csk"
             },
             },
             ...
             ...
-        ],
-        "arecord": "192.0.2.1",             # or null
-        "aaaarecord": "2001:db8::deec:1",   # or null
-        "acme_challenge": ""
+        ]
     }
     }
 
 
 Field details:
 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.*
-
 ``keys``
 ``keys``
     :Access mode: read-only
     :Access mode: read-only
 
 
@@ -120,13 +78,11 @@ endpoint, like this::
         Authorization:"Token {token}" \
         Authorization:"Token {token}" \
         name:='"example.com"'
         name:='"example.com"'
 
 
-Only the ``name`` field is mandatory; ``arecord``, ``acme_challenge``, and
-``aaaarecord`` are optional and deprecated.
+Only the ``name`` field is mandatory.
 
 
 Upon success, the response status code will be ``201 Created``, with the
 Upon success, the response status code will be ``201 Created``, with the
 domain object contained in the response body.  ``400 Bad Request`` is returned
 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
+if the request contained malformed data.  If the object could not be
 created although the request was wellformed, the API responds with ``403
 created although the request was wellformed, the API responds with ``403
 Forbidden`` if the maximum number of domains for this user has been reached,
 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
 and with ``409 Conflict`` otherwise.  This can happen, for example, if there
@@ -171,34 +127,6 @@ returns the domain object in the reponse body.  Otherwise, the return status
 code is ``404 Not Found``.
 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
 Deleting a Domain
 ~~~~~~~~~~~~~~~~~
 ~~~~~~~~~~~~~~~~~
 
 

+ 11 - 9
nslord/cronhook/secure-zones.sh

@@ -13,19 +13,21 @@ for ZONE in `echo "SELECT name FROM domains WHERE type = 'NATIVE' && id NOT IN(S
 
 
 	# Set up DNSSEC, switch zone type to MASTER, and increase serial for notify
 	# Set up DNSSEC, switch zone type to MASTER, and increase serial for notify
 	pdnsutil secure-zone -- "$ZONE" \
 	pdnsutil secure-zone -- "$ZONE" \
-	    && pdnsutil set-nsec3 -- "$ZONE" "1 0 300 $SALT" \
-	    && pdnsutil set-kind -- "$ZONE" MASTER \
-	    && pdnsutil increase-serial -- "$ZONE"
+		&& pdnsutil set-nsec3 -- "$ZONE" "1 0 300 $SALT" \
+		&& pdnsutil set-kind -- "$ZONE" MASTER \
+		&& pdnsutil increase-serial -- "$ZONE"
 
 
 	# Take care of delegations
 	# Take care of delegations
 	if [ "$PARENT" == "dedyn.io" ]; then
 	if [ "$PARENT" == "dedyn.io" ]; then
+		SUBNAME=${ZONE%%.*}
+
 		set +x # don't write commands with sensitive information to the screen
 		set +x # don't write commands with sensitive information to the screen
 
 
-		echo "Setting DS/NS records for $ZONE and put them in parent zone"
-		DATA='{"rrsets": [ {"name": "'"$ZONE".'", "type": "DS", "ttl": 60, "changetype": "REPLACE", "records": '
-		DATA+=`curl -sS -X GET -H "X-API-Key: $APITOKEN" http://nslord:8081/api/v1/servers/localhost/zones/$ZONE/cryptokeys \
-			| jq -c '[.[] | select(.active == true) | {content: .ds[]?, disabled: false}]'`
-		DATA+=' }, {"name": "'"$ZONE".'", "type": "NS", "ttl": 60, "changetype": "REPLACE", "records": [ {"content": "ns1.desec.io.", "disabled": false}, {"content": "ns2.desec.io.", "disabled": false} ] } ] }'
-		curl -sS -X PATCH --data "$DATA" -H "X-API-Key: $APITOKEN" http://nslord:8081/api/v1/servers/localhost/zones/$PARENT
+		echo "Getting DS records for $ZONE and put them in parent zone"
+		DATA='{"subname": "'"$SUBNAME"'", "type": "DS", "ttl": 60, "records": '
+		DATA+=`curl -sS -X GET -H "X-API-Key: $APITOKEN" "http://nslord:8081/api/v1/servers/localhost/zones/$ZONE/cryptokeys" \
+			| jq -c '[.[] | select(.active == true) | .ds[]?]'`
+		DATA+=' }'
+		curl -sS -X POST --data "$DATA" -H "Content-Type: application/json" http://api:8080/api/v1/domains/$PARENT/rrsets/
 	fi
 	fi
 done
 done