-
-
Notifications
You must be signed in to change notification settings - Fork 59
feat(api): initial version of check-delegation management command #816
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bdc4018
53a4f98
76503d0
7b86652
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| # Generated by Django 5.1.8 on 2025-04-12 15:02 | ||
|
|
||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ("desecapi", "0044_alter_captcha_created_alter_domain_renewal_state_and_more"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AddField( | ||
| model_name="domain", | ||
| name="delegation_status", | ||
| field=models.IntegerField( | ||
| blank=True, | ||
| choices=[ | ||
| (0, "Not Delegated"), | ||
| (1, "Elsewhere"), | ||
| (2, "Partial"), | ||
| (3, "Exclusive"), | ||
| (4, "Multi"), | ||
| (128, "Error Nxdomain"), | ||
| (129, "Error No Answer"), | ||
| (130, "Error No Nameservers"), | ||
| (131, "Error Timeout"), | ||
| ], | ||
| default=None, | ||
| null=True, | ||
| ), | ||
| ), | ||
| migrations.AddField( | ||
| model_name="domain", | ||
| name="delegation_status_changed", | ||
| field=models.DateTimeField(blank=True, default=None, null=True), | ||
| ), | ||
| migrations.AddField( | ||
| model_name="domain", | ||
| name="delegation_status_touched", | ||
| field=models.DateTimeField(blank=True, default=None, null=True), | ||
| ), | ||
| migrations.AddField( | ||
| model_name="domain", | ||
| name="security_status", | ||
| field=models.IntegerField( | ||
| blank=True, | ||
| choices=[ | ||
| (0, "Insecure"), | ||
| (1, "Foreign Keys"), | ||
| (2, "Secure Exclusive"), | ||
| (3, "Secure"), | ||
| (128, "Error Nxdomain"), | ||
| (129, "Error No Answer"), | ||
| (130, "Error No Nameservers"), | ||
| (131, "Error Timeout"), | ||
| ], | ||
| default=None, | ||
| null=True, | ||
| ), | ||
| ), | ||
| migrations.AddField( | ||
| model_name="domain", | ||
| name="security_status_changed", | ||
| field=models.DateTimeField(blank=True, default=None, null=True), | ||
| ), | ||
| migrations.AddField( | ||
| model_name="domain", | ||
| name="security_status_touched", | ||
| field=models.DateTimeField(blank=True, default=None, null=True), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,15 +1,21 @@ | ||||||
| from __future__ import annotations | ||||||
|
|
||||||
| from functools import cached_property | ||||||
|
|
||||||
| import dns | ||||||
| from functools import cache, cached_property | ||||||
| from socket import getaddrinfo | ||||||
|
|
||||||
| import dns.name | ||||||
| import dns.rdataclass | ||||||
| import dns.rdatatype | ||||||
| import dns.rdtypes | ||||||
| import dns.resolver | ||||||
| import psl_dns | ||||||
| from django.conf import settings | ||||||
| from django.contrib.auth.models import AnonymousUser | ||||||
| from django.core.exceptions import ValidationError | ||||||
| from django.db import models | ||||||
| from django.db.models import CharField, F, Manager, Q, Value | ||||||
| from django.db.models.functions import Concat, Length | ||||||
| from django.utils import timezone | ||||||
| from django_prometheus.models import ExportModelOperationsMixin | ||||||
| from dns.exception import Timeout | ||||||
| from dns.resolver import NoNameservers | ||||||
|
|
@@ -23,6 +29,13 @@ | |||||
|
|
||||||
| psl = psl_dns.PSL(resolver=settings.PSL_RESOLVER, timeout=0.5) | ||||||
|
|
||||||
| # CHECKING DISABLED general-purpose resolver for queries to the public DNS | ||||||
| resolver_CD = dns.resolver.Resolver(configure=False) | ||||||
| resolver_CD.nameservers = settings.RESOLVERS | ||||||
| resolver_CD.flags = ( | ||||||
| (resolver_CD.flags or 0) | dns.flags.CD | dns.flags.AD | dns.flags.RD | ||||||
| ) | ||||||
|
|
||||||
|
|
||||||
| class DomainManager(Manager): | ||||||
| def filter_qname(self, qname: str, **kwargs) -> models.query.QuerySet: | ||||||
|
|
@@ -52,6 +65,25 @@ class RenewalState(models.IntegerChoices): | |||||
| NOTIFIED = 2 | ||||||
| WARNED = 3 | ||||||
|
|
||||||
| class DelegationStatus(models.IntegerChoices): | ||||||
| NOT_DELEGATED = 0 | ||||||
| ELSEWHERE = 1 | ||||||
| PARTIAL = 2 | ||||||
| EXCLUSIVE = 3 | ||||||
| MULTI = 4 | ||||||
|
Comment on lines
+71
to
+73
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I supposed This suggests splitting up into |
||||||
| ERROR_NXDOMAIN = 128 | ||||||
| ERROR_NO_NAMESERVERS = 129 | ||||||
| ERROR_TIMEOUT = 130 | ||||||
|
|
||||||
| class SecurityStatus(models.IntegerChoices): | ||||||
| INSECURE = 0 | ||||||
| FOREIGN_KEYS = 1 | ||||||
| SECURE_EXCLUSIVE = 2 | ||||||
| SECURE = 3 | ||||||
| ERROR_NXDOMAIN = 128 | ||||||
| ERROR_NO_NAMESERVERS = 130 | ||||||
| ERROR_TIMEOUT = 131 | ||||||
|
|
||||||
| created = models.DateTimeField(auto_now_add=True) | ||||||
| name = models.CharField( | ||||||
| max_length=191, unique=True, validators=validate_domain_name | ||||||
|
|
@@ -63,6 +95,26 @@ class RenewalState(models.IntegerChoices): | |||||
| choices=RenewalState.choices, db_index=True, default=RenewalState.IMMORTAL | ||||||
| ) | ||||||
| renewal_changed = models.DateTimeField(auto_now_add=True) | ||||||
| delegation_status = models.IntegerField( | ||||||
| choices=DelegationStatus.choices, | ||||||
| default=None, | ||||||
| null=True, | ||||||
| blank=True, | ||||||
| ) | ||||||
| delegation_status_touched = models.DateTimeField( | ||||||
| default=None, null=True, blank=True | ||||||
| ) | ||||||
| delegation_status_changed = models.DateTimeField( | ||||||
| default=None, null=True, blank=True | ||||||
| ) | ||||||
| security_status = models.IntegerField( | ||||||
| choices=SecurityStatus.choices, | ||||||
| default=None, | ||||||
| null=True, | ||||||
| blank=True, | ||||||
| ) | ||||||
| security_status_touched = models.DateTimeField(default=None, null=True, blank=True) | ||||||
| security_status_changed = models.DateTimeField(default=None, null=True, blank=True) | ||||||
|
Comment on lines
+104
to
+117
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. agreed |
||||||
|
|
||||||
| _keys = None | ||||||
| objects = DomainManager() | ||||||
|
|
@@ -177,6 +229,176 @@ def is_registrable(self): | |||||
|
|
||||||
| return True | ||||||
|
|
||||||
| @staticmethod | ||||||
| @cache # located at object-level to start with clear cache for new objects | ||||||
| def _lookup(target) -> set[str]: | ||||||
| try: | ||||||
| addrinfo = getaddrinfo(str(target), None) | ||||||
| except OSError: | ||||||
| return set() | ||||||
| return {v[-1][0] for v in addrinfo} | ||||||
|
|
||||||
| def update_dns_delegation_status(self) -> DelegationStatus: | ||||||
| """ | ||||||
| Queries the DNS to determine the delegation status of this domian and | ||||||
| update the delegation status on record. | ||||||
|
|
||||||
| The delegation status is evaluated by trying to determine the NS records | ||||||
| as defined in the parent zone to the this `Domain`. | ||||||
|
|
||||||
| The parent zone's apex is determined by walking up the DNS tree, querying | ||||||
| NS records until an answer is provided. Once the apex and nameservers are | ||||||
| found, they are queried for the nameservers of `self.name`. | ||||||
| """ | ||||||
|
Comment on lines
+242
to
+252
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This docstring needs some proofreading :) |
||||||
| old_delegation_status = self.delegation_status | ||||||
|
|
||||||
| our_ns_addrs = { | ||||||
| rr.address | ||||||
| for name in settings.DEFAULT_NS | ||||||
| for rrtype in {"A", "AAAA"} | ||||||
| for rr in list(resolver_CD.resolve(name, rrtype)) | ||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| } | ||||||
|
|
||||||
| try: | ||||||
| # Determine the parent zone nameservers | ||||||
| ## names | ||||||
| parent_apex_candidate = dns.name.from_text(self.name).parent() | ||||||
| parent_nameservers = [] | ||||||
| while len(parent_apex_candidate) > 1: | ||||||
| parent_nameservers = resolver_CD.resolve( | ||||||
| parent_apex_candidate, dns.rdatatype.NS, raise_on_no_answer=False | ||||||
| ) | ||||||
| if parent_nameservers: | ||||||
| break | ||||||
| else: | ||||||
| parent_apex_candidate = parent_apex_candidate.parent() | ||||||
|
|
||||||
| # TODO what if no parnet_nameservers? what if len(parent_apex_candidate) == 0? | ||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If no parent nameservers, then the domain is not delegated (because the TLD does not exist, so it is unregisterable). Should we have a special status for that? (I can see arguments both ways.) Is the second questions about |
||||||
|
|
||||||
| ## addresses | ||||||
| parent_nameservers_addrs = [ | ||||||
| rr.address | ||||||
| for ns in parent_nameservers | ||||||
| for rrtype in {"A", "AAAA"} | ||||||
| for rr in list( | ||||||
| resolver_CD.resolve(ns.target, rrtype, raise_on_no_answer=False) | ||||||
| ) | ||||||
| ] # TODO duplicate with above | ||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes; perhaps make a small function called |
||||||
|
|
||||||
| # Determine this zone's nameservers as defined at parent zone nameservers | ||||||
| ## names | ||||||
| resolver = dns.resolver.Resolver(configure=False) | ||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we do this on scale, we may consider reusing this resolver across domains (i.e., pass it into the function and only overwrite the |
||||||
| resolver.nameservers = parent_nameservers_addrs | ||||||
| resolver.flags = dns.flags.AD | ||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We're talking to an auth (not a resolver), so we don't need to tell it that we understand AD bit, but chances are that we may confuse the auth. However, we should be setting the CD bit (see RFC 6840 Section 5.9).
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. flags need to be double-checked, see comment above |
||||||
| auth_ns_names = { | ||||||
| rr.target | ||||||
| for rr in resolver.resolve( | ||||||
| self.name, dns.rdatatype.NS, raise_on_no_answer=False | ||||||
| ) | ||||||
| } # FIXME does not work because NS records are located in the AUTHORITY section instead of ANSWER section | ||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You'll need a loop in which you call try:
resolver.resolve(...) # probably shouldn't set `raise_on_no_answer`
except dns.resolver.NoAnswer as e:
response = e.response()
if response:
for rr in response.authority:
if rr.rdtype == dns.rdatatype.NS and rr.rdclass == <something-IN>:
...This code is inspired by |
||||||
|
|
||||||
| ## addresses | ||||||
| auth_ns_addrs = { | ||||||
| rr.address | ||||||
| for name in auth_ns_names | ||||||
| for rrtype in {"A", "AAAA"} | ||||||
| for rr in list( | ||||||
| resolver_CD.resolve(name, rrtype, raise_on_no_answer=False) | ||||||
| ) | ||||||
| } # TODO duplicate with above | ||||||
| except dns.resolver.NXDOMAIN: | ||||||
| self.delegation_status = self.DelegationStatus.ERROR_NXDOMAIN | ||||||
| except dns.resolver.NoNameservers: | ||||||
| self.delegation_status = self.DelegationStatus.ERROR_NO_NAMESERVERS | ||||||
|
Comment on lines
+309
to
+312
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These will get raised from any of the queries that are run above. We run a lot of queries where an occasional failure would not matter; for example, if one of the parent NS can't be resolved. We should not error out of the entire processing, I guess. Not sure how to best deal with this. We could somehow stash away the exceptions and proceed, and only set the delegation error status when no result could be obtained at all.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's error out whenever there is a problem with the delegation to let the user know something needs to be fixed. |
||||||
| except dns.resolver.LifetimeTimeout: | ||||||
| self.delegation_status = self.DelegationStatus.ERROR_TIMEOUT | ||||||
| else: | ||||||
|
|
||||||
| if our_ns_addrs == auth_ns_addrs: | ||||||
| # just ours | ||||||
| self.delegation_status = self.DelegationStatus.EXCLUSIVE | ||||||
| elif our_ns_addrs < auth_ns_addrs: | ||||||
| # all of ours, and others | ||||||
| self.delegation_status = self.DelegationStatus.MULTI | ||||||
| elif our_ns_addrs & auth_ns_addrs: | ||||||
| # intersection is non-empty, but not all of our's are included | ||||||
| # some of ours, and others | ||||||
| self.delegation_status = self.DelegationStatus.PARTIAL | ||||||
| elif auth_ns_addrs: | ||||||
| # none of ours, but not empty | ||||||
| self.delegation_status = self.DelegationStatus.ELSEWHERE | ||||||
| elif auth_ns_addrs == set(): | ||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's always a set, so if it's not truthy, then it's the empty set, which means that this can simply be the |
||||||
| # empty | ||||||
| self.delegation_status = self.DelegationStatus.NOT_DELEGATED | ||||||
| elif auth_ns_addrs is None: | ||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How can this branch be reached? (
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. likely due to refactoring |
||||||
| # error | ||||||
| self.delegation_status = self.DelegationStatus | ||||||
|
Comment on lines
+333
to
+335
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this a valid value for this member? |
||||||
|
|
||||||
| print( | ||||||
| self.name, | ||||||
| list(parent_nameservers), | ||||||
| list(parent_nameservers_addrs), | ||||||
| auth_ns_names, | ||||||
| auth_ns_addrs, | ||||||
| ) | ||||||
|
|
||||||
| now = timezone.now() | ||||||
| self.delegation_status_touched = now | ||||||
| if old_delegation_status != self.delegation_status: | ||||||
| self.delegation_status_changed = now | ||||||
| return self.delegation_status | ||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this method also expected to save those attribute updates? (If not, perhaps call it
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. didn't think about it, no preference |
||||||
|
|
||||||
| def update_dns_security_status(self) -> SecurityStatus: | ||||||
| """Queries the DNS to determine the security status of this domain and | ||||||
| updates the security status on record.""" | ||||||
| old_security_status = self.security_status | ||||||
|
|
||||||
| if self.delegation_status not in [ | ||||||
| self.DelegationStatus.MULTI, | ||||||
| self.DelegationStatus.EXCLUSIVE, | ||||||
| ]: | ||||||
| self.security_status = None | ||||||
| return None | ||||||
|
Comment on lines
+356
to
+361
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should also include |
||||||
|
|
||||||
| try: | ||||||
| auth_ds = set( | ||||||
| resolver_CD.resolve( | ||||||
| self.name, dns.rdatatype.DS, raise_on_no_answer=False | ||||||
| ) | ||||||
| ) | ||||||
|
Comment on lines
+364
to
+368
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think here we should check the AD bit (because otherwise the parent is insecure, and effectively the zone is not delegated securely). This means that we probably can't use the Perhaps we should store this as an extra column (boolean
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes should add another column |
||||||
| except dns.resolver.NXDOMAIN: | ||||||
| self.security_status = self.SecurityStatus.ERROR_NXDOMAIN | ||||||
| except dns.resolver.NoNameservers: | ||||||
| self.delegation_status = self.SecurityStatus.ERROR_NO_NAMESERVERS | ||||||
| except dns.resolver.LifetimeTimeout: | ||||||
| self.delegation_status = self.SecurityStatus.ERROR_TIMEOUT | ||||||
| else: | ||||||
| auth_ds = {ds for ds in auth_ds if ds.digest_type == 2} | ||||||
|
|
||||||
| # Compute overlap of delegation DS records with ours | ||||||
| our_ds_set = { | ||||||
| dns.rdata.from_text(rdclass="IN", rdtype="DS", tok=ds) | ||||||
| for key in self.keys | ||||||
| for ds in key.get("ds", []) | ||||||
| if dns.rdata.from_text(rdclass="IN", rdtype="DS", tok=ds).digest_type | ||||||
| == 2 # only digest type 2 is mandatory | ||||||
| } | ||||||
|
|
||||||
| if our_ds_set == auth_ds: | ||||||
| self.security_status = self.SecurityStatus.SECURE_EXCLUSIVE | ||||||
| elif our_ds_set < auth_ds: | ||||||
| self.security_status = self.SecurityStatus.SECURE | ||||||
| elif auth_ds != set(): | ||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| self.security_status = self.SecurityStatus.FOREIGN_KEYS | ||||||
| else: | ||||||
| self.security_status = self.SecurityStatus.INSECURE | ||||||
|
|
||||||
| now = timezone.now() | ||||||
| self.security_status_touched = now | ||||||
| if old_security_status != self.security_status: | ||||||
| self.security_status_changed = now | ||||||
| return self.security_status | ||||||
|
|
||||||
| @property | ||||||
| def keys(self): | ||||||
| if not self._keys: | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have not succeeded in getting an AD bit in a response when querying with CD, even when also querying with AD. (See RFC 6840 Section 5.7-5.9, and also my comment below in
update_dns_security_status().)As a result, I'm not sure if this is a meaningful combination of query flags.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Flags should instruct resolve to answer with DNSSEC records but omit checking