Skip to content

Commit ec10204

Browse files
wip feat(api): add management command for RFC 9859
1 parent 76062ae commit ec10204

File tree

1 file changed

+128
-0
lines changed

1 file changed

+128
-0
lines changed
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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+
except dns.exception.ValidationFailure as e:
42+
print(f"failed: {e}")
43+
continue
44+
except Exception as e:
45+
print("failed")
46+
msg = "Error while processing {}: {}".format(domain.name, e)
47+
raise CommandError(msg)
48+
49+
if answer is None:
50+
print("unsupported")
51+
else:
52+
notifies = 0
53+
for dsync in answer:
54+
result = self._notify_domain(domain_name, dsync)
55+
if result is not None:
56+
notifies += result
57+
print(
58+
f"notified, {notifies} targets confirmed (from {answer.qname}/DSYNC)"
59+
)
60+
61+
def _resolve_securely(self, qname, rdtype):
62+
if self.debug:
63+
print(f"resolving {qname}/{rdtype} ...")
64+
try:
65+
answer = self.resolver.resolve(qname, rdtype)
66+
response = answer.response
67+
except dns.resolver.NoAnswer as e:
68+
answer = None
69+
response = e.response()
70+
except dns.resolver.NXDOMAIN as e:
71+
answer = None
72+
response = e.response(qname)
73+
finally:
74+
if not (response.flags & dns.flags.AD):
75+
raise dns.exception.ValidationFailure(
76+
f"unauthenticated response: {qname}/{rdtype}"
77+
)
78+
return answer, response
79+
80+
def _notify_domain(self, domain_name, dsync):
81+
# Only process NOTIFY(CDS)
82+
if dsync.scheme != 1 or dsync.rrtype != dns.rdatatype.CDS:
83+
return
84+
85+
notify = dns.message.make_query(domain_name, dns.rdatatype.CDS)
86+
notify.set_opcode(dns.opcode.NOTIFY)
87+
notify.flags += dns.flags.AA - dns.flags.RD
88+
opt = dns.edns.ReportChannelOption(dns.name.from_text("ns1.desec.io."))
89+
notify.use_edns(edns=True, options=[opt])
90+
91+
response = dns.query.udp(
92+
notify, gethostbyname_cached(dsync.target.to_text()), timeout=5
93+
)
94+
95+
notify.flags += dns.flags.QR
96+
# TODO why does this work despite of the EDNS0 option not being in the response?
97+
return notify == response
98+
99+
def _get_dsync(self, domain_name):
100+
# This implements the discovery algorithm from RFC 9859 Section 4.1
101+
102+
# Try child-specific (or wildcard), assuming parent one level up
103+
qname = dns.name.Name((domain_name[0], "_dsync", *domain_name[1:]))
104+
answer, response = self._resolve_securely(qname, dns.rdatatype.DSYNC)
105+
if answer:
106+
return answer
107+
108+
# Find parent
109+
owner_names = [
110+
rr.name
111+
for rr in response.authority
112+
if rr.rdtype == dns.rdatatype.SOA and rr.rdclass == dns.rdataclass.IN
113+
]
114+
if len(owner_names) > 1:
115+
ValueError("Negative response has several SOA records")
116+
parent = owner_names[0]
117+
118+
# Try child-specific (or wildcard), with parent from previous negative response
119+
infix = dns.name.from_text("_dsync").relativize(dns.name.root)
120+
parent_qname = domain_name - parent + infix + parent
121+
if parent_qname != qname:
122+
answer, _ = self._resolve_securely(parent_qname, dns.rdatatype.DSYNC)
123+
if answer:
124+
return answer
125+
126+
# Try fall-back DSYNC record at _dsync.$parent
127+
qname = infix + parent
128+
return self._resolve_securely(qname, dns.rdatatype.DSYNC)[0]

0 commit comments

Comments
 (0)