Skip to content

Commit 62d989e

Browse files
committed
feat(api): Allow overwriting IP address for specific (sub)domain in multi-update
1 parent 176b003 commit 62d989e

File tree

3 files changed

+140
-15
lines changed

3 files changed

+140
-15
lines changed

api/desecapi/tests/test_dyndns12update.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,36 @@ def test_update_multiple_v4(self):
317317
self.assertIP(ipv4=new_ip)
318318
self.assertIP(subname="sub", ipv4=new_ip)
319319

320+
def test_update_multiple_with_overwrite(self):
321+
# /nic/update?hostname=sub1.a.io,sub2.a.io,sub3.a.io&myip=1.2.3.4&ipv6=::1&b.io.ipv6=::2
322+
new_ip4 = "1.2.3.4"
323+
new_ip6 = "::1"
324+
new_ip6_overwrite = "::2"
325+
domain1 = "sub1." + self.my_domain.name
326+
domain2 = "sub2." + self.my_domain.name
327+
domain3 = "sub3." + self.my_domain.name
328+
329+
with self.assertRequests(
330+
self.request_pdns_zone_update(self.my_domain.name),
331+
self.request_pdns_zone_axfr(self.my_domain.name),
332+
):
333+
response = self.client.get(
334+
self.reverse("v1:dyndns12update"),
335+
{
336+
"hostname": f"{domain1},{domain2},{domain3}",
337+
"myip": new_ip4,
338+
"ipv6": new_ip6,
339+
f"{domain2.lower()}.ipv6": new_ip6_overwrite,
340+
},
341+
)
342+
343+
self.assertStatus(response, status.HTTP_200_OK)
344+
self.assertEqual(response.data, "good")
345+
346+
self.assertIP(subname="sub1", ipv4=new_ip4, ipv6=new_ip6)
347+
self.assertIP(subname="sub2", ipv4=new_ip4, ipv6=new_ip6_overwrite)
348+
self.assertIP(subname="sub3", ipv4=new_ip4, ipv6=new_ip6)
349+
320350
def test_update_multiple_username_param(self):
321351
# /nic/update?username=a.io,sub.a.io&myip=1.2.3.4
322352
new_ip = "1.2.3.4"
@@ -390,6 +420,36 @@ def test_update_multiple_with_subnet(self):
390420
self.assertIP(subname="sub1", ipv4="10.1.0.1")
391421
self.assertIP(subname="sub2", ipv4="10.1.0.2")
392422

423+
def test_update_multiple_with_subnet_and_ip_override(self):
424+
# /nic/update?hostname=a.io,b.io&myip=10.1.0.0/16&a.io=192.168.1.1
425+
domain1 = "sub1." + self.my_domain.name
426+
domain2 = "sub2." + self.my_domain.name
427+
self.create_rr_set(
428+
self.my_domain, ["10.0.0.1"], subname="sub1", type="A", ttl=60
429+
)
430+
self.create_rr_set(
431+
self.my_domain, ["10.0.0.2"], subname="sub2", type="A", ttl=60
432+
)
433+
434+
with self.assertRequests(
435+
self.request_pdns_zone_update(self.my_domain.name),
436+
self.request_pdns_zone_axfr(self.my_domain.name),
437+
):
438+
response = self.client.get(
439+
self.reverse("v1:dyndns12update"),
440+
{
441+
"hostname": f"{domain1},{domain2}",
442+
"myip": "10.1.0.0/16",
443+
f"{domain1.lower()}.myip": "192.168.1.1",
444+
},
445+
)
446+
447+
self.assertStatus(response, status.HTTP_200_OK)
448+
self.assertEqual(response.data, "good")
449+
450+
self.assertIP(subname="sub1", ipv4="192.168.1.1")
451+
self.assertIP(subname="sub2", ipv4="10.1.0.2")
452+
393453
def test_update_multiple_with_one_being_already_up_to_date(self):
394454
# /nic/update?hostname=a.io,sub.a.io&myip=1.2.3.4
395455
new_ip = "1.2.3.4"
@@ -433,6 +493,18 @@ def test_update_same_domain_twice(self):
433493

434494
self.assertIP(ipv4=new_ip)
435495

