Sfoglia il codice sorgente

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

Nils Wisiol 2 anni fa
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(".")
         subname, _, parent_name = self.name.partition(".")
         return subname, parent_name or None
         return subname, parent_name or None
 
 
+    @property
+    def zonefile(self):
+        return pdns.get_zonefile(self)
+
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         self.full_clean(validate_unique=False)
         self.full_clean(validate_unique=False)
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)

+ 11 - 0
api/desecapi/pdns.py

@@ -161,6 +161,17 @@ def get_zone(domain):
     return r.json()
     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):
 def get_rrset_datas(domain):
     """
     """
     Retrieves a dict representation of the RRsets in a given zone
     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_CRYPTO_KEYS = r"/zones/(?P<id>[^/]+)/cryptokeys"
     PDNS_ZONE = r"/zones/(?P<id>[^/]+)"
     PDNS_ZONE = r"/zones/(?P<id>[^/]+)"
     PDNS_ZONE_AXFR = r"/zones/(?P<id>[^/]+)/axfr-retrieve"
     PDNS_ZONE_AXFR = r"/zones/(?P<id>[^/]+)/axfr-retrieve"
+    PDNS_ZONE_EXPORT = r"/zones/(?P<id>[^/]+)/export"
 
 
     @classmethod
     @classmethod
     def get_full_pdns_url(cls, path_regex, ns="LORD", **kwargs):
     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
     @classmethod
     def request_pdns_zone_retrieve_crypto_keys(cls, name=None):
     def request_pdns_zone_retrieve_crypto_keys(cls, name=None):
         return {
         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.assertEqual(response.data["name"], self.my_domain.name)
             self.assertTrue(isinstance(response.data["keys"], list))
             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):
     def test_retrieve_other_domains(self):
         for domain in self.other_domains:
         for domain in self.other_domains:
             response = self.client.get(
             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 django.core.cache import cache
 from rest_framework import mixins, viewsets
 from rest_framework import mixins, viewsets
+from rest_framework.decorators import action
 from rest_framework.permissions import IsAuthenticated, SAFE_METHODS
 from rest_framework.permissions import IsAuthenticated, SAFE_METHODS
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.settings import api_settings
 from rest_framework.settings import api_settings
@@ -9,6 +13,7 @@ from desecapi import permissions
 from desecapi.models import Domain
 from desecapi.models import Domain
 from desecapi.pdns import get_serials
 from desecapi.pdns import get_serials
 from desecapi.pdns_change_tracker import PDNSChangeTracker
 from desecapi.pdns_change_tracker import PDNSChangeTracker
+from desecapi.renderers import PlainTextRenderer
 from desecapi.serializers import DomainSerializer
 from desecapi.serializers import DomainSerializer
 
 
 from .base import IdempotentDestroyMixin
 from .base import IdempotentDestroyMixin
@@ -31,17 +36,23 @@ class DomainViewSet(
         ret = [IsAuthenticated, permissions.IsOwner]
         ret = [IsAuthenticated, permissions.IsOwner]
         if self.action == "create":
         if self.action == "create":
             ret.append(permissions.WithinDomainLimit)
             ret.append(permissions.WithinDomainLimit)
+        if self.action == "zonefile":
+            ret.append(permissions.TokenHasDomainRRsetsPermission)
         if self.request.method not in SAFE_METHODS:
         if self.request.method not in SAFE_METHODS:
             ret.append(permissions.TokenNoDomainPolicy)
             ret.append(permissions.TokenNoDomainPolicy)
         return ret
         return ret
 
 
     @property
     @property
     def throttle_scope(self):
     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
     @property
     def pagination_class(self):
     def pagination_class(self):
@@ -52,6 +63,10 @@ class DomainViewSet(
         else:
         else:
             return api_settings.DEFAULT_PAGINATION_CLASS
             return api_settings.DEFAULT_PAGINATION_CLASS
 
 
+    @property
+    def domain(self):
+        return self.get_object()
+
     def get_queryset(self):
     def get_queryset(self):
         qs = self.request.user.domains
         qs = self.request.user.domains
 
 
@@ -86,6 +101,12 @@ class DomainViewSet(
             with PDNSChangeTracker():
             with PDNSChangeTracker():
                 parent_domain.update_delegation(instance)
                 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):
 class SerialListView(APIView):
     permission_classes = (permissions.IsVPNClient,)
     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.pdns_change_tracker import PDNSChangeTracker
 from desecapi.serializers import RRsetSerializer
 from desecapi.serializers import RRsetSerializer
 
 
-from . import IdempotentDestroyMixin
+from .base import IdempotentDestroyMixin
 
 
 
 
 class EmptyPayloadMixin:
 class EmptyPayloadMixin:
@@ -24,14 +24,7 @@ class EmptyPayloadMixin:
         return request
         return request
 
 
 
 
-class RRsetView:
-    serializer_class = RRsetSerializer
-    permission_classes = (
-        IsAuthenticated,
-        permissions.IsDomainOwner,
-        permissions.TokenHasDomainRRsetsPermission,
-    )
-
+class DomainViewMixin:
     @property
     @property
     def domain(self):
     def domain(self):
         try:
         try:
@@ -40,6 +33,15 @@ class RRsetView:
         except models.Domain.DoesNotExist:
         except models.Domain.DoesNotExist:
             raise Http404
             raise Http404
 
 
+
+class RRsetView(DomainViewMixin):
+    serializer_class = RRsetSerializer
+    permission_classes = (
+        IsAuthenticated,
+        permissions.IsDomainOwner,
+        permissions.TokenHasDomainRRsetsPermission,
+    )
+
     @property
     @property
     def throttle_scope(self):
     def throttle_scope(self):
         # noinspection PyUnresolvedReferences
         # 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
 Domain management is done through the ``/api/v1/domains/`` endpoint.  The
 following sections describe how to create, list, modify, and delete domains
 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
 All operations are subject to rate limiting.  For details, see
 :ref:`rate-limits`.
 :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.
 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:
 
 
 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).                                                              |
 | ``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   |                                                                                           |
 |                                         | 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  |                                                                                           |
 |                                         | 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')
         tsprint(f'API <<< SSL could not be verified against any verification method')
         raise exc
         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:
         if data is not None:
             data = json.dumps(data)
             data = json.dumps(data)
 
 
@@ -157,7 +157,7 @@ class DeSECAPIV1Client:
             method,
             method,
             url,
             url,
             data=data,
             data=data,
-            headers=self.headers,
+            headers=self.headers | (headers or {}),
             **kwargs,
             **kwargs,
         )
         )
 
 

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

