浏览代码

feat(api): add management command to handle inactive domain purge

Peter Thomassen 5 年之前
父节点
当前提交
bc0b4de7dc

+ 109 - 0
api/desecapi/management/commands/scavenge-unused.py

@@ -0,0 +1,109 @@
+import datetime
+from functools import reduce
+import operator
+
+from django.conf import settings
+from django.core.mail import get_connection, mail_admins
+from django.core.management import BaseCommand
+from django.db import transaction
+from django.db.models import F, Q
+from django.test import RequestFactory
+from django.utils import timezone
+from rest_framework.reverse import reverse
+
+from desecapi import models, serializers, views
+from desecapi.pdns_change_tracker import PDNSChangeTracker
+
+
+fresh_days = 183
+notice_days_notify = 28
+notice_days_warn = 7
+
+
+class Command(BaseCommand):
+    base_queryset = models.Domain.objects.filter(reduce(
+        operator.or_,
+        (Q(name__endswith=f'.{lps}') for lps in settings.LOCAL_PUBLIC_SUFFIXES),
+        Q(pk__in=[])  # always-false default
+    ))
+
+    @classmethod
+    def renew_touched_domains(cls):
+        last_published_threshold = timezone.localdate() - datetime.timedelta(days=183)
+        recently_touched_domains = cls.base_queryset.filter(published__date__gte=last_published_threshold,
+                                                            renewal_changed__lt=F('published'))
+
+        print('Renewing domains:', *recently_touched_domains.values_list('name', flat=True))
+        recently_touched_domains.update(renewal_state=models.Domain.RenewalState.FRESH, renewal_changed=F('published'))
+
+    @classmethod
+    def warn_domain_deletion(cls, renewal_state, notice_days, inactive_days):
+        def confirmation_link(domain_name, user):
+            action = models.AuthenticatedRenewDomainBasicUserAction(domain=domain_name, user=user)
+            verification_code = serializers.AuthenticatedRenewDomainBasicUserActionSerializer(action).data['code']
+            request = RequestFactory().generic('', '', secure=True, HTTP_HOST=f'desec.{settings.DESECSTACK_DOMAIN}')
+            return reverse('v1:confirm-renew-domain', request=request, args=[verification_code])
+
+        # We act when `renewal_changed` is at least this date (or older)
+        inactive_threshold = timezone.localdate() - datetime.timedelta(days=inactive_days)
+        # Filter candidates which have the state of interest, at least since the calculated date
+        expiry_candidates = cls.base_queryset.filter(renewal_state=renewal_state,
+                                                     renewal_changed__date__lte=inactive_threshold)
+
+        # Group domains by user, so that we can send one message per user
+        domain_user_map = {}
+        for domain in expiry_candidates.order_by('name'):
+            if domain.owner not in domain_user_map:
+                domain_user_map[domain.owner] = []
+            domain_user_map[domain.owner].append(domain)
+
+        # Prepare and send emails, and keep renewal status in sync
+        deletion_date = timezone.localdate() + datetime.timedelta(days=notice_days)
+        for user, domains in domain_user_map.items():
+            with transaction.atomic():
+                # Update renewal status of the user's affected domains, but don't commit before sending the email
+                for domain in domains:
+                    domain.renewal_state += 1
+                    domain.renewal_changed = timezone.now()
+                    domain.save(update_fields=['renewal_state', 'renewal_changed'])
+                links = [{'name': domain, 'confirmation_link': confirmation_link(domain, user)} for domain in domains]
+                user.send_email('renew-domain', context={'domains': links, 'deletion_date': deletion_date})
+
+    @classmethod
+    def delete_domains(cls, inactive_days):
+        published_threshold = timezone.localdate() - datetime.timedelta(days=inactive_days)
+        renewal_changed_threshold = timezone.localdate() - datetime.timedelta(days=notice_days_warn)
+        expiry_candidates = cls.base_queryset.filter(renewal_state=models.Domain.RenewalState.WARNED)
+        domains = expiry_candidates.filter(renewal_changed__date__lte=renewal_changed_threshold,
+                                           published__date__lte=published_threshold)
+        for domain in domains:
+            with PDNSChangeTracker():
+                domain.delete()
+            if not domain.owner.domains.exists():
+                domain.owner.delete()
+        # Do one large delegation update
+        with PDNSChangeTracker():
+            for domain in domains:
+                views.DomainViewSet.auto_delegate(domain)
+
+    def handle(self, *args, **kwargs):
+        try:
+            # Reset renewal status for domains that have recently been touched
+            self.renew_touched_domains()
+
+            # Announce domain deletion in `notice_days_notice` days if not yet notified (FRESH) and inactive for
+            # `inactive_days` days. Updates status from FRESH to NOTIFIED.
+            self.warn_domain_deletion(models.Domain.RenewalState.FRESH, notice_days_notify, fresh_days)
+
+            # After `notice_days_notify - notice_days_warn` more days, warn again if the status has not changed
+            # Updates status from NOTIFIED to WARNED.
+            self.warn_domain_deletion(models.Domain.RenewalState.NOTIFIED, notice_days_warn,
+                                      notice_days_notify - notice_days_warn)
+
+            # Finally, delete domains inactive for `inactive_days + notice_days_notify` days if status has not changed
+            self.delete_domains(fresh_days + notice_days_notify)
+        except Exception as e:
+            subject = 'Renewal Exception!'
+            message = f'{type(e)}\n\n{str(e)}'
+            print(f'Chores exception: {type(e)}, {str(e)}')
+            mail_admins(subject, message, connection=get_connection('django.core.mail.backends.smtp.EmailBackend'))

