Преглед на файлове

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 години
родител
ревизия
3fdd2012b7

+ 23 - 10
api/desecapi/authentication.py

@@ -1,6 +1,5 @@
 from __future__ import unicode_literals
-import base64
-
+import base64, os
 from rest_framework import exceptions, HTTP_HEADER_ENCODING
 from rest_framework.authtoken.models import Token
 from rest_framework.authentication import BaseAuthentication, get_authorization_header, authenticate
@@ -20,13 +19,11 @@ class BasicTokenAuthentication(BaseAuthentication):
     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
-    """
-    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):
         auth = get_authorization_header(request).split()
@@ -69,7 +66,6 @@ class URLParamAuthentication(BaseAuthentication):
     """
     Authentication against username/password as provided in URL parameters.
     """
-
     model = Token
 
     def authenticate(self, request):
@@ -88,7 +84,6 @@ class URLParamAuthentication(BaseAuthentication):
         return self.authenticate_credentials(request.query_params['username'], request.query_params['password'])
 
     def authenticate_credentials(self, userid, key):
-
         try:
             token = self.model.objects.get(key=key)
         except self.model.DoesNotExist:
@@ -98,3 +93,21 @@ class URLParamAuthentication(BaseAuthentication):
             raise exceptions.AuthenticationFailed('User inactive or deleted')
 
         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',
             fields=[
                 ('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)),
             ],
         ),

+ 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
 from django.core.validators import MinValueValidator
 from rest_framework.authtoken.models import Token
+from collections import Counter
 
 
 class MyUserManager(BaseUserManager):
@@ -93,20 +94,17 @@ class User(AbstractBaseUser):
     def unlock(self):
         self.captcha_required = False
         for domain in self.domains.all():
-            domain.pdns_resync()
+            domain.sync_to_pdns()
         self.save()
 
 
 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)
-    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')
-    acme_challenge = models.CharField(max_length=255, blank=True)
     _dirtyName = False
-    _dirtyRecords = False
+    _ns_records_data = [{'content': 'ns1.desec.io.'},
+                        {'content': 'ns2.desec.io.'}]
 
     def setter_name(self, val):
         if val != self.name:
@@ -114,24 +112,6 @@ class Domain(models.Model, mixins.SetterMixin):
 
         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):
         if self._dirtyName:
             raise ValidationError('You must not change the domain name')
@@ -156,69 +136,138 @@ class Domain(models.Model, mixins.SetterMixin):
 
         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.
         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:
-            pdns.create_zone(self)
+            self._create_pdns_zone()
         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
 
-        # 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
-    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):
-        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)
-        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):
-        # 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):
+        """
+        Return domain name.  Needed for serialization via StringRelatedField.
+        (Must be unique.)
+        """
         return self.name
 
     class Meta:
@@ -238,7 +287,6 @@ def get_default_value_mref():
 
 
 class Donation(models.Model):
-
     created = models.DateTimeField(default=get_default_value_created)
     name = models.CharField(max_length=255)
     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)
     email = models.EmailField(max_length=255, blank=True)
 
-
     def save(self, *args, **kwargs):
         self.iban = self.iban[:6] + "xxx" # do NOT save account details
         super().save(*args, **kwargs) # Call the "real" save() method.
@@ -281,8 +328,8 @@ class RRset(models.Model, mixins.SetterMixin):
     class Meta:
         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()
         super().__init__(*args, **kwargs)
 
@@ -310,54 +357,74 @@ class RRset(models.Model, mixins.SetterMixin):
 
     def setter_ttl(self, val):
         if val != self.ttl:
-            self._dirty = True
+            self._dirties.add('ttl')
 
         return val
 
     def clean(self):
         errors = {}
-        for field in self._dirties:
+        for field in (self._dirties & {'domain', 'subname', 'type'}):
             errors[field] = ValidationError(
                 'You cannot change the `%s` field.' % field)
 
         if 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
     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):
         super().delete(*args, **kwargs)
-        self.update_pdns()
+        pdns.set_rrset(self)
+        self.records_data = None
+        self._dirties = {}
 
     @transaction.atomic
-    def save(self, pdns=True, *args, **kwargs):
+    def save(self, sync=True, notify=True, *args, **kwargs):
         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):
+    created = models.DateTimeField(auto_now_add=True)
     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
     content = models.CharField(max_length=4092)

+ 16 - 84
api/desecapi/pdns.py

@@ -68,56 +68,18 @@ def _pdns_put(url):
     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
     if not name.endswith('.'):
         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)
 
-    # Don't forget to import automatically generated RRsets (specifically, NS)
-    domain.sync_from_pdns()
 
 def delete_zone(domain):
     """
@@ -128,12 +90,13 @@ def delete_zone(domain):
 
 def get_keys(domain):
     """
-    Retrieves a JSON representation of the DNSSEC key information
+    Retrieves a dict representation of the DNSSEC key information
     """
     try:
         r = _pdns_get('/zones/%s/cryptokeys' % domain.pdns_id)
         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:
         keys = []
 
@@ -142,16 +105,16 @@ def get_keys(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)
 
     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,
              'subname': rrset['name'][:-(len(domain.name) + 2)],
@@ -162,11 +125,11 @@ def get_rrsets(domain):
             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':
         [{'name': rrset.name, 'type': rrset.type, 'ttl': rrset.ttl,
           'changetype': 'REPLACE',
@@ -177,43 +140,12 @@ def set_rrsets(domain, rrsets):
     }
     _pdns_patch('/zones/' + domain.pdns_id, data)
 
+    if notify:
+        notify_zone(domain)
+
 
 def notify_zone(domain):
     """
     Commands pdns to notify the zone to the pdns slaves.
     """
     _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)
 
     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):
         if value in RRset.RESTRICTED_TYPES:
