소스 검색

feat(api,webapp): Accept zonefile import upon domain creation

Nils Wisiol 3 년 전
부모
커밋
51f7a33afb

+ 84 - 2
api/desecapi/serializers.py

@@ -6,6 +6,7 @@ from base64 import b64encode
 from datetime import timedelta
 
 import django.core.exceptions
+import dns.zone
 from captcha.audio import AudioCaptcha
 from captcha.image import ImageCaptcha
 from django.contrib.auth.password_validation import validate_password
@@ -579,10 +580,11 @@ class DomainSerializer(serializers.ModelSerializer):
         **serializers.Serializer.default_error_messages,
         'name_unavailable': 'This domain name conflicts with an existing zone, or is disallowed by policy.',
     }
+    zonefile = serializers.CharField(write_only=True, required=False, allow_blank=True)
 
     class Meta:
         model = models.Domain
-        fields = ('created', 'published', 'name', 'keys', 'minimum_ttl', 'touched',)
+        fields = ('created', 'published', 'name', 'keys', 'minimum_ttl', 'touched', 'zonefile')
         read_only_fields = ('published', 'minimum_ttl',)
         extra_kwargs = {
             'name': {'trim_whitespace': False},
@@ -590,6 +592,7 @@ class DomainSerializer(serializers.ModelSerializer):
 
     def __init__(self, *args, include_keys=False, **kwargs):
         self.include_keys = include_keys
+        self.import_zone = None
         super().__init__(*args, **kwargs)
 
     def get_fields(self):
@@ -604,10 +607,89 @@ class DomainSerializer(serializers.ModelSerializer):
             raise serializers.ValidationError(self.default_error_messages['name_unavailable'], code='name_unavailable')
         return value
 
+    def parse_zonefile(self, domain_name: str, zonefile: str):
+        try:
+            self.import_zone = dns.zone.from_text(
+                zonefile,
+                origin=dns.name.from_text(domain_name),
+                allow_include=False,
+                check_origin=False,
+                relativize=False,
+            )
+        except dns.zonefile.CNAMEAndOtherData:
+            raise serializers.ValidationError(
+                {'zonefile': ['No other records with the same name are allowed alongside a CNAME record.']})
+        except ValueError as e:
+            if 'has non-origin SOA' in str(e):
+                raise serializers.ValidationError(
+                    {'zonefile': [f'Zonefile includes an SOA record for a name different from {domain_name}.']})
+            raise e
+        except dns.exception.SyntaxError as e:
+            try:
+                line = str(e).split(':')[1]
+                raise serializers.ValidationError({'zonefile': [f'Zonefile contains syntax error in line {line}.']})
+            except IndexError:
+                raise serializers.ValidationError({'zonefile': [f'Could not parse zonefile: {str(e)}']})
+
+    def validate(self, attrs):
+        if attrs.get('zonefile') is not None:
+            self.parse_zonefile(attrs.get('name'), attrs.pop('zonefile'))
+        return super().validate(attrs)
+
     def create(self, validated_data):
+        # save domain
         if 'minimum_ttl' not in validated_data and models.Domain(name=validated_data['name']).is_locally_registrable:
             validated_data.update(minimum_ttl=60)
-        return super().create(validated_data)
+        domain: models.Domain = super().create(validated_data)
+
+        # save RRsets if zonefile was given
+        nodes = getattr(self.import_zone, 'nodes', None)
+        if nodes:
+            zone_name = dns.name.from_text(validated_data['name'])
+            min_ttl, max_ttl = domain.minimum_ttl, settings.MAXIMUM_TTL
+            data = [
+                {
+                    'type': dns.rdatatype.to_text(rrset.rdtype),
+                    'ttl': max(min_ttl, min(max_ttl, rrset.ttl)),
+                    'subname': (owner_name - zone_name).to_text() if owner_name - zone_name != dns.name.empty else '',
+                    'records': [rr.to_text() for rr in rrset],
+                }
+                for owner_name, node in nodes.items()
+                for rrset in node.rdatasets
+                if (
+                    dns.rdatatype.to_text(rrset.rdtype) not in (
+                        models.RR_SET_TYPES_AUTOMATIC |  # do not import automatically managed record types
+                        {'CDS', 'CDNSKEY', 'DNSKEY'}  # do not import these, as this would likely be unexpected
+                    )
+                    and not (owner_name - zone_name == dns.name.empty and rrset.rdtype == dns.rdatatype.NS)  # ignore apex NS
+                )
+            ]
+
+            rrset_list_serializer = RRsetSerializer(data=data, context=dict(domain=domain), many=True)
+            # The following line raises if data passed validation by dnspython during zone file parsing,
+            # but is rejected by validation in RRsetSerializer. See also
+            # test_create_domain_zonefile_import_validation
+            try:
+                rrset_list_serializer.is_valid(raise_exception=True)
+            except serializers.ValidationError as e:
+                if isinstance(e.detail, serializers.ReturnList):
+                    # match the order of error messages with the RRsets provided to the
+                    # serializer to make sense to the client
+                    def fqdn(idx): return (data[idx]['subname'] + "." + domain.name).lstrip('.')
+                    raise serializers.ValidationError({
+                        'zonefile': [
+                            f"{fqdn(idx)}/{data[idx]['type']}: {err}"
+                            for idx, d in enumerate(e.detail)
+                            for _, errs in d.items()
+                            for err in errs
+                        ]
+                    })
+
+                raise e
+
+            rrset_list_serializer.save()
+
+        return domain
 
 
 class DonationSerializer(serializers.ModelSerializer):

+ 18 - 1
api/desecapi/tests/base.py

@@ -6,7 +6,7 @@ import string
 from contextlib import nullcontext
 from functools import partial, reduce
 from json import JSONDecodeError
-from typing import Union, List, Dict
+from typing import Union, List, Dict, Set
 from unittest import mock
 
 from django.conf import settings
@@ -842,6 +842,23 @@ class DesecTestCase(MockPDNSTestCase):
                     )
                 )
 
