Skip to content

Commit bd8d863

Browse files
committed
feat(api): Allow overwriting IP address for specific (sub)domain in multi-update
1 parent 7f753a9 commit bd8d863

File tree

3 files changed

+123
-9
lines changed

3 files changed

+123
-9
lines changed

api/desecapi/tests/test_dyndns12update.py

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

334+
def test_update_multiple_with_overwrite(self):
335+
# /nic/update?hostname=sub1.a.io,sub2.a.io,sub3.a.io&myip=1.2.3.4&ipv6=::1&b.io.ipv6=::2
336+
new_ip4 = "1.2.3.4"
337+
new_ip6 = "::1"
338+
new_ip6_overwrite = "::2"
339+
domain1 = "sub1." + self.my_domain.name
340+
domain2 = "sub2." + self.my_domain.name
341+
domain3 = "sub3." + self.my_domain.name
342+
343+
with self.assertRequests(
344+
self.request_pdns_zone_update(self.my_domain.name),
345+
self.request_pdns_zone_axfr(self.my_domain.name),
346+
):
347+
response = self.client.get(
348+
self.reverse("v1:dyndns12update"),
349+
{
350+
"hostname": f"{domain1},{domain2},{domain3}",
351+
"myip": new_ip4,
352+
"ipv6": new_ip6,
353+
f"{domain2.lower()}.ipv6": new_ip6_overwrite,
354+
},
355+
)
356+
357+
self.assertStatus(response, status.HTTP_200_OK)
358+
self.assertEqual(response.data, "good")
359+
360+
self.assertIP(subname="sub1", ipv4=new_ip4, ipv6=new_ip6)
361+
self.assertIP(subname="sub2", ipv4=new_ip4, ipv6=new_ip6_overwrite)
362+
self.assertIP(subname="sub3", ipv4=new_ip4, ipv6=new_ip6)
363+
334364
def test_update_multiple_username_param(self):
335365
# /nic/update?username=a.io,sub.a.io&myip=1.2.3.4
336366
new_ip = "1.2.3.4"
@@ -404,6 +434,36 @@ def test_update_multiple_with_subnet(self):
404434
self.assertIP(subname="sub1", ipv4="10.1.0.1")
405435
self.assertIP(subname="sub2", ipv4="10.1.0.2")
406436

437+
def test_update_multiple_with_subnet_and_ip_override(self):
438+
# /nic/update?hostname=a.io,b.io&myip=10.1.0.0/16&a.io=192.168.1.1
439+
domain1 = "sub1." + self.my_domain.name
440+
domain2 = "sub2." + self.my_domain.name
441+
self.create_rr_set(
442+
self.my_domain, ["10.0.0.1"], subname="sub1", type="A", ttl=60
443+
)
444+
self.create_rr_set(
445+
self.my_domain, ["10.0.0.2"], subname="sub2", type="A", ttl=60
446+
)
447+
448+
with self.assertRequests(
449+
self.request_pdns_zone_update(self.my_domain.name),
450+
self.request_pdns_zone_axfr(self.my_domain.name),
451+
):
452+
response = self.client.get(
453+
self.reverse("v1:dyndns12update"),
454+
{
455+
"hostname": f"{domain1},{domain2}",
456+
"myip": "10.1.0.0/16",
457+
f"{domain1.lower()}.myip": "192.168.1.1",
458+
},
459+
)
460+
461+
self.assertStatus(response, status.HTTP_200_OK)
462+
self.assertEqual(response.data, "good")
463+
464+
self.assertIP(subname="sub1", ipv4="192.168.1.1")
465+
self.assertIP(subname="sub2", ipv4="10.1.0.2")
466+
407467
def test_update_multiple_with_one_being_already_up_to_date(self):
408468
# /nic/update?hostname=a.io,sub.a.io&myip=1.2.3.4
409469
new_ip = "1.2.3.4"
@@ -447,6 +507,20 @@ def test_update_same_domain_twice(self):
447507

448508
self.assertIP(ipv4=new_ip)
449509

510+
def test_update_overwrite_with_invalid_subnet(self):
511+
# /nic/update?hostname=a.io&a.io.myip=1.2.3.4/64
512+
domain1 = self.create_domain(owner=self.owner).name
513+
514+
with self.assertRequests():
515+
response = self.client.get(
516+
self.reverse("v1:dyndns12update"),
517+
{"hostname": f"{domain1}", f"{domain1.lower()}.myip": "1.2.3.4/64"},
518+
)
519+
520+
self.assertContains(
521+
response, "invalid subnet", status_code=status.HTTP_400_BAD_REQUEST
522+
)
523+
450524
def test_update_multiple_with_invalid_subnet(self):
451525
# /nic/update?hostname=sub1.a.io,sub2.a.io&myip=1.2.3.4/64
452526
domain1 = "sub1." + self.my_domain.name

