Skip to content

Commit 176b003

Browse files
committed
feat(api): Support updating multiple subdomains at once using dyndns API
1 parent 7ea201f commit 176b003

File tree

6 files changed

+379
-69
lines changed

6 files changed

+379
-69
lines changed

api/desecapi/models/domains.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
from django.contrib.auth.models import AnonymousUser
99
from django.core.exceptions import ValidationError
1010
from django.db import models
11-
from django.db.models import CharField, F, Manager, Q, Value
12-
from django.db.models.functions import Concat, Length
11+
from django.db.models import CharField, F, Manager, Q, Value, Window
12+
from django.db.models.functions import Concat, Length, RowNumber
1313
from django_prometheus.models import ExportModelOperationsMixin
1414
from dns.exception import Timeout
1515
from dns.resolver import NoNameservers
@@ -40,6 +40,23 @@ def filter_qname(self, qname: str, **kwargs) -> models.query.QuerySet:
4040
dotted_qname=Value(f".{qname}", output_field=CharField()),
4141
).filter(dotted_qname__endswith=F("dotted_name"), **kwargs)
4242

43+
def filter_qnames(self, qnames: list[str], **kwargs) -> models.query.QuerySet:
44+
if not qnames:
45+
return self.none()
46+
47+
window = Window(
48+
expression=RowNumber(),
49+
order_by=F("name_length").desc(),
50+
)
51+
qsets = [
52+
self.filter_qname(qname, **kwargs)
53+
.annotate(distance=window)
54+
.filter(distance=1)
55+
for qname in qnames
56+
]
57+
58+
return qsets[0].union(*qsets[1:])
59+
4360

4461
class Domain(ExportModelOperationsMixin("Domain"), models.Model):
4562
@staticmethod

api/desecapi/models/records.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,24 +56,17 @@
5656
)
5757

5858

59-
def replace_ip_subnet(queryset, subnet):
59+
def replace_ip_subnet(records, subnet):
6060
"""
61-
Fetches A or AAAA record contents from an RRset queryset (depending on the subnet's
62-
address family) and returns them with their subnet bits replaced accordingly.
61+
Takes a list of A or AAAA records and returns them with their subnet bits replaced accordingly.
6362
"""
64-
subnet = ip_network(subnet, strict=False)
65-
try:
66-
records = queryset.get(
67-
type={IPv4Network: "A", IPv6Network: "AAAA"}[type(subnet)]
68-
).records.all()
69-
except ObjectDoesNotExist:
70-
records = []
7163
return [
7264
str(
7365
ip_address(int(ip_address(record.content)) & int(subnet.hostmask)) # suffix
7466
+ int(subnet.network_address) # prefix
7567
)
7668
for record in records
69+
if type(ip_address(record.content)) is type(subnet.network_address)
7770
]
7871

7972

api/desecapi/tests/test_dyndns12update.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,191 @@ def test_subnet(self):
296296
self.assertEqual(response.data, "good")
297297
self.assertIP(ipv6="2a01::3303:72dc:f412:7233", subname="foo")
298298

