Skip to content

Commit 2807a5f

Browse files
fixup! feat(api): Support updating multiple subdomains at once using dyndns API
1 parent f00ffed commit 2807a5f

File tree

5 files changed

+91
-82
lines changed

5 files changed

+91
-82
lines changed

api/desecapi/models/domains.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,6 @@ 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-
qsets = [
48-
self.filter_qname(qname, **kwargs).order_by("-name_length")[:1]
49-
for qname in qnames
50-
]
51-
52-
return qsets[0].union(*qsets[1:], all=True)
53-
5443

5544
class Domain(ExportModelOperationsMixin("Domain"), models.Model):
5645
@staticmethod

api/desecapi/tests/test_dyndns12update.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,20 @@ def test_ddclient_dyndns2_v4_invalid(self):
161161
self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
162162
self.assertIn("malformed", str(response.data))
163163

164+
def test_ddclient_dyndns2_v4_valid_priority(self):
165+
# /nic/update?system=dyndns&hostname=foobar.dedyn.io&myip=10.2.3.4asdf
166+
params = {
167+
"domain_name": self.my_domain.name,
168+
"system": "dyndns",
169+
"hostname": self.my_domain.name,
170+
"myip": "invalid",
171+
"ip": "10.4.2.1",
172+
}
173+
response = self.client.get(self.reverse("v1:dyndns12update"), params)
174+
self.assertStatus(response, status.HTTP_200_OK)
175+
self.assertEqual(response.data, "good")
176+
self.assertIP(ipv4="10.4.2.1")
177+
164178
def test_ddclient_dyndns2_v4_invalid_or_foreign_domain(self):
165179
# /nic/update?system=dyndns&hostname=<...>&myip=10.2.3.4
166180
for name in [

api/desecapi/views/dyndns.py

Lines changed: 63 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,20 @@ def _find_action(self, param_keys, separator) -> UpdateAction:
8282
# Check URL parameters
8383
for param_key in param_keys:
8484
try:
85-
params = {
86-
param.strip()
87-
for param in self.request.query_params[param_key].split(",")
88-
if separator in param or param.strip() in ("", "preserve")
89-
}
85+
params = set(
86+
filter(
87+
lambda param: separator in param or param in ("", "preserve"),
88+
map(str.strip, self.request.query_params[param_key].split(",")),
89+
)
90+
)
9091
except KeyError:
9192
continue
92-
if len(params) > 1:
93+
if not params:
94+
continue
95+
96+
try:
97+
(param,) = params # unpacks if params has exactly one element
98+
except ValueError: # more than one element
9399
if params & {"", "preserve"}:
94100
raise ValidationError(
95101
detail=f'IP parameter "{param_key}" cannot have addresses and "preserve" at the same time.',
@@ -100,23 +106,23 @@ def _find_action(self, param_keys, separator) -> UpdateAction:
100106
detail=f'IP parameter "{param_key}" cannot use subnet notation with multiple addresses.',
101107
code="multiple-subnet",
102108
)
103-
if params:
104-
params = list(params)
105-
if len(params) == 1 and "/" in params[0]:
106-
try:
107-
subnet = ip_network(params[0], strict=False)
108-
return UpdateWithSubnet(subnet=subnet)
109-
except ValueError as e:
110-
raise ValidationError(
111-
detail=f'IP parameter "{param_key}" is an invalid subnet: {e}',
112-
code="invalid-subnet",
113-
)
114-
if params == ["preserve"]:
115-
return PreserveIPs()
116-
elif params == [""]:
117-
return SetIPs(ips=[])
118-
else:
119-
return SetIPs(ips=params)
109+
else: # one element
110+
match param:
111+
case "":
112+
return SetIPs(ips=[])
113+
case "preserve":
114+
return PreserveIPs()
115+
case str(x) if "/" in x:
116+
try:
117+
subnet = ip_network(param, strict=False)
118+
return UpdateWithSubnet(subnet=subnet)
119+
except ValueError as e:
120+
raise ValidationError(
121+
detail=f'IP parameter "{param_key}" is an invalid subnet: {e}',
122+
code="invalid-subnet",
123+
)
124+
125+
return SetIPs(ips=list(params))
120126

121127
# Check remote IP address
122128
client_ip = self.request.META.get("REMOTE_ADDR")
@@ -184,9 +190,15 @@ def qnames(self) -> set[str]:
184190

185191
@cached_property
186192
def domain(self) -> Domain:
187-
domains = Domain.objects.filter_qnames(
188-
self.qnames, owner=self.request.user
189-
).all()
193+
qname_qs = (
194+
Domain.objects.filter_qname(qname, owner=self.request.user)
195+
for qname in self.qnames
196+
)
197+
domains = (
198+
Domain.objects.none()
199+
.union(*(qs.order_by("-name_length")[:1] for qs in qname_qs), all=True)
200+
.all()
201+
)
190202

191203
if len(domains) != len(self.qnames):
192204
metrics.get("desecapi_dynDNS12_domain_not_found").inc()
@@ -224,52 +236,46 @@ def _get_records(records: list[RR], action: UpdateAction) -> list[str] | None:
224236
Determines the final list of IP address records based on the given action.
225237
226238
Args:
227-
records (list): A list of Record objects for a single domain.
239+
records (list): A list of RR objects (for one RRset).
228240
action (UpdateAction): The action to perform.
229241
230242
Returns:
231243
list or None: A list of IP address strings, or None if the records should be preserved.
232244
"""
233-
if isinstance(action, SetIPs):
234-
return action.ips
235-
elif isinstance(action, UpdateWithSubnet):
236-
return replace_ip_subnet(records, action.subnet)
237-
elif isinstance(action, PreserveIPs):
238-
return None
245+
match action:
246+
case SetIPs():
247+
return action.ips
248+
case UpdateWithSubnet():
249+
return replace_ip_subnet(records, action.subnet)
250+
case PreserveIPs():
251+
return None
239252

240253
def get(self, request, *args, **kwargs) -> Response:
241-
instances = self.get_queryset().all()
254+
instances = self.get_queryset()
242255

243-
grouped_records = defaultdict(list)
256+
subname_records = defaultdict(list)
244257
for rrset in instances:
245-
grouped_records[rrset.subname].extend(rrset.records.all())
258+
subname_records[rrset.subname].extend(rrset.records.all())
246259

247260
actions = {
248261
"A": self._find_action(["myip", "myipv4", "ip"], separator="."),
249262
"AAAA": self._find_action(["myipv6", "ipv6", "myip", "ip"], separator=":"),
250263
}
251264

252-
data = []
253-
for subname in self.subnames:
254-
subname_records = grouped_records.get(subname, [])
255-
256-
data += [
257-
{
258-
"type": type_,
259-
"subname": subname,
260-
"ttl": 60,
261-
"records": records,
262-
}
263-
for type_, action in actions.items()
264-
if (records := self._get_records(subname_records, action)) is not None
265-
]
266-
267-
serializer = self.get_serializer(
268-
instances,
269-
data=data,
270-
many=True,
271-
partial=True,
272-
)
265+
data = [
266+
{
267+
"type": type_,
268+
"subname": subname,
269+
"ttl": 60,
270+
"records": records,
271+
}
272+
for subname in self.subnames
273+
for type_, action in actions.items()
274+
if (records := self._get_records(subname_records[subname], action))
275+
is not None
276+
]
277+
278+
serializer = self.get_serializer(instances, data=data, many=True, partial=True)
273279
try:
274280
serializer.is_valid(raise_exception=True)
275281
except ValidationError as e:

docs/dyndns/configure.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,8 @@ in the same request.
189189
When using ddclient, you can simply specify multiple (sub)domains in your
190190
``ddclient.conf`` by separating them with a comma. For example, you can set
191191
``domain.org,sub.domain.org&myipv6=preserve`` as the domain to update. In this
192-
case the IPv4 address of both ``domain.org`` and ``sub.domain.org`` while will
193-
be updated while preserving the (different) IPv6 addresses.
192+
case the IPv4 address of both ``domain.org`` and ``sub.domain.org`` will be
193+
updated while preserving any (different) IPv6 addresses.
194194

195195
If you try to update several subdomains by issuing multiple update requests,
196196
your update requests may be refused (see :ref:`rate-limits`).

docs/dyndns/update-api.rst

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -73,27 +73,27 @@ the case, we suggest looking for another client.
7373
Determine Hostname
7474
******************
7575
To update your IP address in the DNS, our servers need to determine the
76-
hostname you want to update. To determine the hostname, we try the following
76+
hostname(s) you want to update. To determine them, we try the following
7777
steps until there is a match:
7878

7979
- ``hostname`` query string parameter, unless it is set to ``YES`` (this
80-
sometimes happens with dynDNS update clients). This parameter can also be a
81-
comma-separated list of hostnames to update multiple domains in a single
82-
request. All updates are performed in a single, atomic transaction.
80+
sometimes happens with dynDNS update clients).
8381

84-
- ``host_id`` query string parameter. This can also be a comma-separated list of
85-
hostnames.
82+
- ``host_id`` query string parameter.
8683

87-
- The username as provided in the HTTP Basic Authorization header. This can also
88-
be a comma-separated list of hostnames.
84+
- The username as provided in the HTTP Basic Authorization header.
8985

90-
- The username as provided in the ``username`` query string parameter. This can
91-
also be a comma-separated list of hostnames. All hostnames must belong to the
92-
same domain.
86+
- The username as provided in the ``username`` query string parameter.
9387

9488
- After successful authentication (no matter how), the only hostname that is
9589
associated with your user account (if not ambiguous).
9690

91+
You can either specify a single hostname, or a comma-separated list of hostnames
92+
in order to update multiple subdomains in a single request. This works with any
93+
of the above parameters; however, all hostnames must belong to the same domain.
94+
The resulting updates are performed atomically (that is, they are either all
95+
applied or they all fail).
96+
9797
If we cannot determine a hostname to update, the API returns a status code of
9898
``400 Bad Request`` (if no hostname was given but multiple domains exist in
9999
the account) or ``404 Not Found`` (if the specified domain was not found).
@@ -225,5 +225,5 @@ or option 2::
225225

226226
Update multiple domains simultaneously::
227227

228-
curl "https://update.dedyn.io/?hostname=<your domain>,<your sub domain>&myip=1.2.3.4" \
228+
curl "https://update.dedyn.io/?hostname=<your domain>,<your subdomain>&myip=1.2.3.4" \
229229
--header "Authorization: Token <your token secret>"

0 commit comments

Comments
 (0)