Skip to content

Commit 76503d0

Browse files
committed
wip: feat(api): extend domain to query delegation and security status
1 parent 53a4f98 commit 76503d0

File tree

5 files changed

+440
-4
lines changed

5 files changed

+440
-4
lines changed

api/api/settings.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,14 @@
160160
]
161161

162162
DESECSTACK_DOMAIN = os.environ["DESECSTACK_DOMAIN"]
163+
RESOLVERS = [
164+
"9.9.9.9",
165+
"2620:fe::fe", # Quad9
166+
"1.1.1.1",
167+
"2606:4700:4700::1111", # Cloudflare
168+
"8.8.8.8",
169+
"2001:4860:4860::8888", # Google
170+
]
163171

164172
# default NS records
165173
DEFAULT_NS = [name + "." for name in os.environ["DESECSTACK_NS"].strip().split()]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Generated by Django 5.1.8 on 2025-04-12 15:02
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("desecapi", "0044_alter_captcha_created_alter_domain_renewal_state_and_more"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="domain",
15+
name="delegation_status",
16+
field=models.IntegerField(
17+
blank=True,
18+
choices=[
19+
(0, "Not Delegated"),
20+
(1, "Elsewhere"),
21+
(2, "Partial"),
22+
(3, "Exclusive"),
23+
(4, "Multi"),
24+
(128, "Error Nxdomain"),
25+
(129, "Error No Answer"),
26+
(130, "Error No Nameservers"),
27+
(131, "Error Timeout"),
28+
],
29+
default=None,
30+
null=True,
31+
),
32+
),
33+
migrations.AddField(
34+
model_name="domain",
35+
name="delegation_status_changed",
36+
field=models.DateTimeField(blank=True, default=None, null=True),
37+
),
38+
migrations.AddField(
39+
model_name="domain",
40+
name="delegation_status_touched",
41+
field=models.DateTimeField(blank=True, default=None, null=True),
42+
),
43+
migrations.AddField(
44+
model_name="domain",
45+
name="security_status",
46+
field=models.IntegerField(
47+
blank=True,
48+
choices=[
49+
(0, "Insecure"),
50+
(1, "Foreign Keys"),
51+
(2, "Secure Exclusive"),
52+
(3, "Secure"),
53+
(128, "Error Nxdomain"),
54+
(129, "Error No Answer"),
55+
(130, "Error No Nameservers"),
56+
(131, "Error Timeout"),
57+
],
58+
default=None,
59+
null=True,
60+
),
61+
),
62+
migrations.AddField(
63+
model_name="domain",
64+
name="security_status_changed",
65+
field=models.DateTimeField(blank=True, default=None, null=True),
66+
),
67+
migrations.AddField(
68+
model_name="domain",
69+
name="security_status_touched",
70+
field=models.DateTimeField(blank=True, default=None, null=True),
71+
),
72+
]

api/desecapi/models/domains.py

Lines changed: 152 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
from __future__ import annotations
22

3-
from functools import cached_property
4-
5-
import dns
3+
from functools import cache, cached_property
4+
from socket import getaddrinfo
5+
6+
import dns.name
7+
import dns.rdataclass
8+
import dns.rdatatype
9+
import dns.rdtypes
10+
import dns.resolver
611
import psl_dns
712
from django.conf import settings
813
from django.contrib.auth.models import AnonymousUser
914
from django.core.exceptions import ValidationError
1015
from django.db import models
1116
from django.db.models import CharField, F, Manager, Q, Value
1217
from django.db.models.functions import Concat, Length
18+
from django.utils import timezone
1319
from django_prometheus.models import ExportModelOperationsMixin
1420
from dns.exception import Timeout
1521
from dns.resolver import NoNameservers
@@ -23,6 +29,11 @@
2329

2430
psl = psl_dns.PSL(resolver=settings.PSL_RESOLVER, timeout=0.5)
2531

32+
# CHECKING DISABLED general-purpose resolver for queries to the public DNS
33+
resolver_CD = dns.resolver.Resolver(configure=False)
34+
resolver_CD.nameservers = settings.RESOLVERS
35+
resolver_CD.flags = (resolver_CD.flags or 0) | dns.flags.CD | dns.flags.AD | dns.flags.RD
36+
2637

2738
class DomainManager(Manager):
2839
def filter_qname(self, qname: str, **kwargs) -> models.query.QuerySet:
@@ -52,6 +63,25 @@ class RenewalState(models.IntegerChoices):
5263
NOTIFIED = 2
5364
WARNED = 3
5465

66+
class DelegationStatus(models.IntegerChoices):
67+
NOT_DELEGATED = 0
68+
ELSEWHERE = 1
69+
PARTIAL = 2
70+
EXCLUSIVE = 3
71+
MULTI = 4
72+
ERROR_NXDOMAIN = 128
73+
ERROR_NO_NAMESERVERS = 129
74+
ERROR_TIMEOUT = 130
75+
76+
class SecurityStatus(models.IntegerChoices):
77+
INSECURE = 0
78+
FOREIGN_KEYS = 1
79+
SECURE_EXCLUSIVE = 2
80+
SECURE = 3
81+
ERROR_NXDOMAIN = 128
82+
ERROR_NO_NAMESERVERS = 130
83+
ERROR_TIMEOUT = 131
84+
5585
created = models.DateTimeField(auto_now_add=True)
5686
name = models.CharField(
5787
max_length=191, unique=True, validators=validate_domain_name
@@ -63,6 +93,26 @@ class RenewalState(models.IntegerChoices):
6393
choices=RenewalState.choices, db_index=True, default=RenewalState.IMMORTAL
6494
)
6595
renewal_changed = models.DateTimeField(auto_now_add=True)
96+
delegation_status = models.IntegerField(
97+
choices=DelegationStatus.choices,
98+
default=None,
99+
null=True,
100+
blank=True,
101+
)
102+
delegation_status_touched = models.DateTimeField(
103+
default=None, null=True, blank=True
104+
)
105+
delegation_status_changed = models.DateTimeField(
106+
default=None, null=True, blank=True
107+
)
108+
security_status = models.IntegerField(
109+
choices=SecurityStatus.choices,
110+
default=None,
111+
null=True,
112+
blank=True,
113+
)
114+
security_status_touched = models.DateTimeField(default=None, null=True, blank=True)
115+
security_status_changed = models.DateTimeField(default=None, null=True, blank=True)
66116

