Skip to content

Commit c41be83

Browse files
wip feat(api): add management command for RFC 9859
1 parent 663fb8a commit c41be83

1 file changed

Lines changed: 96 additions & 0 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from django.core.management import BaseCommand, CommandError
2+
import dns.resolver
3+
4+
from desecapi.models import Domain
5+
from desecapi.utils import gethostbyname_cached
6+
7+
8+
class Command(BaseCommand):
9+
debug = False
10+
help = "Notify parent to update the DS RRset."
11+
resolver: dns.resolver.Resolver
12+
13+
def add_arguments(self, parser):
14+
parser.add_argument(
15+
"domain-name",
16+
nargs="*",
17+
help="Domain name to notify for. If omitted, notify for all domains known locally.",
18+
)
19+
20+
def handle(self, *args, **options):
21+
domains = Domain.objects.all()
22+
self.debug = options.get("verbosity", 1) > 1
23+
24+
if options["domain-name"]:
25+
domains = domains.filter(name__in=options["domain-name"])
26+
domain_names = domains.values_list("name", flat=True)
27+
28+
for domain_name in options["domain-name"]:
29+
if domain_name not in domain_names:
30+
raise CommandError("{} is not a known domain".format(domain_name))
31+
32+
self.resolver = dns.resolver.Resolver(configure=False)
33+
self.resolver.nameservers = [gethostbyname_cached("resolver")]
34+
self.resolver.flags = dns.flags.RD | dns.flags.AD
35+
36+
for domain in domains:
37+
self.stdout.write("%s ... " % domain.name, ending="")
38+
domain_name = dns.name.from_text(domain.name)
39+
try:
40+
answer = self._get_dsync(domain_name)
41+
# TODO self._notify_domain
42+
self.stdout.write(" notified")
43+
except Exception as e:
44+
self.stdout.write(" failed")
45+
msg = "Error while processing {}: {}".format(domain.name, e)
46+
raise CommandError(msg)
47+
48+
def _resolve_securely(self, qname, rdtype):
49+
if self.debug:
50+
print(f"resolving {qname}/{rdtype} ...")
51+
try:
52+
answer = self.resolver.resolve(qname, rdtype)
53+
response = answer.response
54+
except dns.resolver.NoAnswer as e:
55+
answer = None
56+
response = e.response()
57+
except dns.resolver.NXDOMAIN as e:
58+
answer = None
59+
response = e.response(qname)
60+
finally:
61+
if not (response.flags & dns.flags.AD):
62+
raise dns.exception.ValidationFailure(
63+
f"unauthenticated response: {qname}/{rdtype}"
64+
)
65+
return answer, response
66+
67+
def _get_dsync(self, domain_name):
68+
# This implements the discovery algorithm from RFC 9859 Section 4.1
69+
70+
# Try child-specific (or wildcard), assuming parent one level up
71+
qname = dns.name.Name((domain_name[0], "_dsync", *domain_name[1:]))
72+
answer, response = self._resolve_securely(qname, dns.rdatatype.DSYNC)
73+
if answer:
74+
return answer
75+
76+
# Find parent
77+
owner_names = [
78+
rr.name
79+
for rr in response.authority
80+
if rr.rdtype == dns.rdatatype.SOA and rr.rdclass == dns.rdataclass.IN
81+
]
82+
if len(owner_names) > 1:
83+
ValueError("Negative response has several SOA records")
84+
parent = owner_names[0]
85+
86+
# Try child-specific (or wildcard), with parent from previous negative response
87+
infix = dns.name.from_text("_dsync").relativize(dns.name.root)
88+
parent_qname = domain_name - parent + infix + parent
89+
if parent_qname != qname:
90+
answer, _ = self._resolve_securely(parent_qname, dns.rdatatype.DSYNC)
91+
if answer:
92+
return answer
93+
94+
# Try fall-back DSYNC record at _dsync.$parent
95+
qname = infix + parent
96+
return self._resolve_securely(qname, dns.rdatatype.DSYNC)[0]

0 commit comments

Comments
 (0)