api/desecapi/views/dyndns.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ class DynDNS12UpdateView(generics.GenericAPIView):
6262
serializer_class = RRsetSerializer
6363
throttle_scope = "dyndns"
6464

65+
IPV4_PARAMS = ["myip", "myipv4", "ip"]
66+
IPV6_PARAMS = ["myipv6", "ipv6", "myip", "ip"]
67+
6568
@property
6669
def throttle_scope_bucket(self):
6770
return self.domain.name
@@ -70,11 +73,17 @@ def _find_action(self, param_keys, separator) -> UpdateAction:
7073
"""
7174
Parses the request for IP parameters and determines the appropriate update action.
7275
73-
This method checks a given list of parameter keys in the request URL. It handles
74-
plain IP addresses, comma-separated lists of IPs, the "preserve" keyword, and
75-
subnet notation (e.g., "10.0.0.0/24"). It also uses the client's remote IP
76+
This method checks a given list of parameter keys in the request URL. The keys can
77+
be global (e.g. ['myip']) or scoped to a specific hostname (e.g. ['example.com.myip']).
78+
79+
It handles plain IP addresses, comma-separated lists of IPs, the "preserve" keyword,
80+
and subnet notation (e.g., "10.0.0.0/24"). It also uses the client's remote IP
7681
as a fallback.
7782
83+
Args:
84+
param_keys (list): A list of parameter keys to check for in the request.
85+
separator (str): The IP address separator ("." for IPv4, ":" for IPv6).
86+
7887
Returns:
7988
UpdateAction: A dataclass instance (`SetIPs`, `UpdateWithSubnet`, or `PreserveIPs`)
8089
representing the action to be taken.
@@ -257,10 +266,17 @@ def get(self, request, *args, **kwargs) -> Response:
257266
for rrset in instances:
258267
subname_records[rrset.subname].extend(rrset.records.all())
259268

260-
actions = {
261-
"A": self._find_action(["myip", "myipv4", "ip"], separator="."),
262-
"AAAA": self._find_action(["myipv6", "ipv6", "myip", "ip"], separator=":"),
263-
}
269+
actions = {}
270+
for subname in self.subnames:
271+
fqdn = ".".join(filter(None, (subname, self.domain.name)))
272+
actions[("A", subname)] = self._find_action(
273+
[f"{fqdn}.{p}" for p in self.IPV4_PARAMS] + self.IPV4_PARAMS,
274+
separator="."
275+
)
276+
actions[("AAAA", subname)] = self._find_action(
277+
[f"{fqdn}.{p}" for p in self.IPV6_PARAMS] + self.IPV6_PARAMS,
278+
separator=":"
279+
)
264280

265281
data = [
266282
{
@@ -269,8 +285,7 @@ def get(self, request, *args, **kwargs) -> Response:
269285
"ttl": 60,
270286
"records": records,
271287
}
272-
for subname in self.subnames
273-
for type_, action in actions.items()
288+
for (type_, subname), action in actions.items()
274289
if (records := self._get_records(subname_records[subname], action))
275290
is not None
276291
]

docs/dyndns/update-api.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,31 @@ query parameter, such as ``ipv6=2a01:a:b:c::1/64``.
159159
Note that using an encrypted connection (TLS) does *not* protect against
160160
this attack, as TLS does not protect the IP address.
161161

162+
Per-Hostname IP Addresses
163+
-------------------------
164+
When updating multiple hostnames at once, it is possible to specify different
165+
IP information for each hostname. This is done by prefixing the IP parameter
166+
with the hostname it applies to.
167+
168+
For example, to set a global IPv4 address for all hostnames but a specific
169+
IPv6 address for ``host2.example.com``, you would send a request like this::
170+
171+
?hostname=host1.example.com,host2.example.com
172+
&myip=1.2.3.4
173+
&host2.example.com.myipv6=2001:db8::1
174+
175+
This would set the IPv4 address of ``host1.example.com`` and ``host2.example.com``
176+
to ``1.2.3.4``. Additionally, there would be an IPv6 address with the value
177+
``2001:db8::1`` set for ``host2.example.com``.
178+
179+
For each hostname, the server will look for IP parameters in the following order:
180+
181+
1. A parameter prefixed with that specific hostname (e.g., ``host2.example.com.myipv6``).
182+
2. A global, non-prefixed parameter (e.g., ``myip``).
183+
3. The remote IP address of the client making the request (if applicable).
184+
185+
This allows for flexible and powerful combinations of updates in a single API call.
186+
162187
Update Response
163188
```````````````
164189
If successful, the server will return a response with status ``200 OK`` and

0 commit comments

Comments
 (0)