Browse Source

feat(api): allow export of domains in zonefile format

Nils Wisiol 2 years ago
parent
commit
bcfc3749ea

+ 4 - 0
api/desecapi/models/domains.py

@@ -239,6 +239,10 @@ class Domain(ExportModelOperationsMixin("Domain"), models.Model):
         subname, _, parent_name = self.name.partition(".")
         return subname, parent_name or None
 
+    @property
+    def zonefile(self):
+        return pdns.get_zonefile(self)
+
     def save(self, *args, **kwargs):
         self.full_clean(validate_unique=False)
         super().save(*args, **kwargs)

+ 11 - 0
api/desecapi/pdns.py

@@ -161,6 +161,17 @@ def get_zone(domain):
     return r.json()
 
 
+def get_zonefile(domain) -> bin:
+    """
+    Retrieves the zonefile (presentation format) of a given zone as binary string
+    """
+    r = _pdns_get(
+        NSLORD, "/zones/" + pdns_id(domain.name) + "/export", accept="text/dns"
+    )
+
+    return r.content
+
+
 def get_rrset_datas(domain):
     """
     Retrieves a dict representation of the RRsets in a given zone

+ 12 - 0
api/desecapi/tests/base.py

@@ -301,6 +301,7 @@ class MockPDNSTestCase(APITestCase):
     PDNS_ZONE_CRYPTO_KEYS = r"/zones/(?P<id>[^/]+)/cryptokeys"
     PDNS_ZONE = r"/zones/(?P<id>[^/]+)"
     PDNS_ZONE_AXFR = r"/zones/(?P<id>[^/]+)/axfr-retrieve"
+    PDNS_ZONE_EXPORT = r"/zones/(?P<id>[^/]+)/export"
 
     @classmethod
     def get_full_pdns_url(cls, path_regex, ns="LORD", **kwargs):
@@ -559,6 +560,17 @@ class MockPDNSTestCase(APITestCase):
             },
         ]
 
+    @classmethod
+    def request_pdns_zone_retrieve_zone_export(cls, name=None):
+        return {
+            "method": "GET",
+            "uri": cls.get_full_pdns_url(
+                cls.PDNS_ZONE_EXPORT, id=cls._pdns_zone_id_heuristic(name)
+            ),
+            "status": 200,
+            "body": "Zone export dummy!",
+        }
+
     @classmethod
     def request_pdns_zone_retrieve_crypto_keys(cls, name=None):
         return {

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

@@ -301,6 +301,19 @@ class DomainOwnerTestCase1(DomainOwnerTestCase):
             self.assertEqual(response.data["name"], self.my_domain.name)
             self.assertTrue(isinstance(response.data["keys"], list))
 
+    def test_zonefile_my_domain(self):
+        url = self.reverse("v1:domain-detail", name=self.my_domain.name) + "zonefile/"
+        with self.assertPdnsRequests(
+            self.request_pdns_zone_retrieve_zone_export(name=self.my_domain.name)
+        ):
+            response = self.client.get(url)
+            self.assertStatus(response, status.HTTP_200_OK)
+            prefix, data = response.data.split(b"\n", 1)
+            self.assertTrue(
+                prefix.startswith(b"; Zonefile for " + self.my_domain.name.encode())
+            )
+            self.assertEqual(data, b"Zone export dummy!")
+
     def test_retrieve_other_domains(self):
         for domain in self.other_domains:
             response = self.client.get(

+ 26 - 5
api/desecapi/views/domains.py

@@ -1,5 +1,9 @@
+from datetime import timezone, datetime
+
+from django.conf import settings
 from django.core.cache import cache
 from rest_framework import mixins, viewsets
+from rest_framework.decorators import action
 from rest_framework.permissions import IsAuthenticated, SAFE_METHODS
 from rest_framework.response import Response
 from rest_framework.settings import api_settings
@@ -9,6 +13,7 @@ from desecapi import permissions
 from desecapi.models import Domain
 from desecapi.pdns import get_serials
 from desecapi.pdns_change_tracker import PDNSChangeTracker
+from desecapi.renderers import PlainTextRenderer
 from desecapi.serializers import DomainSerializer
 
 from .base import IdempotentDestroyMixin
@@ -31,17 +36,23 @@ class DomainViewSet(
         ret = [IsAuthenticated, permissions.IsOwner]
         if self.action == "create":
             ret.append(permissions.WithinDomainLimit)
+        if self.action == "zonefile":
+            ret.append(permissions.TokenHasDomainRRsetsPermission)
         if self.request.method not in SAFE_METHODS:
             ret.append(permissions.TokenNoDomainPolicy)
         return ret
 
     @property
     def throttle_scope(self):
-        return (
-            "dns_api_cheap"
-            if self.request.method in SAFE_METHODS
-            else "dns_api_expensive"
-        )
+        if self.action == "zonefile":
+            self.throttle_scope_bucket = self.kwargs["name"]
+            return "dns_api_per_domain_expensive"
+        else:
+            return (
+                "dns_api_cheap"
+                if self.request.method in SAFE_METHODS
+                else "dns_api_expensive"
+            )
 
     @property
     def pagination_class(self):
@@ -52,6 +63,10 @@ class DomainViewSet(
         else:
             return api_settings.DEFAULT_PAGINATION_CLASS
 
+    @property
+    def domain(self):
+        return self.get_object()
+
     def get_queryset(self):
         qs = self.request.user.domains
 
@@ -86,6 +101,12 @@ class DomainViewSet(
             with PDNSChangeTracker():
                 parent_domain.update_delegation(instance)
 
+    @action(detail=True, renderer_classes=[PlainTextRenderer])
+    def zonefile(self, request, name=None):
+        instance = self.get_object()
+        prefix = f"; Zonefile for {instance.name} exported from desec.{settings.DESECSTACK_DOMAIN} at {datetime.now(timezone.utc)}\n".encode()
+        return Response(prefix + instance.zonefile, content_type="text/dns")
+
 
 class SerialListView(APIView):
     permission_classes = (permissions.IsVPNClient,)

+ 11 - 9
api/desecapi/views/records.py

@@ -7,7 +7,7 @@ from desecapi import models, permissions
 from desecapi.pdns_change_tracker import PDNSChangeTracker
 from desecapi.serializers import RRsetSerializer
 
-from . import IdempotentDestroyMixin
+from .base import IdempotentDestroyMixin
 
 
 class EmptyPayloadMixin:
@@ -24,14 +24,7 @@ class EmptyPayloadMixin:
         return request
 
 
-class RRsetView:
-    serializer_class = RRsetSerializer
-    permission_classes = (
-        IsAuthenticated,
-        permissions.IsDomainOwner,
-        permissions.TokenHasDomainRRsetsPermission,
-    )
-
+class DomainViewMixin:
     @property
     def domain(self):
         try:
@@ -40,6 +33,15 @@ class RRsetView:
         except models.Domain.DoesNotExist:
             raise Http404
 
+
+class RRsetView(DomainViewMixin):
+    serializer_class = RRsetSerializer
+    permission_classes = (
+        IsAuthenticated,
+        permissions.IsDomainOwner,
+        permissions.TokenHasDomainRRsetsPermission,
+    )
+
     @property
     def throttle_scope(self):
         # noinspection PyUnresolvedReferences

+ 19 - 1
docs/dns/domains.rst

@@ -5,7 +5,7 @@ Domain Management
 
 Domain management is done through the ``/api/v1/domains/`` endpoint.  The
 following sections describe how to create, list, modify, and delete domains
-using JSON objects.
+using JSON objects and how to export domain data in zonefile format.
 
 All operations are subject to rate limiting.  For details, see
 :ref:`rate-limits`.
@@ -257,6 +257,24 @@ and ``subname`` would just be ``_acme-challenge``.
 The above API request helps you answer this kind of question.
 
 
+Exporting a Domain as Zonefile
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To export domain data in zonefile format, send a ``GET`` request to the
+``zonefile`` endpoint of this domain, i.e. to ``/domains/{name}/zonefile``::
+
+    curl -X GET https://desec.io/api/v1/domains/{name}/zonefile \
+        --header "Authorization: Token {secret}"
+
+Note that this will return a plain-text zonefile format without JSON formatting
+that includes all domain data except for DNSSEC-specific record types, e.g.::
+
+    ; Zonefile for example.com exported from desec.io at 2022-08-26 16:03:18.258961+00:00
+    example.com.	1234	IN	NS	ns1.example.com.
+    example.com.	1234	IN	NS	ns2.example.com.
+    example.com.	300	IN	SOA	get.desec.io. get.desec.io. 2022082602 86400 3600 2419200 3600
+
+
 .. _deleting-a-domain:
 
 Deleting a Domain

+ 2 - 2
docs/rate-limits.rst

@@ -33,11 +33,11 @@ the API.  When several rates are given, all are enforced at the same time.
 +-----------------------------------------+----------+-------------------------------------------------------------------------------------------+
 | ``dyndns``                              | 1/min    | dynDNS updates (per domain).                                                              |
 +-----------------------------------------+----------+-------------------------------------------------------------------------------------------+
-| ``dns_api_cheap``                       | 10/s     | DNS read operations (e.g. fetching an RRset)                                              |
+| ``dns_api_cheap``                       | 10/s     | DNS read operations (e.g. fetching an RRset), except zonefile export                      |
 |                                         |          |                                                                                           |
 |                                         | 50/min   |                                                                                           |
 +-----------------------------------------+----------+-------------------------------------------------------------------------------------------+
-| ``dns_api_expensive``                   | 10/s     | DNS write operations: domain creation/deletion                                            |
+| ``dns_api_expensive``                   | 10/s     | Domain creation/deletion, zonefile export                                                 |
 |                                         |          |                                                                                           |
 |                                         | 300/min  |                                                                                           |
 |                                         |          |                                                                                           |

+ 2 - 2
test/e2e2/conftest.py

@@ -143,7 +143,7 @@ class DeSECAPIV1Client:
         tsprint(f'API <<< SSL could not be verified against any verification method')
         raise exc
 
-    def _request(self, method: str, *, path: str, data: Optional[dict] = None, **kwargs) -> requests.Response:
+    def _request(self, method: str, *, path: str, data: Optional[dict] = None, headers: Optional[dict] = None, **kwargs) -> requests.Response:
         if data is not None:
             data = json.dumps(data)
 
@@ -157,7 +157,7 @@ class DeSECAPIV1Client:
             method,
             url,
             data=data,
-            headers=self.headers,
+            headers=self.headers | (headers or {}),
             **kwargs,
         )
 

+ 29 - 4
test/e2e2/spec/test_api_domains.py

@@ -5,6 +5,8 @@ import time
 import pytest
 from conftest import DeSECAPIV1Client, NSLordClient, random_domainname, FaketimeShift
 
+DEFAULT_TTL = int(os.environ['DESECSTACK_NSLORD_DEFAULT_TTL'])
+
 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==
@@ -27,6 +29,10 @@ p6gfsf6t5tvesh74gd38o43u26q8kqes 300 IN RRSIG NSEC3 13 4 300 20220324000000 2022
 """
 
 
