Skip to content

Commit 0b764f7

Browse files
committed
feat(api): Allow overwriting IP address for specific (sub)domain
1 parent 541d894 commit 0b764f7

File tree

4 files changed

+276
-44
lines changed

4 files changed

+276
-44
lines changed

api/desecapi/tests/test_dyndns12update.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,70 @@ def test_update_multiple_v4(self):
330330
self.assertIP(ipv4=new_ip)
331331
self.assertIP(subname="sub", ipv4=new_ip)
332332

333+
def test_update_multiple_with_overwrite(self):
334+
# /nic/update?hostname=sub1.a.io,sub2.a.io,sub3.a.io&myip=1.2.3.4&ipv6=::1&sub2.a.io.ipv6=::2
335+
new_ip4 = "1.2.3.4"
336+
new_ip6 = "::1"
337+
new_ip6_overwrite = "::2"
338+
domain1 = "sub1." + self.my_domain.name
339+
domain2 = "sub2." + self.my_domain.name
340+
domain3 = "sub3." + self.my_domain.name
341+
342+
with self.assertRequests(
343+
self.request_pdns_zone_update(self.my_domain.name),
344+
self.request_pdns_zone_axfr(self.my_domain.name),
345+
):
346+
response = self.client.get(
347+
self.reverse("v1:dyndns12update"),
348+
{
349+
"hostname": f"{domain1},{domain2},{domain3}",
350+
"myip": new_ip4,
351+
"ipv6": new_ip6,
352+
f"myipv6:{domain2}": new_ip6_overwrite,
353+
},
354+
)
355+
356+
self.assertStatus(response, status.HTTP_200_OK)
357+
self.assertEqual(response.data, "good")
358+
359+
self.assertIP(subname="sub1", ipv4=new_ip4, ipv6=new_ip6)
360+
self.assertIP(subname="sub2", ipv4=new_ip4, ipv6=new_ip6_overwrite)
361+
self.assertIP(subname="sub3", ipv4=new_ip4, ipv6=new_ip6)
362+
363+
def test_update_multiple_with_extra(self):
364+
# /nic/update?hostname=sub1.a.io,sub3.a.io&myip=1.2.3.4&ipv6=::1&sub2.a.io.ipv6=::2
365+
old_ip4 = "10.0.0.2"
366+
new_ip4 = "1.2.3.4"
367+
new_ip6 = "::1"
368+
new_ip6_extra = "::2"
369+
domain1 = "sub1." + self.my_domain.name
370+
domain2 = "sub2." + self.my_domain.name
371+
domain3 = "sub3." + self.my_domain.name
372+
self.create_rr_set(
373+
self.my_domain, [old_ip4], subname="sub2", type="A", ttl=60
374+
)
375+
376+
with self.assertRequests(
377+
self.request_pdns_zone_update(self.my_domain.name),
378+
self.request_pdns_zone_axfr(self.my_domain.name),
379+
):
380+
response = self.client.get(
381+
self.reverse("v1:dyndns12update"),
382+
{
383+
"hostname": f"{domain1},{domain3}",
384+
"myip": new_ip4,
385+
"ipv6": new_ip6,
386+
f"myipv6:{domain2}": new_ip6_extra,
387+
},
388+
)
389+
390+
self.assertStatus(response, status.HTTP_200_OK)
391+
self.assertEqual(response.data, "good")
392+
393+
self.assertIP(subname="sub1", ipv4=new_ip4, ipv6=new_ip6)
394+
self.assertIP(subname="sub2", ipv4=old_ip4, ipv6=new_ip6_extra)
395+
self.assertIP(subname="sub3", ipv4=new_ip4, ipv6=new_ip6)
396+
333397
def test_update_multiple_username_param(self):
334398
# /nic/update?username=a.io,sub.a.io&myip=1.2.3.4
335399
new_ip = "1.2.3.4"
@@ -403,6 +467,36 @@ def test_update_multiple_with_subnet(self):
403467
self.assertIP(subname="sub1", ipv4="10.1.0.1")
404468
self.assertIP(subname="sub2", ipv4="10.1.0.2")
405469