@@ -56,7 +56,7 @@ class DomainSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = Domain
-        fields = ('name', 'owner', 'arecord', 'aaaarecord', 'acme_challenge', 'keys')
+        fields = ('name', 'owner', 'keys')
 
 
 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
 
 ALLOWED_HOSTS = [
+    'api',
     'desec.%s' % os.environ['DESECSTACK_DOMAIN'],
     'update.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
 AUTH_USER_MODEL = 'desecapi.User'
 
+# default NS records
+DEFAULT_NS = ['ns1.desec.io.', 'ns2.desec.io.']
+
 # PowerDNS API access
 NSLORD_PDNS_API = 'http://nslord:8081/api/v1/servers/localhost'
 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)
         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):
         url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
         response = self.client.get(url)
         newname = utils.generateDomainname()
         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')
         self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
         response = self.client.get(url)
@@ -194,38 +182,6 @@ class AuthenticatedDomainTests(APITestCase):
         response = self.client.post(url, data)
         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):
         name = utils.generateDomainname()
 
@@ -249,100 +205,6 @@ class AuthenticatedDomainTests(APITestCase):
         self.assertEqual(httpretty.httpretty.latest_requests[-2].method, 'GET')
         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):
         url = reverse('domain-detail', args=(self.ownedDomains[1].pk,))
         urlByName = reverse('domain-detail/byName', args=(self.ownedDomains[1].name,))
@@ -379,17 +241,15 @@ class AuthenticatedDynDomainTests(APITestCase):
     def testCanDeleteOwnedDynDomain(self):
         httpretty.enable()
         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,))
         response = self.client.delete(url)
         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.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(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):
         url = reverse('domain-list')
 

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

@@ -47,16 +47,24 @@ class DynDNS12UpdateTest(APITestCase):
         httpretty.reset()
         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']
         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:
-            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:
-            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)
 
     def testDynDNS1UpdateDDClientSuccess(self):
@@ -201,13 +209,15 @@ class DynDNS12UpdateTest(APITestCase):
         self.owner.captcha_required = True
         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,
@@ -223,10 +233,10 @@ class DynDNS12UpdateTest(APITestCase):
 
         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):
         self.owner.captcha_required = True
@@ -235,36 +245,34 @@ class DynDNS12UpdateTest(APITestCase):
         url = reverse('domain-list')
         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)
         response = self.client.post(url, data)
         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.PATCH, settings.NSLORD_PDNS_API + '/zones/' + newdomain + '.')
         httpretty.register_uri(httpretty.GET,
                                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")
         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.GET,
                                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")
         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,))
         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)
         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.last_request().method, 'PUT')
 
     def testDeleteCausesPdnsAPICall(self):
         httpretty.enable()

+ 21 - 10
api/desecapi/views.py

@@ -15,8 +15,7 @@ from rest_framework.authentication import (
 from rest_framework.renderers import StaticHTMLRenderer
 from dns import resolver
 from django.template.loader import get_template
-from desecapi.authentication import (
-    BasicTokenAuthentication, URLParamAuthentication)
+import desecapi.authentication as auth
 import base64
 from desecapi import settings
 from rest_framework.exceptions import (
@@ -33,7 +32,6 @@ from desecapi.emails import send_account_lock_email, send_token_email
 import re
 import ipaddress, os
 
-# TODO Generalize?
 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]+$')
 
@@ -57,10 +55,15 @@ class DomainList(generics.ListCreateAPIView):
             raise ex
 
         # 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():
             ex = ValidationError(detail={"detail": "This domain name is unavailable.", "code": "domain-unavailable"})
             ex.status_code = status.HTTP_409_CONFLICT
@@ -165,6 +168,7 @@ class RRsetDetail(generics.RetrieveUpdateDestroyAPIView):
 
 
 class RRsetList(generics.ListCreateAPIView):
+    authentication_classes = (TokenAuthentication, auth.IPAuthentication,)
     serializer_class = RRsetSerializer
     permission_classes = (permissions.IsAuthenticated, IsDomainOwner,)
 
@@ -280,7 +284,7 @@ class DnsQuery(APIView):
 
 
 class DynDNS12Update(APIView):
-    authentication_classes = (TokenAuthentication, BasicTokenAuthentication, URLParamAuthentication,)
+    authentication_classes = (TokenAuthentication, auth.BasicTokenAuthentication, auth.URLParamAuthentication,)
     renderer_classes = [StaticHTMLRenderer]
 
     def findDomain(self, request):
@@ -362,9 +366,16 @@ class DynDNS12Update(APIView):
         if domain is None:
             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')
 

+ 4 - 0
api/uwsgi.ini

@@ -5,3 +5,7 @@ wsgi-file = desecapi/wsgi.py
 processes = 64
 threads = 4
 #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_SECRETKEY
     - DESECSTACK_DBAPI_PASSWORD_desec
+    - DESECSTACK_IPV4_REAR_PREFIX16
     - DESECSTACK_IPV6_SUBNET
     - DESECSTACK_NSLORD_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"
             },
             ...
-        ],
-        "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.*
-
 ``keys``
     :Access mode: read-only
 
@@ -120,13 +78,11 @@ endpoint, like this::
         Authorization:"Token {token}" \
         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
 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
 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
@@ -171,34 +127,6 @@ 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
 ~~~~~~~~~~~~~~~~~
 

+ 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
 	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
 	if [ "$PARENT" == "dedyn.io" ]; then
+		SUBNAME=${ZONE%%.*}
+
 		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
 done