+    def assertRRsetDB(self, domain: Domain, subname: str, type_: str, ttl: int = None,
+                      rr_contents: Set[str] = None):
+        if rr_contents is not None:
+            try:
+                has_rr_contents = {rr.content for rr in domain.rrset_set.get(subname=subname, type=type_).records.all()}
+            except RRset.DoesNotExist:
+                has_rr_contents = set()
+            self.assertSetEqual(
+                has_rr_contents, rr_contents,
+                f'{domain.name}: RRset for subname="{subname}" and type={type_} did not have the expected records '
+                f'{rr_contents}, but had {has_rr_contents}.',
+            )
+        if ttl is not None:
+            has_ttl = domain.rrset_set.get(subname=subname, type=type_).ttl
+            self.assertEqual(has_ttl, ttl, f'{domain.name}: RRset for subname="{subname}" and type={type_} did not '
+                                           f'have the expected TTL of {ttl}, but had {has_ttl}.')
+
     @staticmethod
     def _count_occurrences_by_mask(rr_sets, masks):
         def _cmp(key, a, b):

+ 221 - 0
api/desecapi/tests/test_domains.py

@@ -303,6 +303,227 @@ class DomainOwnerTestCase1(DomainOwnerTestCase):
             self.assertFalse(domain.is_locally_registrable)
             self.assertEqual(domain.renewal_state, Domain.RenewalState.IMMORTAL);
 