470+
def test_update_multiple_with_subnet_and_ip_override(self):
471+
# /nic/update?hostname=a.io,b.io&myip=10.1.0.0/16&a.io=192.168.1.1
472+
domain1 = "sub1." + self.my_domain.name
473+
domain2 = "sub2." + self.my_domain.name
474+
self.create_rr_set(
475+
self.my_domain, ["10.0.0.1"], subname="sub1", type="A", ttl=60
476+
)
477+
self.create_rr_set(
478+
self.my_domain, ["10.0.0.2"], subname="sub2", type="A", ttl=60
479+
)
480+
481+
with self.assertRequests(
482+
self.request_pdns_zone_update(self.my_domain.name),
483+
self.request_pdns_zone_axfr(self.my_domain.name),
484+
):
485+
response = self.client.get(
486+
self.reverse("v1:dyndns12update"),
487+
{
488+
"hostname": f"{domain1},{domain2}",
489+
"myip": "10.1.0.0/16",
490+
f"myipv4:{domain1}": "192.168.1.1",
491+
},
492+
)
493+
494+
self.assertStatus(response, status.HTTP_200_OK)
495+
self.assertEqual(response.data, "good")
496+
497+
self.assertIP(subname="sub1", ipv4="192.168.1.1")
498+
self.assertIP(subname="sub2", ipv4="10.1.0.2")
499+
406500
def test_update_multiple_with_one_being_already_up_to_date(self):
407501
# /nic/update?hostname=a.io,sub.a.io&myip=1.2.3.4
408502
new_ip = "1.2.3.4"
@@ -446,6 +540,20 @@ def test_update_same_domain_twice(self):
446540

447541
self.assertIP(ipv4=new_ip)
448542

543+
def test_update_overwrite_with_invalid_subnet(self):
544+
# /nic/update?hostname=a.io&a.io.myip=1.2.3.4/64
545+
domain1 = self.create_domain(owner=self.owner).name
546+
547+
with self.assertRequests():
548+
response = self.client.get(
549+
self.reverse("v1:dyndns12update"),
550+
{"hostname": f"{domain1}", f"myipv4:{domain1}": "1.2.3.4/64"},
551+
)
552+
553+
self.assertContains(
554+
response, "invalid subnet", status_code=status.HTTP_400_BAD_REQUEST
555+
)
556+
449557
def test_update_multiple_with_invalid_subnet(self):
450558
# /nic/update?hostname=sub1.a.io,sub2.a.io&myip=1.2.3.4/64
451559
domain1 = "sub1." + self.my_domain.name

api/desecapi/views/dyndns.py

Lines changed: 136 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ class PreserveIPs:
5050

5151
UpdateAction = SetIPs | UpdateWithSubnet | PreserveIPs
5252

53-
5453
class DynDNS12UpdateView(generics.GenericAPIView):
5554
authentication_classes = (
5655
TokenAuthentication,
@@ -82,47 +81,13 @@ def _find_action(self, param_keys, separator) -> UpdateAction:
8281
# Check URL parameters
8382
for param_key in param_keys:
8483
try:
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-
)
84+
param_value = self.request.query_params[param_key]
9185
except KeyError:
9286
continue
93-
if not params:
94-
continue
9587

96-
try:
97-
(param,) = params # unpacks if params has exactly one element
98-
except ValueError: # more than one element
99-
if params & {"", "preserve"}:
100-
raise ValidationError(
101-
detail=f'IP parameter "{param_key}" cannot have addresses and "preserve" or an empty value at the same time.',
102-
code="inconsistent-parameter",
103-
)
104-
if any("/" in param for param in params):
105-
raise ValidationError(
106-
detail=f'IP parameter "{param_key}" cannot use subnet notation with multiple addresses.',
107-
code="multiple-subnet",
108-
)
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))
88+
action = self._get_action_from_param(param_key, param_value, separator)
89+
if action is not None:
90+
return action
12691

12792
# Check remote IP address
12893
client_ip = self.request.META.get("REMOTE_ADDR")
@@ -132,6 +97,71 @@ def _find_action(self, param_keys, separator) -> UpdateAction:
13297
# give up
13398
return SetIPs(ips=[])
13499