299+
def test_update_multiple_v4(self):
300+
# /nic/update?hostname=a.io,sub.a.io&myip=1.2.3.4
301+
new_ip = "1.2.3.4"
302+
domain1 = self.my_domain.name
303+
domain2 = "sub." + self.my_domain.name
304+
305+
with self.assertRequests(
306+
self.request_pdns_zone_update(domain1),
307+
self.request_pdns_zone_axfr(domain1),
308+
):
309+
response = self.client.get(
310+
self.reverse("v1:dyndns12update"),
311+
{"hostname": f"{domain1},{domain2}", "myip": new_ip},
312+
)
313+
314+
self.assertStatus(response, status.HTTP_200_OK)
315+
self.assertEqual(response.data, "good")
316+
317+
self.assertIP(ipv4=new_ip)
318+
self.assertIP(subname="sub", ipv4=new_ip)
319+
320+
def test_update_multiple_username_param(self):
321+
# /nic/update?username=a.io,sub.a.io&myip=1.2.3.4
322+
new_ip = "1.2.3.4"
323+
domain1 = self.my_domain.name
324+
domain2 = "sub." + self.my_domain.name
325+
326+
with self.assertRequests(
327+
self.request_pdns_zone_update(domain1),
328+
self.request_pdns_zone_axfr(domain1),
329+
):
330+
response = self.client_token_authorized.get(
331+
self.reverse("v1:dyndns12update"),
332+
{"username": f"{domain1},{domain2}", "myip": new_ip},
333+
)
334+
335+
self.assertStatus(response, status.HTTP_200_OK)
336+
self.assertEqual(response.data, "good")
337+
338+
self.assertIP(ipv4=new_ip)
339+
self.assertIP(subname="sub", ipv4=new_ip)
340+
341+
def test_update_multiple_v4v6(self):
342+
# /nic/update?hostname=a.io,sub.a.io&myip=1.2.3.4&myipv6=1::1
343+
new_ip4 = "1.2.3.4"
344+
new_ip6 = "1::1"
345+
domain1 = self.my_domain.name
346+
domain2 = "sub." + domain1
347+
348+
with self.assertRequests(
349+
self.request_pdns_zone_update(domain1),
350+
self.request_pdns_zone_axfr(domain1),
351+
):
352+
response = self.client.get(
353+
self.reverse("v1:dyndns12update"),
354+
{
355+
"hostname": f"{domain1},{domain2}",
356+
"myip": new_ip4,
357+
"myipv6": new_ip6,
358+
},
359+
)
360+
361+
self.assertStatus(response, status.HTTP_200_OK)
362+
self.assertEqual(response.data, "good")
363+
364+
self.assertIP(ipv4=new_ip4, ipv6=new_ip6)
365+
self.assertIP(subname="sub", ipv4=new_ip4, ipv6=new_ip6)
366+
367+
def test_update_multiple_with_subnet(self):
368+
# /nic/update?hostname=sub1.a.io,sub2.a.io&myip=10.1.0.0/16
369+
domain1 = "sub1." + self.my_domain.name
370+
domain2 = "sub2." + self.my_domain.name
371+
self.create_rr_set(
372+
self.my_domain, ["10.0.0.1"], subname="sub1", type="A", ttl=60
373+
)
374+
self.create_rr_set(
375+
self.my_domain, ["10.0.0.2"], subname="sub2", type="A", ttl=60
376+
)
377+
378+
with self.assertRequests(
379+
self.request_pdns_zone_update(self.my_domain.name),
380+
self.request_pdns_zone_axfr(self.my_domain.name),
381+
):
382+
response = self.client.get(
383+
self.reverse("v1:dyndns12update"),
384+
{"hostname": f"{domain1},{domain2}", "myip": "10.1.0.0/16"},
385+
)
386+
387+
self.assertStatus(response, status.HTTP_200_OK)
388+
self.assertEqual(response.data, "good")
389+
390+
self.assertIP(subname="sub1", ipv4="10.1.0.1")
391+
self.assertIP(subname="sub2", ipv4="10.1.0.2")
392+
393+
def test_update_multiple_with_one_being_already_up_to_date(self):
394+
# /nic/update?hostname=a.io,sub.a.io&myip=1.2.3.4
395+
new_ip = "1.2.3.4"
396+
domain1 = self.my_domain.name
397+
domain2 = "sub." + domain1
398+
self.create_rr_set(self.my_domain, [new_ip], subname="sub", type="A", ttl=60)
399+
400+
with self.assertRequests(
401+
self.request_pdns_zone_update(domain1),
402+
self.request_pdns_zone_axfr(domain1),
403+
):
404+
response = self.client.get(
405+
self.reverse("v1:dyndns12update"),
406+
{"hostname": f"{domain1},{domain2}", "myip": new_ip},
407+
)
408+
409+
self.assertStatus(response, status.HTTP_200_OK)
410+
self.assertEqual(response.data, "good")
411+
412+
self.assertIP(ipv4=new_ip)
413+
self.assertIP(subname="sub", ipv4=new_ip)
414+
415+
def test_update_same_domain_twice(self):
416+
# /nic/update?hostname=foobar.dedyn.io,foobar.dedyn.io&myip=1.2.3.4
417+
new_ip = "1.2.3.4"
418+
419+
with self.assertRequests(
420+
self.request_pdns_zone_update(self.my_domain.name),
421+
self.request_pdns_zone_axfr(self.my_domain.name),
422+
):
423+
response = self.client.get(
424+
self.reverse("v1:dyndns12update"),
425+
{
426+
"hostname": f"{self.my_domain.name},{self.my_domain.name}",
427+
"myip": new_ip,
428+
},
429+
)
430+
431+
self.assertStatus(response, status.HTTP_200_OK)
432+
self.assertEqual(response.data, "good")
433+
434+
self.assertIP(ipv4=new_ip)
435+
436+
def test_update_multiple_with_invalid_subnet(self):
437+
# /nic/update?hostname=sub1.a.io,sub2.a.io&myip=1.2.3.4/64
438+
domain1 = "sub1." + self.my_domain.name
439+
domain2 = "sub2." + self.my_domain.name
440+
441+
with self.assertRequests():
442+
response = self.client.get(
443+
self.reverse("v1:dyndns12update"),
444+
{"hostname": f"{domain1},{domain2}", "myip": "1.2.3.4/64"},
445+
)
446+
447+
self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
448+
449+
def test_update_multiple_with_subdomains_of_different_domains(self):
450+
# /nic/update?hostname=a.io,b.io&myip=1.2.3.4
451+
domain1 = "sub1." + self.my_domain.name
452+
domain2 = "sub2." + self.create_domain(owner=self.owner).name
453+
454+
with self.assertRequests():
455+
response = self.client.get(
456+
self.reverse("v1:dyndns12update"),
457+
{"hostname": f"{domain1},{domain2}", "myip": "1.2.3.4"},
458+
)
459+
460+
self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
461+
462+
def test_update_with_trailing_comma(self):
463+
response = self.client_token_authorized.get(
464+
self.reverse("v1:dyndns12update"),
465+
{"host_id": f"{self.my_domain.name},", "myip": "1.2.3.4"},
466+
)
467+
468+
self.assertStatus(response, status.HTTP_404_NOT_FOUND)
469+
self.assertEqual(response.content, b"nohost")
470+
471+
def test_update_with_partial_ownership(self):
472+
with self.assertRequests():
473+
response = self.client.get(
474+
self.reverse("v1:dyndns12update"),
475+
{
476+
"hostname": f"{self.my_domain.name},{self.other_domain.name}",
477+
"myip": "1.2.3.4",
478+
},
479+
)
480+
481+
self.assertStatus(response, status.HTTP_404_NOT_FOUND)
482+
self.assertEqual(response.content, b"nohost")
483+
299484

300485
class SingleDomainDynDNS12UpdateTest(DynDNS12UpdateTest):
301486
NUM_OWNED_DOMAINS = 1

0 commit comments

Comments
 (0)