+def ttl(value, min_ttl=int(os.environ['DESECSTACK_MINIMUM_TTL_DEFAULT'])):
+    return max(min_ttl, min(86400, value))
+
+
 def test_create(api_user: DeSECAPIV1Client):
     assert len(api_user.domain_list()) == 0
     assert api_user.domain_create(random_domainname()).status_code == 201
@@ -34,17 +40,18 @@ 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):
+def test_create_import_export(api_user: DeSECAPIV1Client):
     assert len(api_user.domain_list()) == 0
-    assert api_user.domain_create(random_domainname(), example_zonefile).status_code == 201
+    domainname = random_domainname()
+    assert api_user.domain_create(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"]),
+            DEFAULT_TTL,
             {f"{name}." for name in os.environ["DESECSTACK_NS"].split(" ")}
         ),
         ('', 'A'): (
-            max(60, int(os.environ["DESECSTACK_MINIMUM_TTL_DEFAULT"])),
+            ttl(60),
             {'83.219.1.24'}
         ),
     })
@@ -56,6 +63,13 @@ def test_create_and_import(api_user: DeSECAPIV1Client):
         ('', 'SOA'): (None, None),
     }, via_dns=False)
     assert NSLordClient.query(api_user.domain, 'NSEC3PARAM')[0].to_text() == '1 0 0 -'