496+
def test_update_overwrite_with_invalid_subnet(self):
497+
# /nic/update?hostname=a.io&a.io.myip=1.2.3.4/64
498+
domain1 = self.create_domain(owner=self.owner).name
499+
500+
with self.assertRequests():
501+
response = self.client.get(
502+
self.reverse("v1:dyndns12update"),
503+
{"hostname": f"{domain1}", f"{domain1.lower()}.myip": "1.2.3.4/64"},
504+
)
505+
506+
self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
507+
436508
def test_update_multiple_with_invalid_subnet(self):
437509
# /nic/update?hostname=sub1.a.io,sub2.a.io&myip=1.2.3.4/64
438510
domain1 = "sub1." + self.my_domain.name

api/desecapi/views/dyndns.py

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -62,22 +62,35 @@ 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
6871

69-
def _find_action(self, param_keys, separator) -> UpdateAction:
72+
def _find_action(
73+
self, param_keys, separator, use_remote_ip_fallback=False
74+
) -> UpdateAction:
7075
"""
7176
Parses the request for IP parameters and determines the appropriate update action.
7277
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-
as a fallback.
78+
This method checks a given list of parameter keys in the request URL. The keys can
79+
be global (e.g. ['myip']) or scoped to a specific hostname (e.g. ['example.com.myip']).
80+
81+
It handles plain IP addresses, comma-separated lists of IPs, the "preserve" keyword,
82+
and subnet notation (e.g., "10.0.0.0/24").
83+
84+
Args:
85+
param_keys (list): A list of parameter keys to check for in the request.
86+
separator (str): The IP address separator ("." for IPv4, ":" for IPv6).
87+
use_remote_ip_fallback (bool): If True, uses the client's remote IP as a
88+
fallback if no other parameters are found.
7789
7890
Returns:
79-
UpdateAction: A dataclass instance (`SetIPs`, `UpdateWithSubnet`, or `PreserveIPs`)
80-
representing the action to be taken.
91+
UpdateAction or None: A dataclass instance (`SetIPs`, `UpdateWithSubnet`, or
92+
`PreserveIPs`) representing the action to be taken, or None if no relevant
93+
parameter was found and the fallback to client IP is disabled.
8194
"""
8295
# Check URL parameters
8396
for param_key in param_keys:
@@ -119,12 +132,13 @@ def _find_action(self, param_keys, separator) -> UpdateAction:
119132
return SetIPs(ips=params)
120133

121134
# Check remote IP address
122-
client_ip = self.request.META.get("REMOTE_ADDR")
123-
if separator in client_ip:
124-
return SetIPs(ips=[client_ip])
135+
if use_remote_ip_fallback:
136+
client_ip = self.request.META.get("REMOTE_ADDR")
137+
if separator in client_ip:
138+
return SetIPs(ips=[client_ip])
125139

126140
# give up
127-
return SetIPs(ips=[])
141+
return None
128142

129143
@staticmethod
130144
def _sanitize_qnames(qnames_str) -> set[str]:
@@ -245,12 +259,26 @@ def get(self, request, *args, **kwargs) -> Response:
245259
grouped_records[rrset.subname].extend(rrset.records.all())
246260

247261
actions = {
248-
"A": self._find_action(["myip", "myipv4", "ip"], separator="."),
249-
"AAAA": self._find_action(["myipv6", "ipv6", "myip", "ip"], separator=":"),
262+
"A": self._find_action(
263+
self.IPV4_PARAMS, separator=".", use_remote_ip_fallback=True
264+
)
265+
or SetIPs(ips=[]),
266+
"AAAA": self._find_action(
267+
self.IPV6_PARAMS, separator=":", use_remote_ip_fallback=True
268+
)
269+
or SetIPs(ips=[]),
250270
}
251271

252272
data = []
253-
for subname in self.subnames:
273+
for qname, subname in zip(self.qnames, self.subnames):
274+
scoped_ipv4_params = [f"{qname}.{p}" for p in self.IPV4_PARAMS]
275+
scoped_ipv6_params = [f"{qname}.{p}" for p in self.IPV6_PARAMS]
276+
domain_actions = {
277+
"A": self._find_action(scoped_ipv4_params, separator=".")
278+
or actions["A"],
279+
"AAAA": self._find_action(scoped_ipv6_params, separator=":")
280+
or actions["AAAA"],
281+
}
254282
subname_records = grouped_records.get(subname, [])
255283

256284
data += [
@@ -260,7 +288,7 @@ def get(self, request, *args, **kwargs) -> Response:
260288
"ttl": 60,
261289
"records": records,
262290
}
263-
for type_, action in actions.items()
291+
for type_, action in domain_actions.items()
264292
if (records := self._get_records(subname_records, action)) is not None
265293
]
266294

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)