100+
@staticmethod
101+
def _get_action_from_param(param_key: str, param_value: str, separator: str) -> UpdateAction | None:
102+
"""
103+
Parses a single query parameter value to determine the DynDNS update action.
104+
105+
This function is responsible for interpreting the `param_value` (which can be a single IP,
106+
a comma-separated list of IPs, 'preserve', or a CIDR subnet) and converting it into
107+
a structured UpdateAction dataclass. It also performs validation on the parameter's format.
108+
109+
Args:
110+
param_key: The name of the query parameter (e.g., 'myip', 'myipv4', 'myipv6', or a qname for extra actions).
111+
Used for error messages.
112+
param_value: The string value of the query parameter (e.g., '1.2.3.4', '1.2.3.4,5.6.7.8',
113+
'192.168.1.0/24', 'preserve', or '').
114+
separator: The character used to distinguish IP versions (e.g., '.' for IPv4, ':' for IPv6).
115+
116+
Returns:
117+
An instance of SetIPs, UpdateWithSubnet, PreserveIPs, or None if no valid action can be
118+
derived from the parameter (e.g., an IPv4 address was given, but IPv6 is required by the separator).
119+
Returns SetIPs(ips=[]) if param_value is an empty string.
120+
121+
Raises:
122+
ValidationError: If the parameter value is inconsistent (e.g., 'preserve' with addresses)
123+
or if a subnet is malformed.
124+
"""
125+
params = set(
126+
filter(
127+
lambda param: separator in param or param in ("", "preserve"),
128+
map(str.strip, param_value.split(","))
129+
)
130+
)
131+
if not params:
132+
return None
133+
134+
try:
135+
(param,) = params # unpacks if params has exactly one element
136+
except ValueError: # more than one element
137+
if params & {"", "preserve"}:
138+
raise ValidationError(
139+
detail=f'IP parameter "{param_key}" cannot have addresses and "preserve" or an empty value at the same time.',
140+
code="inconsistent-parameter",
141+
)
142+
if any("/" in param for param in params):
143+
raise ValidationError(
144+
detail=f'IP parameter "{param_key}" cannot use subnet notation with multiple addresses.',
145+
code="multiple-subnet",
146+
)
147+
else: # one element
148+
match param:
149+
case "":
150+
return SetIPs(ips=[])
151+
case "preserve":
152+
return PreserveIPs()
153+
case str(x) if "/" in x:
154+
try:
155+
subnet = ip_network(param, strict=False)
156+
return UpdateWithSubnet(subnet=subnet)
157+
except ValueError as e:
158+
raise ValidationError(
159+
detail=f'IP parameter "{param_key}" is an invalid subnet: {e}',
160+
code="invalid-subnet",
161+
)
162+
163+
return SetIPs(ips=list(params))
164+
135165
@staticmethod
136166
def _sanitize_qnames(qnames_str) -> set[str]:
137167
qnames = qnames_str.lower().split(",")
@@ -188,19 +218,52 @@ def qnames(self) -> set[str]:
188218
}
189219
)
190220

221+
@cached_property
222+
def extra_qnames(self) -> dict[str, dict[str, str]]:
223+
"""
224+
Parses query parameters of the form 'myipv4:qname' or 'myipv6:qname'
225+
to extract additional qnames and their associated update arguments.
226+
227+
Returns:
228+
A dictionary where keys are qnames (e.g., 'sub.example.com') and values
229+
are dictionaries mapping RR type ('A' or 'AAAA') to the raw query parameter
230+
value (e.g., {'A': '1.2.3.4,5.6.7.8'} or {'AAAA': 'preserve'}).
231+
Multiple IP values for the same qname/type are concatenated with commas.
232+
"""
233+
qnames = defaultdict(dict)
234+
235+
for param, value in self.request.query_params.items():
236+
if param.startswith("myipv6:"):
237+
type_ = "AAAA"
238+
elif param.startswith("myipv4:"):
239+
type_ = "A"
240+
else:
241+
continue
242+
243+
for qname in self._sanitize_qnames(param.split(":", 1)[1]):
244+
existing = qnames[qname].get(type_)
245+
if existing is not None:
246+
argument = f"{existing},{value}"
247+
else:
248+
argument = value
249+
qnames[qname][type_] = argument
250+
251+
return qnames
252+
191253
@cached_property
192254
def domain(self) -> Domain:
255+
qnames = self.qnames | self.extra_qnames.keys()
193256
qname_qs = (
194257
Domain.objects.filter_qname(qname, owner=self.request.user)
195-
for qname in self.qnames
258+
for qname in qnames
196259
)
197260
domains = (
198261
Domain.objects.none()
199262
.union(*(qs.order_by("-name_length")[:1] for qs in qname_qs), all=True)
200263
.all()
201264
)
202265