67117
_keys = None
68118
objects = DomainManager()
@@ -177,6 +227,105 @@ def is_registrable(self):
177227

178228
return True
179229

230+
@staticmethod
231+
@cache # located at object-level to start with clear cache for new objects
232+
def _lookup(target) -> set[str]:
233+
try:
234+
addrinfo = getaddrinfo(str(target), None)
235+
except OSError:
236+
return set()
237+
return {v[-1][0] for v in addrinfo}
238+
239+
def update_dns_delegation_status(self) -> DelegationStatus:
240+
"""Queries the DNS to determine the delegation status of this domian and
241+
update the delegation status on record."""
242+
old_delegation_status = self.delegation_status
243+
our_ns_names = {dns.name.from_text(ns) for ns in settings.DEFAULT_NS}
244+
245+
try:
246+
auth_ns_names = {
247+
rr.target for rr in resolver_CD.resolve(self.name, dns.rdatatype.NS, raise_on_no_answer=False)
248+
}
249+
except dns.resolver.NXDOMAIN:
250+
self.delegation_status = self.DelegationStatus.ERROR_NXDOMAIN
251+
except dns.resolver.NoNameservers:
252+
self.delegation_status = self.DelegationStatus.ERROR_NO_NAMESERVERS
253+
except dns.resolver.LifetimeTimeout:
254+
self.delegation_status = self.DelegationStatus.ERROR_TIMEOUT
255+
else:
256+
257+
if our_ns_names == auth_ns_names:
258+
# just ours
259+
self.delegation_status = self.DelegationStatus.EXCLUSIVE
260+
elif our_ns_names < auth_ns_names:
261+
# all of ours, and others
262+
self.delegation_status = self.DelegationStatus.MULTI
263+
elif our_ns_names & auth_ns_names:
264+
# some of ours, and others
265+
self.delegation_status = self.DelegationStatus.PARTIAL
266+
elif auth_ns_names:
267+
# none of ours, but not empty
268+
self.delegation_status = self.DelegationStatus.ELSEWHERE
269+
elif auth_ns_names == set():
270+
# empty
271+
self.delegation_status = self.DelegationStatus.NOT_DELEGATED
272+
elif auth_ns_names is None:
273+
# error
274+
self.delegation_status = self.DelegationStatus
275+
276+
now = timezone.now()
277+
self.delegation_status_touched = now
278+
if old_delegation_status != self.delegation_status:
279+
self.delegation_status_changed = now
280+
return self.delegation_status
281+
282+
def update_dns_security_status(self) -> SecurityStatus:
283+
"""Queries the DNS to determine the security status of this domain and
284+
updates the security status on record."""
285+
old_security_status = self.security_status
286+
287+
if self.delegation_status not in [
288+
self.DelegationStatus.MULTI,
289+
self.DelegationStatus.EXCLUSIVE,
290+
]:
291+
self.security_status = None
292+
return None
293+
294+
try:
295+
auth_ds = set(resolver_CD.resolve(self.name, dns.rdatatype.DS, raise_on_no_answer=False))
296+
except dns.resolver.NXDOMAIN:
297+
self.security_status = self.SecurityStatus.ERROR_NXDOMAIN
298+
except dns.resolver.NoNameservers:
299+
self.delegation_status = self.SecurityStatus.ERROR_NO_NAMESERVERS
300+
except dns.resolver.LifetimeTimeout:
301+
self.delegation_status = self.SecurityStatus.ERROR_TIMEOUT
302+
else:
303+
auth_ds = {ds for ds in auth_ds if ds.digest_type == 2}
304+
305+
# Compute overlap of delegation DS records with ours
306+
our_ds_set = {
307+
dns.rdata.from_text(rdclass="IN", rdtype="DS", tok=ds)
308+
for key in self.keys
309+
for ds in key.get("ds", [])
310+
if dns.rdata.from_text(rdclass="IN", rdtype="DS", tok=ds).digest_type
311+
== 2 # only digest type 2 is mandatory
312+
}
313+
314+
if our_ds_set == auth_ds:
315+
self.security_status = self.SecurityStatus.SECURE_EXCLUSIVE
316+
elif our_ds_set < auth_ds:
317+
self.security_status = self.SecurityStatus.SECURE
318+
elif auth_ds != set():
319+
self.security_status = self.SecurityStatus.FOREIGN_KEYS
320+
else:
321+
self.security_status = self.SecurityStatus.INSECURE
322+
323+
now = timezone.now()
324+
self.security_status_touched = now
325+
if old_security_status != self.security_status:
326+
self.security_status_changed = now
327+
return self.security_status
328+
180329
@property
181330
def keys(self):
182331
if not self._keys:

0 commit comments

Comments
 (0)