@@ -5,6 +5,8 @@ import time
 import pytest
 import pytest
 from conftest import DeSECAPIV1Client, NSLordClient, random_domainname, FaketimeShift
 from conftest import DeSECAPIV1Client, NSLordClient, random_domainname, FaketimeShift
 
 
+DEFAULT_TTL = int(os.environ['DESECSTACK_NSLORD_DEFAULT_TTL'])
+
 example_zonefile = """
 example_zonefile = """
 @ 300 IN SOA get.desec.io. get.desec.io. 2021114126 86400 3600 2419200 3600
 @ 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==
 @ 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):
 def test_create(api_user: DeSECAPIV1Client):
     assert len(api_user.domain_list()) == 0
     assert len(api_user.domain_list()) == 0
     assert api_user.domain_create(random_domainname()).status_code == 201
     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())
     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 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
     assert len(api_user.domain_list()) == 1
     api_user.assert_rrsets({
     api_user.assert_rrsets({
         ('', 'NS'): (
         ('', 'NS'): (
-            int(os.environ["DESECSTACK_NSLORD_DEFAULT_TTL"]),
+            DEFAULT_TTL,
             {f"{name}." for name in os.environ["DESECSTACK_NS"].split(" ")}
             {f"{name}." for name in os.environ["DESECSTACK_NS"].split(" ")}
         ),
         ),
         ('', 'A'): (
         ('', 'A'): (
-            max(60, int(os.environ["DESECSTACK_MINIMUM_TTL_DEFAULT"])),
+            ttl(60),
             {'83.219.1.24'}
             {'83.219.1.24'}
         ),
         ),
     })
     })
@@ -56,6 +63,13 @@ def test_create_and_import(api_user: DeSECAPIV1Client):
         ('', 'SOA'): (None, None),
         ('', 'SOA'): (None, None),
     }, via_dns=False)
     }, via_dns=False)
     assert NSLordClient.query(api_user.domain, 'NSEC3PARAM')[0].to_text() == '1 0 0 -'
     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):
 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_destroy(name).status_code == 204
     assert api_user_domain.domain_create(name).status_code == 201
     assert api_user_domain.domain_create(name).status_code == 201
     assert NSLordClient.query(name, 'SOA')[0].serial > old_serial
     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(" ")
+               }