203-
if len(domains) != len(self.qnames):
266+
if len(domains) != len(qnames):
204267
metrics.get("desecapi_dynDNS12_domain_not_found").inc()
205268
raise NotFound("nohost")
206269

@@ -218,6 +281,25 @@ def domain(self) -> Domain:
218281
def subnames(self) -> list[str]:
219282
return [qname.rpartition(f".{self.domain.name}")[0] for qname in self.qnames]
220283

284+
@cached_property
285+
def extra_actions(self) -> dict[tuple[str, str], UpdateAction]:
286+
"""
287+
Converts the raw string arguments from `extra_qnames` into structured `UpdateAction` objects.
288+
289+
Returns:
290+
A dictionary where keys are `(RR_type, subname)` tuples (e.g., ('A', 'sub'))
291+
and values are `UpdateAction` instances (SetIPs, UpdateWithSubnet, PreserveIPs).
292+
"""
293+
return {
294+
(type_, qname.rpartition(f".{self.domain.name}")[0]): self._get_action_from_param(
295+
qname,
296+
argument,
297+
"." if type_ == "A" else ":"
298+
)
299+
for qname, arguments in self.extra_qnames.items()
300+
for type_, argument in arguments.items()
301+
}
302+
221303
def get_serializer_context(self):
222304
return {
223305
**super().get_serializer_context(),
@@ -226,8 +308,12 @@ def get_serializer_context(self):
226308
}
227309

228310
def get_queryset(self):
311+
subnames = [
312+
*self.subnames,
313+
*[subname for (type_, subname) in self.extra_actions.keys()]
314+
]
229315
return self.domain.rrset_set.filter(
230-
subname__in=self.subnames, type__in=["A", "AAAA"]
316+
subname__in=subnames, type__in=["A", "AAAA"]
231317
).prefetch_related("records")
232318

233319
@staticmethod
@@ -261,6 +347,13 @@ def get(self, request, *args, **kwargs) -> Response:
261347
"A": self._find_action(["myip", "myipv4", "ip"], separator="."),
262348
"AAAA": self._find_action(["myipv6", "ipv6", "myip", "ip"], separator=":"),
263349
}
350+
subname_actions = {
351+
(type_, subname): action
352+
for subname in self.subnames
353+
for type_, action in actions.items()
354+
}
355+
for (type_, subname), action in self.extra_actions.items():
356+
subname_actions[(type_, subname)] = action
264357

265358
data = [
266359
{
@@ -269,8 +362,7 @@ def get(self, request, *args, **kwargs) -> Response:
269362
"ttl": 60,
270363
"records": records,
271364
}
272-
for subname in self.subnames
273-
for type_, action in actions.items()
365+
for (type_, subname), action in subname_actions.items()
274366
if (records := self._get_records(subname_records[subname], action))
275367
is not None
276368
]

docs/dyndns/configure.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,5 +193,11 @@ syntax.) For example, when using ddclient, you can edit ``ddclient.conf`` and se
193193
case the IPv4 address of both ``domain.org`` and ``sub.domain.org`` will be
194194
updated while preserving any (different) IPv6 addresses.
195195

196+
For Fritz!Box devices, an update URL could look like this:
197+
``https://update.dedyn.io/?myipv4=<ipaddr>&myipv6=<ip6addr>&myipv6:host.<domain>=<ip6lanprefix>``
198+
This example updates the main domain with the router's global IPv4 and IPv6
199+
addresses, and additionally updates the subdomain `host.<domain name>` with
200+
an IPv6 LAN prefix.
201+
196202
If you try to update several subdomains by issuing multiple update requests,
197203
your update requests may be refused (see :ref:`rate-limits`).

0 commit comments

Comments
 (0)