+    _, zonefile = api_user.get(f"/domains/{api_user.domain}/zonefile").content.decode().split("\n", 1)
+    assert {l.strip() for l in zonefile.strip().split('\n') if 'SOA' not in l} == \
+           {f"{domainname}.	{ttl(60)}	IN	A	83.219.1.24"} | \
+           {
+                f"{domainname}.	{DEFAULT_TTL}	IN	NS	{name}."
+                for name in os.environ["DESECSTACK_NS"].split(" ")
+           }
 
 
 def test_get(api_user_domain: DeSECAPIV1Client):
@@ -89,3 +103,14 @@ def test_recreate(api_user_domain: DeSECAPIV1Client):
     assert api_user_domain.domain_destroy(name).status_code == 204
     assert api_user_domain.domain_create(name).status_code == 201
     assert NSLordClient.query(name, 'SOA')[0].serial > old_serial
+
+
+def test_export(api_user_domain: DeSECAPIV1Client):
+    """Check export of fresh domain (only contains NS and SOA RRs)"""
+    for content_type in ['text/dns', 'application/json']:
+        _, zonefile = api_user_domain.get(f"/domains/{api_user_domain.domain}/zonefile", headers={'Accept': content_type}).content.decode().split("\n", 1)
+        assert {l.strip() for l in zonefile.strip().split('\n') if 'SOA' not in l} == \
+               {
+                    f"{api_user_domain.domain}.	{DEFAULT_TTL}	IN	NS	{name}."
+                    for name in os.environ["DESECSTACK_NS"].split(" ")
+               }