+    def test_create_domain_zonefile_import(self):
+        zonefile = """$ORIGIN .
+$TTL 43200 ; 12 hours
+import-me.example IN SOA ns1.example.com. hostmaster.example.com. (
+2022021300 ; serial
+10800 ; refresh (3 hours)
+3600 ; retry (1 hour)
+2419000 ; expire (3 weeks 6 days 23 hours 56 minutes 40 seconds)
+43200 ; minimum (12 hours)
+)
+import-me.example NS ns1.example.com.
+import-me.example NS ns2.example.com.
+import-me.example NS ns3.example.com.
+import-me.example NS ns4.example.com.
+import-me.example NS ns5.example.com.
+$TTL 300 ; 5 mins
+import-me.example A 10.1.1.1
+*.import-me.example A 10.1.1.1
+import-me.example TXT "v=spf1 -all"
+_dmarc.import-me.example TXT "v=DMARC1; p=reject;"
+xxx.import-me.example NS ns4.example.
+xxx.import-me.example NS ns5.example.
+
+$TTL 43200 ; 12 hours
+localhost.import-me.example A 127.0.0.1
+
+# show zone import-me.example
+"""
+        name = 'import-me.example'
+        with self.assertPdnsRequests(
+                self.requests_desec_domain_creation(name, axfr=False, keys=False) +
+                self.requests_desec_rr_sets_update(name) +
+                [self.request_pdns_zone_retrieve_crypto_keys(name)]
+        ):
+            response = self.client.post(self.reverse('v1:domain-list'), {'name': name, 'zonefile': zonefile})
+        self.assertStatus(response, status.HTTP_201_CREATED)
+        domain = Domain.objects.get(name=name)
+        self.assertRRsetDB(domain, subname='', type_='SOA', rr_contents=set())
+        self.assertRRsetDB(domain, subname='', type_='NS', ttl=settings.DEFAULT_NS_TTL,
+                           rr_contents=set(settings.DEFAULT_NS))
+        ttl = max(300, settings.MINIMUM_TTL_DEFAULT)
+        self.assertRRsetDB(domain, subname='', type_='A', ttl=ttl, rr_contents={'10.1.1.1'})
+        self.assertRRsetDB(domain, subname='*', type_='A', ttl=ttl, rr_contents={'10.1.1.1'})
+        self.assertRRsetDB(domain, subname='', type_='TXT', ttl=ttl, rr_contents={'"v=spf1 -all"'})
+        self.assertRRsetDB(domain, subname='_dmarc', type_='TXT', ttl=ttl,
+                           rr_contents={'"v=DMARC1; p=reject;"'})
+        self.assertRRsetDB(domain, subname='xxx', type_='NS', ttl=ttl,
+                           rr_contents={'ns4.example.', 'ns5.example.'})
+        self.assertRRsetDB(domain, subname='localhost', type_='A', ttl=43200, rr_contents={'127.0.0.1'})
+
+    def test_create_domain_zonefile_import_cname_exclusivity(self):
+        zonefile = """$ORIGIN .
+$TTL 43200 ; 12 hours
+import-me.example IN SOA ns1.example.com. hostmaster.example.com. 2022021300 10800 3600 2419000 43200
+import-me.example NS ns1.example.com.
+www.import-me.example CNAME a.example.
+www.import-me.example A 127.0.0.1
+"""
+        name = 'import-me.example'
+        response = self.client.post(self.reverse('v1:domain-list'), {'name': name, 'zonefile': zonefile})
+        self.assertResponse(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(
+            response.json(),
+            {'zonefile': ['No other records with the same name are allowed alongside a CNAME record.']},
+        )
+
+    def test_create_domain_zonefile_import_name_non_apex_soa(self):
+        zonefile = """$ORIGIN .
+$TTL 43200 ; 12 hours
+asdf.import-me.example IN SOA ns1.example.com. hostmaster.example.com. 2022021300 10800 3600 2419000 43200
+import-me.example NS ns1.example.com.
+www.import-me.example CNAME a.example.
+www.import-me.example A 127.0.0.1
+"""
+        name = 'import-me.example'
+        response = self.client.post(self.reverse('v1:domain-list'), {'name': name, 'zonefile': zonefile})
+        self.assertResponse(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(
+            response.json(),
+            {'zonefile': [f'Zonefile includes an SOA record for a name different from {name}.']},
+        )
+
+    def test_create_domain_zonefile_import_syntax_error_line(self):
+        zonefile = """$ORIGIN .
+$TTL 43200 ; 12 hours
+import-me.example IN SOA ns1.example.com. hostmaster.example.com. 2022021300 10800 3600 2419000 43200
+import-me.example NS ns1.example.com.
+www.import-me.example CNAME a.example.
+www.import-me.example A asdf
+"""
+        name = 'import-me.example'
+        response = self.client.post(self.reverse('v1:domain-list'), {'name': name, 'zonefile': zonefile})
+        self.assertResponse(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(
+            response.json(),
+            {'zonefile': [f'Zonefile contains syntax error in line 6.']},
+        )
+
+    def test_create_domain_zonefile_import_foreign_rrset(self):
+        zonefile = f"""$ORIGIN .
+$TTL 43200 ; 12 hours
+import-me.example IN SOA ns1.example.com. hostmaster.example.com. 2022021300 10800 3600 2419000 43200
+import-me.example NS ns1.example.com.
+import-me.example A 127.0.0.1
+inject.{self.other_domain.name}. CNAME a.example.
+"""
+        name = 'import-me.example'
+        with self.assertPdnsRequests(
+                self.requests_desec_domain_creation(name, axfr=False, keys=False) +
+                self.requests_desec_rr_sets_update(name) +
+                [self.request_pdns_zone_retrieve_crypto_keys(name)]
+        ):
+            response = self.client.post(self.reverse('v1:domain-list'), {'name': name, 'zonefile': zonefile})
+        self.assertResponse(response, status.HTTP_201_CREATED)
+        self.assertRRsetDB(self.other_domain, subname='inject', type_='CNAME', rr_contents=set())
+
+    def test_create_domain_zonefile_import_no_soa(self):
+        zonefile = f"""$ORIGIN .
+$TTL 43200 ; 12 hours
+import-me.example A 127.0.0.1
+import-me.example A 127.0.0.2
+import-me.example MX 10 example.com.
+"""
+        name = 'import-me.example'
+        with self.assertPdnsRequests(
+                self.requests_desec_domain_creation(name, axfr=False, keys=False) +
+                self.requests_desec_rr_sets_update(name) +
+                [self.request_pdns_zone_retrieve_crypto_keys(name)]
+        ):
+            response = self.client.post(self.reverse('v1:domain-list'), {'name': name, 'zonefile': zonefile})
+        self.assertResponse(response, status.HTTP_201_CREATED)
+        self.assertRRsetDB(Domain.objects.get(name=name), subname='', type_='MX', rr_contents={'10 example.com.'})
+
+    def test_create_domain_zonefile_import_names(self):
+        """ensures that names on the right-hand-side which are below the zone's name are handled correctly"""
+        zonefile = """example.net. 3600 MX 10 mail.example.net.
+example.net. 3600 MX 10 mail.example.org.
+example.net. 3600 PTR mail.example.net.
+example.net. 3600 PTR mail.example.org."""
+        name = 'example.net'
+        with self.assertPdnsRequests(
+                self.requests_desec_domain_creation(name, axfr=False, keys=False) +
+                self.requests_desec_rr_sets_update(name) +
+                [self.request_pdns_zone_retrieve_crypto_keys(name)]
+        ):
+            response = self.client.post(self.reverse('v1:domain-list'), {'name': name, 'zonefile': zonefile})
+        self.assertResponse(response, status.HTTP_201_CREATED)
+        self.assertRRsetDB(Domain.objects.get(name=name), subname='', type_='MX',
+                           rr_contents={'10 mail.example.net.', '10 mail.example.org.'})
+        self.assertRRsetDB(Domain.objects.get(name=name), subname='', type_='PTR',
+                           rr_contents={'mail.example.net.', 'mail.example.org.'})
+
+    def test_create_domain_zonefile_import_non_canonical(self):
+        zonefile = f"""$ORIGIN .
+$TTL 43200 ; 12 hours
+import-me.example AAAA 0000::1
+"""
+        name = 'import-me.example'
+        with self.assertPdnsRequests(
+                self.requests_desec_domain_creation(name, axfr=False, keys=False) +
+                self.requests_desec_rr_sets_update(name) +
+                [self.request_pdns_zone_retrieve_crypto_keys(name)]
+        ):
+            response = self.client.post(self.reverse('v1:domain-list'), {'name': name, 'zonefile': zonefile})
+        self.assertResponse(response, status.HTTP_201_CREATED)
+        self.assertRRsetDB(Domain.objects.get(name=name), subname='', type_='AAAA', ttl=43200, rr_contents={'::1'})
+
+    def test_create_domain_zonefile_import_validation(self):
+        zonefile = f"""$ORIGIN .
+$TTL 43200 ; 12 hours
+import-me.example MX 10 $url.
+"""
+        name = 'import-me.example'
+        response = self.client.post(self.reverse('v1:domain-list'), {'name': name, 'zonefile': zonefile})
+        self.assertResponse(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(
+            response.json(),
+            {"zonefile": ["import-me.example/MX: Cannot parse record contents: invalid exchange: \\$url."]},
+        )
+        self.assertFalse(Domain.objects.filter(name=name).exists())
+
+    def test_create_domain_zonefile_import_unsupported_type(self):
+        zonefile = f"""$ORIGIN .
+$TTL 43200 ; 12 hours
+import-me.example WKS 10.0.0.1 6 0 1 2 21 23
+"""
+        name = 'import-me.example'
+        response = self.client.post(self.reverse('v1:domain-list'), {'name': name, 'zonefile': zonefile})
+        self.assertResponse(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(
+            response.json(),
+            {"zonefile": [
+                'import-me.example/WKS: The WKS RR set type is currently unsupported.',
+            ]},
+        )
+        self.assertFalse(Domain.objects.filter(name=name).exists())
+
+    def test_create_domain_zonefile_ignore_automatically_managed_rrsets(self):
+        zonefile = f"""$ORIGIN .
+$TTL 43200 ; 12 hours
+import-me.example A 127.0.0.1
+import-me.example RRSIG A 13 2 3600 20220324000000 20220303000000 40316 @ 4wj6ZrLLLm6ZpvCh/vyqWCEkf2Krwkt8 Fi1/VJgfLMoXZSj6koOzJBMYYCiMm0JP WgQwG54fcw6YJQaOfWX1BA==
+"""
+        name = 'import-me.example'
+        with self.assertPdnsRequests(
+                self.requests_desec_domain_creation(name, axfr=False, keys=False) +
+                self.requests_desec_rr_sets_update(name) +
+                [self.request_pdns_zone_retrieve_crypto_keys(name)]
+        ):
+            response = self.client.post(self.reverse('v1:domain-list'), {'name': name, 'zonefile': zonefile})
+        self.assertResponse(response, status.HTTP_201_CREATED)
+        domain = Domain.objects.get(name=name)
+        self.assertRRsetDB(domain, subname='', type_='A', ttl=43200, rr_contents={'127.0.0.1'})
+        self.assertRRsetDB(domain, subname='', type_='RRSIG', rr_contents=set())
+
+    def test_create_domain_zonefile_empty(self):
+        name = 'import-me.example'
+        with self.assertPdnsRequests(self.requests_desec_domain_creation(name)):
+            response = self.client.post(self.reverse('v1:domain-list'), {'name': name, 'zonefile': ''})
+        self.assertResponse(response, status.HTTP_201_CREATED)
+
     def test_create_api_known_domain(self):
         url = self.reverse('v1:domain-list')
 

+ 1 - 0
docker-compose.test-e2e2.yml

@@ -69,6 +69,7 @@ services:
     - DESECSTACK_IPV6_SUBNET
     - DESECSTACK_IPV6_ADDRESS
     - DESECSTACK_NSLORD_DEFAULT_TTL
+    - DESECSTACK_MINIMUM_TTL_DEFAULT
     - DESECSTACK_NSMASTER_TSIGKEY
     # faketime setup
     - LD_PRELOAD=/lib/libfaketime.so

+ 23 - 1
docs/dns/domains.rst

@@ -36,7 +36,8 @@ A JSON object representing a domain has the following structure::
         "minimum_ttl": 3600,
         "name": "example.com",
         "published": "2018-09-18T17:21:38.348112Z",
-        "touched": "2018-09-18T17:21:38.348112Z"
+        "touched": "2018-09-18T17:21:38.348112Z",
+        "zonefile": "import-me.example A 127.0.0.1 ..."
     }
 
 Field details:
@@ -115,6 +116,27 @@ Field details:
     write operations that did not trigger publication, such as rewriting an
     RRset with identical values.
 
+``zonefile``
+    :Access mode: write-only, no read
+
+    Optionally, includes a string in zonefile format with record data to be
+    imported during domain creation.
+
+    Note that not everything given in the zonefile will be imported. Record
+    types that are :ref:`automatically managed by the deSEC API <automatic
+    types>` such as RRSIG, CDNSKEY, CDS, etc. will be silently ignored.
+    Records with names that fall outside of the domain that is created will
+    also be silently ignored.
+
+    Also, NS record at the apex and any DNSKEY records will be
+    silently ignored; instead, NS records pointing to deSEC's name servers
+    and DNSKEY records for freshly generated keys will be created.
+
+    :ref:`Record types that are not supported <unsupported types>` by the API
+    will raise an error, as will records with invalid content.
+    If an error occurs during the import of the zonefile, the domain will not
+    be created.
+
 
 Creating a Domain
 ~~~~~~~~~~~~~~~~~

+ 20 - 12
docs/dns/rrsets.rst

@@ -542,19 +542,10 @@ At least the following record types are supported: ``A``, ``AAAA``, ``AFSDB``,
 Special care needs to be taken with some types of records, as explained below.
 
 
-Restricted Types
-````````````````
+.. _`automatic types`:
 
-``ALIAS``/``ANAME``
-    Due to conflicts with the security guarantees we would like to give, we do
-    not support these record types (`detailed explanation`_).  Attempts to
-    create such records will result in a ``400 Bad Request`` response.
-
-    If you need redirect functionality at the zone apex, consider using the
-    ``HTTPS`` record type which serves exactly this purpose.  (Note that as of
-    06/2021, this record type is not yet supported in all browsers.)
-
-.. _detailed explanation: https://talk.desec.io/t/clarification-on-alias-records/113/2
+Automatically Managed Types
+```````````````````````````
 
 ``DNSKEY``, ``DS``, ``CDNSKEY``, ``CDS``, ``NSEC3PARAM``, ``RRSIG``
     These record types are meant to provide DNSSEC-related information in
@@ -586,6 +577,23 @@ Restricted Types
     it using a standard DNS query.
 
 
+.. _`unsupported types`:
+
+Unsupported Types
+`````````````````
+
+``ALIAS``/``ANAME``
+    Due to conflicts with the security guarantees we would like to give, we do
+    not support these record types (`detailed explanation`_).  Attempts to
+    create such records will result in a ``400 Bad Request`` response.
+
+    If you need redirect functionality at the zone apex, consider using the
+    ``HTTPS`` record type which serves exactly this purpose.  (Note that as of
+    06/2021, this record type is not yet supported in all browsers.)
+
+.. _detailed explanation: https://talk.desec.io/t/clarification-on-alias-records/113/2
+
+
 Caveats
 ```````
 

+ 5 - 2
test/e2e2/conftest.py

@@ -227,10 +227,13 @@ class DeSECAPIV1Client:
     def domain_list(self) -> requests.Response:
         return self.get("/domains/").json()
 
-    def domain_create(self, name) -> requests.Response:
+    def domain_create(self, name, zonefile=None) -> requests.Response:
         if name in self.domains:
             raise ValueError
-        response = self.post("/domains/", data={"name": name})
+        data = {"name": name}
+        if zonefile is not None:
+            data['zonefile'] = zonefile
+        response = self.post("/domains/", data=data)
         self.domains[name] = response.json()
         return response
 

+ 47 - 0
test/e2e2/spec/test_api_domains.py

@@ -1,8 +1,31 @@
+import os
+
 import time
 
 import pytest
 from conftest import DeSECAPIV1Client, NSLordClient, random_domainname, FaketimeShift
 
+example_zonefile = """
+@ 300 IN SOA get.desec.io. get.desec.io. 2021114126 86400 3600 2419200 3600
+@ 300 IN RRSIG SOA 13 3 300 20220324000000 20220303000000 8312 @ XcZOyVwrEMjp1RGi+5rjk82hYbpzRPIm 5Nx8H4p5wlsCSViAOE9WKIv4TC6xH44l AY4CFBbb2e3iui/bzwQnoQ==
+@ 3600 IN DNSKEY 257 3 13 q4/6eDL5bHn2hF7mbtpzGdUvIgaU2GE0 +BsPVYivqPYZrZlk/aAPpHpeUa/5giLM KhI4QPPy1uv2F6jw9RgPLw==
+@ 3600 IN RRSIG DNSKEY 13 3 3600 20220324000000 20220303000000 8312 @ 9X44LeBCpmmrO3mJp2P6GFLenAeOLxhX 1ta2ACMTVwPVaHlz3rG4dgzseTp//YHz +DJSc7P3W9cCDkg5X4Q43g==
+@ 3600 IN CDS 8312 13 2 bca8973bae3e58e697f0558ef55d3df835e6dd443c46ab5778904f186341c0d8
+@ 3600 IN CDS 8312 13 4 c5fd0f288522d0e7eeaf7ddbbbb1d956a8cd7d1eba6e6f12ebe0926ed560ccfca480f6022bacff98c1767c61281466c5
+@ 3600 IN RRSIG CDS 13 3 3600 20220324000000 20220303000000 8312 @ MIGwQf72bq55bQlGMSB5WSKV6iFoELKM 82IBLqU5kNgSHGOVhxAuGL8H/dktLgxY uQEXO0NFRIODq+8zmIovYg==
+@ 60 IN A 83.219.1.24
+@ 60 IN RRSIG A 13 3 60 20220324000000 20220303000000 8312 @ WrjVe9hYjmZNG5nysOEbAOp24DLPJ/9k xucV/5T4wXYXyzeJCxqV3DQ9B7fj6HZX zP8EJeZ9xxsqL9M6myN3vQ==
+@ 3600 IN CDNSKEY 257 3 13 q4/6eDL5bHn2hF7mbtpzGdUvIgaU2GE0 +BsPVYivqPYZrZlk/aAPpHpeUa/5giLM KhI4QPPy1uv2F6jw9RgPLw==
+@ 3600 IN RRSIG CDNSKEY 13 3 3600 20220324000000 20220303000000 8312 @ yRRuPINa9fAuwtdYL0Ggy5IuLDJMuSS1 ydc9WjnUR6uLPM0TGVOvwRk32ItoSOcJ bSfRZshxI/u27kc19eEQAw==
+@ 86400 IN NS ns1.example.
+@ 86400 IN NS ns2.example.
+@ 3600 IN RRSIG NS 13 3 3600 20220324000000 20220303000000 8312 @ mQSIpFAaOZMQpvq9DGJvXKCTuwcH+VyS HZ4EAKiXN50+w6g6+Ogik8GwmrMBG7/4 tC9mxMOIsBn/86GPR8eYzg==
+@ 3600 IN NSEC3PARAM 1 0 0 -
+@ 3600 IN RRSIG NSEC3PARAM 13 3 3600 20220324000000 20220303000000 8312 @ bePOvsK3Npl1GsKRBDtdipKIOVaz9JJX Ka/ccAHZPp8GSwDQFmyBt0l1JWJvGzT0 L+wVQMCsk/rpxrWsUanwdg==
+p6gfsf6t5tvesh74gd38o43u26q8kqes 300 IN NSEC3 1 0 0 - p6gfsf6t5tvesh74gd38o43u26q8kqes A NS SOA RRSIG DNSKEY NSEC3PARAM CDS CDNSKEY
+p6gfsf6t5tvesh74gd38o43u26q8kqes 300 IN RRSIG NSEC3 13 4 300 20220324000000 20220303000000 8312 @ b3ZfxXKLJrOGVTAqmQeEZSjbT7iYKtyM M6Wl6HilgjYTzWPvpiwpFSrETWWP5A19 wKRmT4Nh6nnbTDalUvXLsQ==
+"""
+
 
 def test_create(api_user: DeSECAPIV1Client):
     assert len(api_user.domain_list()) == 0
@@ -11,6 +34,30 @@ def test_create(api_user: DeSECAPIV1Client):
     assert NSLordClient.query(api_user.domain, 'SOA')[0].serial >= int(time.time())
 
 
+def test_create_and_import(api_user: DeSECAPIV1Client):
+    assert len(api_user.domain_list()) == 0
+    assert api_user.domain_create(random_domainname(), example_zonefile).status_code == 201
+    assert len(api_user.domain_list()) == 1
+    api_user.assert_rrsets({
+        ('', 'NS'): (
+            int(os.environ["DESECSTACK_NSLORD_DEFAULT_TTL"]),
+            {f"{name}." for name in os.environ["DESECSTACK_NS"].split(" ")}
+        ),
+        ('', 'A'): (
+            max(60, int(os.environ["DESECSTACK_MINIMUM_TTL_DEFAULT"])),
+            {'83.219.1.24'}
+        ),
+    })
+    api_user.assert_rrsets({
+        ('', 'RRSIG'): (None, None),
+        ('', 'NSEC3PARAM'): (None, None),
+        ('', 'CDS'): (None, None),
+        ('', 'DNSKEY'): (None, None),
+        ('', 'SOA'): (None, None),
+    }, via_dns=False)
+    assert NSLordClient.query(api_user.domain, 'NSEC3PARAM')[0].to_text() == '1 0 0 -'
+
+
 def test_get(api_user_domain: DeSECAPIV1Client):
     domain = api_user_domain.get(f"/domains/{api_user_domain.domain}/").json()
     assert {rr.to_text() for rr in NSLordClient.query(api_user_domain.domain, 'CDS')} == set(domain['keys'][0]['ds'])

+ 13 - 0
www/webapp/src/views/DomainList.vue

@@ -52,6 +52,19 @@ export default {
             datatype: 'TimeAgo',
             searchable: false,
           },
+          zonefile: {
+            name: 'item.zonefile',
+            text: 'Zonefile',
+            textCreate: 'Zonefile for import (paste here)',
+            align: 'left',
+            value: 'zonefile',
+            writeOnCreate: true,
+            datatype: 'GenericTextarea',
+            searchable: false,
+            fieldProps: () => ({ hint: 'Note: automatically managed records will be ignored!' }),
+            hideFromTable: true,
+            advanced: true,
+          }
         },
         actions: [
           {