+ 1 - 0
api/desecapi/models.py

@@ -156,6 +156,7 @@ class User(ExportModelOperationsMixin('User'), AbstractBaseUser):
             'reset-password': fast_lane,
             'reset-password': fast_lane,
             'delete-user': fast_lane,
             'delete-user': fast_lane,
             'domain-dyndns': fast_lane,
             'domain-dyndns': fast_lane,
+            'renew-domain': immediate_lane,
         }
         }
         if reason not in lanes:
         if reason not in lanes:
             raise ValueError(f'Cannot send email to user {self.pk} without a good reason: {reason}')
             raise ValueError(f'Cannot send email to user {self.pk} without a good reason: {reason}')

+ 32 - 0
api/desecapi/templates/emails/renew-domain/content.txt

@@ -0,0 +1,32 @@
+Hi there,
+
+You are the owner of the following domain name(s):
+
+{% for domain in domains %}
+  * {{ domain.name }}
+{% endfor %}
+
+We noticed that the DNS information of the above domain(s) have not
+received any updates for more than 6 months.
+
+As explained in our Terms and Conditions (https://desec.io/terms),
+we regularly purge inactive domains (as indicated by the absence of
+DNS updates). This allows us to free up resources for active deSEC
+users.
+
+The above domain name(s) are scheduled for deletion on
+{{ deletion_date|date:"F j, Y" }}. If there are no other domains in
+your account, we will also delete your account on this date.
+
+To retain your domain name (and account), either change a DNS record
+before that date, or click the following link(s) (valid for {{ link_expiration_hours }} hours):
+
+{% for domain in domains %}
+  * {{ domain.name }}
+    {{ domain.confirmation_link }}
+{% endfor %}
+
+In case you have questions, feel free to contact us!
+
+Stay secure,
+The deSEC Team

+ 1 - 0
api/desecapi/templates/emails/renew-domain/subject.txt

@@ -0,0 +1 @@
+[deSEC] Upcoming domain deletion

+ 140 - 5
api/desecapi/tests/test_user_management.py

@@ -11,7 +11,10 @@ This involves testing five separate endpoints:
 (3) Change email address endpoint,
 (3) Change email address endpoint,
 (4) delete user endpoint, and
 (4) delete user endpoint, and
 (5) verify endpoint.
 (5) verify endpoint.
+
+Furthermore, domain renewals and unused domain/account scavenging are tested.
 """
 """
+from datetime import timedelta
 import random
 import random
 import re
 import re
 import time
 import time
@@ -20,7 +23,9 @@ from urllib.parse import urlparse
 
 
 from django.contrib.auth.hashers import is_password_usable
 from django.contrib.auth.hashers import is_password_usable
 from django.core import mail
 from django.core import mail
+from django.core.management import call_command
 from django.urls import resolve
 from django.urls import resolve
+from django.utils import timezone
 from rest_framework import status
 from rest_framework import status
 from rest_framework.reverse import reverse
 from rest_framework.reverse import reverse
 from rest_framework.test import APIClient
 from rest_framework.test import APIClient
@@ -28,7 +33,7 @@ from rest_framework.test import APIClient
 from api import settings
 from api import settings
 from desecapi.models import Domain, User, Captcha
 from desecapi.models import Domain, User, Captcha
 from desecapi.serializers import AuthenticatedActionSerializer
 from desecapi.serializers import AuthenticatedActionSerializer
-from desecapi.tests.base import DesecTestCase, PublicSuffixMockMixin
+from desecapi.tests.base import DesecTestCase, DomainOwnerTestCase, PublicSuffixMockMixin
 
 
 
 
 class UserManagementClient(APIClient):
 class UserManagementClient(APIClient):
@@ -144,7 +149,7 @@ class UserManagementTestCase(DesecTestCase, PublicSuffixMockMixin):
         self.assertFalse(mail.outbox, "Expected no email to be sent, but %i were sent. First subject line is '%s'." %
         self.assertFalse(mail.outbox, "Expected no email to be sent, but %i were sent. First subject line is '%s'." %
                          (len(mail.outbox), mail.outbox[0].subject if mail.outbox else '<n/a>'))
                          (len(mail.outbox), mail.outbox[0].subject if mail.outbox else '<n/a>'))
 
 
-    def assertEmailSent(self, subject_contains='', body_contains='', recipient=None, reset=True, pattern=None):
+    def assertEmailSent(self, subject_contains='', body_contains=None, recipient=None, reset=True, pattern=None):
         total = 1
         total = 1
         self.assertEqual(len(mail.outbox), total, "Expected %i message in the outbox, but found %i." %
         self.assertEqual(len(mail.outbox), total, "Expected %i message in the outbox, but found %i." %
                          (total, len(mail.outbox)))
                          (total, len(mail.outbox)))
@@ -152,9 +157,9 @@ class UserManagementTestCase(DesecTestCase, PublicSuffixMockMixin):
         self.assertTrue(subject_contains in email.subject,
         self.assertTrue(subject_contains in email.subject,
                         "Expected '%s' in the email subject, but found '%s'" %
                         "Expected '%s' in the email subject, but found '%s'" %
                         (subject_contains, email.subject))
                         (subject_contains, email.subject))
-        self.assertTrue(body_contains in email.body,
-                        "Expected '%s' in the email body, but found '%s'" %
-                        (body_contains, email.body))
+        if type(body_contains) != list: body_contains = [] if body_contains is None else [body_contains]
+        for elem in body_contains:
+            self.assertTrue(elem in email.body, f"Expected '{elem}' in the email body, but found '{email.body}'")
         if recipient is not None:
         if recipient is not None:
             if isinstance(recipient, list):
             if isinstance(recipient, list):
                 self.assertListEqual(recipient, email.recipients())
                 self.assertListEqual(recipient, email.recipients())
@@ -870,3 +875,133 @@ class HasUserAccountTestCase(UserManagementTestCase):
         self.assertVerificationFailureInvalidCodeResponse(self.client.verify(delete_link))
         self.assertVerificationFailureInvalidCodeResponse(self.client.verify(delete_link))
         self.assertVerificationFailureInvalidCodeResponse(self.client.verify(reset_password_link,
         self.assertVerificationFailureInvalidCodeResponse(self.client.verify(reset_password_link,
                                                                              data={'new_password': 'dummy'}))
                                                                              data={'new_password': 'dummy'}))
+
+
+class RenewTestCase(UserManagementTestCase, DomainOwnerTestCase):
+    DYN = True
+
+    def setUp(self):
+        super().setUp()
+        self.email, self.password = self._test_registration(password=self.random_password())
+
+    def assertRenewDomainEmail(self, recipient, body_contains, pattern, reset=True):
+        return self.assertEmailSent(
+            subject_contains='Upcoming domain deletion',
+            body_contains=body_contains,
+            recipient=[recipient],
+            reset=reset,
+            pattern=pattern,
+        )
+
+    def assertRenewDomainVerificationSuccessResponse(self, response):
+        return self.assertContains(
+            response=response,
+            text='We recorded that your domain ',
+            status_code=status.HTTP_200_OK
+        )
+
+    def test_renew_domain_non_local_public_child(self):
+        user = User.objects.get(email=self.email)
+        domain = self.create_domain(owner=user)
+        for days in [182, 184]:
+            domain.published = timezone.now() - timedelta(days=days)
+            domain.renewal_changed = domain.published
+            domain.save()
+
+            self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, Domain.RenewalState.FRESH)
+            call_command('scavenge-unused')
+            self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, Domain.RenewalState.FRESH)
+            self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_changed, domain.renewal_changed)
+            self.assertEqual(Domain.objects.get(pk=domain.pk).published, domain.published)
+            self.assertEqual(len(mail.outbox), 0)
+
+    def test_renew_domain_recently_published(self):
+        domain = self.my_domains[0]
+        for days in [5, 182, 184]:
+            domain.published = timezone.now() - timedelta(days=1)
+            domain.renewal_changed = timezone.now() - timedelta(days=days)
+            for renewal_state in [Domain.RenewalState.FRESH, Domain.RenewalState.NOTIFIED, Domain.RenewalState.WARNED]:
+                domain.renewal_state = renewal_state
+                domain.save()
+
+                self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, domain.renewal_state)
+                call_command('scavenge-unused')
+                self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, Domain.RenewalState.FRESH)
+                self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_changed, domain.published)
+                self.assertEqual(Domain.objects.get(pk=domain.pk).published, domain.published)
+                self.assertEqual(len(mail.outbox), 0)
+
+    def test_renew_domain_fresh_182_days(self):
+        domain = self.my_domains[0]
+        domain.published = timezone.now() - timedelta(days=182)
+        domain.renewal_changed = domain.published
+        domain.save()
+
+        self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, Domain.RenewalState.FRESH)
+        call_command('scavenge-unused')
+        self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, Domain.RenewalState.FRESH)
+        self.assertEqual(len(mail.outbox), 0)
+
+    def test_renew_domain_fresh_183_days(self):
+        domain = self.my_domains[0]
+        domain.published = timezone.now() - timedelta(days=183)
+        domain.renewal_changed = domain.published
+        domain.save()
+
+        self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, Domain.RenewalState.FRESH)
+        call_command('scavenge-unused')
+        self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, Domain.RenewalState.NOTIFIED)
+
+        deletion_date = timezone.localdate() + timedelta(days=28)
+        body_contains = [domain.name, deletion_date.strftime('%B %-d, %Y')]
+        pattern = r'following link[^:]*:\s+\* ' + domain.name.replace('.', r'\.') + r'\s+([^\s]*)'
+        confirmation_link = self.assertRenewDomainEmail(domain.owner.email, body_contains, pattern)
+        self.assertConfirmationLinkRedirect(confirmation_link)
+        self.assertRenewDomainVerificationSuccessResponse(self.client.verify(confirmation_link))
+        self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, Domain.RenewalState.FRESH)
+        self.assertLess(timezone.now() - Domain.objects.get(pk=domain.pk).renewal_changed, timedelta(seconds=1))
+
+        for domain in self.my_domains[1:]:
+            self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, Domain.RenewalState.FRESH)
+            self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_changed, domain.renewal_changed)
+
+    def test_renew_domain_notified_21_days(self):
+        domain = self.my_domains[0]
+        domain.published = timezone.now() - timedelta(days=183+21)
+        domain.renewal_state = Domain.RenewalState.NOTIFIED
+        domain.renewal_changed = timezone.now() - timedelta(days=21)
+        domain.save()
+
+        call_command('scavenge-unused')
+        self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, Domain.RenewalState.WARNED)
+
+        deletion_date = timezone.localdate() + timedelta(days=7)
+        body_contains = [domain.name, deletion_date.strftime('%B %-d, %Y')]
+        pattern = r'following link[^:]*:\s+\* ' + domain.name.replace('.', r'\.') + r'\s+([^\s]*)'
+        confirmation_link = self.assertRenewDomainEmail(domain.owner.email, body_contains, pattern)
+        self.assertConfirmationLinkRedirect(confirmation_link)
+        self.assertRenewDomainVerificationSuccessResponse(self.client.verify(confirmation_link))
+        self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, Domain.RenewalState.FRESH)
+        self.assertLess(timezone.now() - Domain.objects.get(pk=domain.pk).renewal_changed, timedelta(seconds=1))
+
+        for domain in self.my_domains[1:]:
+            self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, Domain.RenewalState.FRESH)
+            self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_changed, domain.renewal_changed)
+
+    def test_renew_domain_warned_7_days(self):
+        while self.my_domains:
+            domain = self.my_domains.pop()
+            domain.published = timezone.now() - timedelta(days=183+28)
+            domain.renewal_state = Domain.RenewalState.WARNED
+            domain.renewal_changed = timezone.now() - timedelta(days=7)
+            domain.save()
+
+            with self.assertPdnsRequests(self.requests_desec_domain_deletion_auto_delegation(name=domain.name)):
+                 call_command('scavenge-unused')
+            self.assertFalse(Domain.objects.filter(pk=domain.pk).exists())
+
+            # User gets deleted when last domain is purged
+            self.assertEqual(User.objects.filter(pk=self.owner.pk).exists(), bool(self.my_domains))
+
+            for domain in self.my_domains:
+                self.assertEqual(Domain.objects.get(pk=domain.pk).renewal_state, Domain.RenewalState.FRESH)