diff --git a/api/desecapi/tests/test_dyndns12update.py b/api/desecapi/tests/test_dyndns12update.py index 49af26d25..528ed0b77 100644 --- a/api/desecapi/tests/test_dyndns12update.py +++ b/api/desecapi/tests/test_dyndns12update.py @@ -330,6 +330,101 @@ def test_update_multiple_v4(self): self.assertIP(ipv4=new_ip) self.assertIP(subname="sub", ipv4=new_ip) + def test_update_multiple_with_overwrite(self): + # /nic/update?hostname=sub1.a.io,sub2.a.io,sub3.a.io&myip=1.2.3.4&ipv6=::1&myipv6:sub2.a.io=::2 + new_ip4 = "1.2.3.4" + new_ip6 = "::1" + new_ip6_overwrite = "::2" + domain1 = "sub1." + self.my_domain.name + domain2 = "sub2." + self.my_domain.name + domain3 = "sub3." + self.my_domain.name + + with self.assertRequests( + self.request_pdns_zone_update(self.my_domain.name), + self.request_pdns_zone_axfr(self.my_domain.name), + ): + response = self.client.get( + self.reverse("v1:dyndns12update"), + { + "hostname": f"{domain1},{domain2},{domain3}", + "myip": new_ip4, + "ipv6": new_ip6, + f"myipv6:{domain2}": new_ip6_overwrite, + }, + ) + + self.assertStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data, "good") + + self.assertIP(subname="sub1", ipv4=new_ip4, ipv6=new_ip6) + self.assertIP(subname="sub2", ipv4=new_ip4, ipv6=new_ip6_overwrite) + self.assertIP(subname="sub3", ipv4=new_ip4, ipv6=new_ip6) + + def test_update_multiple_with_extra(self): + # /nic/update?hostname=sub1.a.io,sub3.a.io&myip=1.2.3.4&ipv6=::1&myipv6:sub2.a.io=::2 + old_ip4 = "10.0.0.2" + new_ip4 = "1.2.3.4" + new_ip6 = "::1" + new_ip6_extra = "::2" + domain1 = "sub1." + self.my_domain.name + domain2 = "sub2." + self.my_domain.name + domain3 = "sub3." + self.my_domain.name + self.create_rr_set(self.my_domain, [old_ip4], subname="sub2", type="A", ttl=60) + + with self.assertRequests( + self.request_pdns_zone_update(self.my_domain.name), + self.request_pdns_zone_axfr(self.my_domain.name), + ): + response = self.client.get( + self.reverse("v1:dyndns12update"), + { + "hostname": f"{domain1},{domain3}", + f"myipv6:{domain2}": new_ip6_extra, + "myip": new_ip4, + "ipv6": new_ip6, + }, + ) + + self.assertStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data, "good") + + self.assertIP(subname="sub1", ipv4=new_ip4, ipv6=new_ip6) + self.assertIP(subname="sub2", ipv4=old_ip4, ipv6=new_ip6_extra) + self.assertIP(subname="sub3", ipv4=new_ip4, ipv6=new_ip6) + + def test_update_multiple_with_same_argument_domain_twice(self): + # /nic/update?hostname=sub1.a.io&myip=1.2.3.4&myipv6:sub1.a.io=::1&myipv6:sub1.a.io,sub2.a.io,sub3.a.io=::2 + old_ip4 = "10.0.0.2" + new_ip4 = "1.2.3.4" + new_ip6 = "::1" + new_ip6_extra = "::2" + domain1 = "sub1." + self.my_domain.name + domain2 = "sub2." + self.my_domain.name + domain3 = "sub3." + self.my_domain.name + self.create_rr_set(self.my_domain, [old_ip4], subname="sub2", type="A", ttl=60) + self.create_rr_set(self.my_domain, [old_ip4], subname="sub3", type="A", ttl=60) + + with self.assertRequests( + self.request_pdns_zone_update(self.my_domain.name), + self.request_pdns_zone_axfr(self.my_domain.name), + ): + response = self.client.get( + self.reverse("v1:dyndns12update"), + { + "hostname": f"{domain1}", + "myip": new_ip4, + f"myipv6:{domain1}": new_ip6, + f"myipv6:{domain1},{domain2},{domain3}": new_ip6_extra, + }, + ) + + self.assertStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data, "good") + + self.assertIP(subname="sub1", ipv4=new_ip4, ipv6=new_ip6_extra) + self.assertIP(subname="sub2", ipv4=old_ip4, ipv6=new_ip6_extra) + self.assertIP(subname="sub3", ipv4=old_ip4, ipv6=new_ip6_extra) + def test_update_multiple_username_param(self): # /nic/update?username=a.io,sub.a.io&myip=1.2.3.4 new_ip = "1.2.3.4" @@ -403,6 +498,36 @@ def test_update_multiple_with_subnet(self): self.assertIP(subname="sub1", ipv4="10.1.0.1") self.assertIP(subname="sub2", ipv4="10.1.0.2") + def test_update_multiple_with_subnet_and_ip_override(self): + # /nic/update?hostname=a.io,b.io&myip=10.1.0.0/16&a.io=192.168.1.1 + domain1 = "sub1." + self.my_domain.name + domain2 = "sub2." + self.my_domain.name + self.create_rr_set( + self.my_domain, ["10.0.0.1"], subname="sub1", type="A", ttl=60 + ) + self.create_rr_set( + self.my_domain, ["10.0.0.2"], subname="sub2", type="A", ttl=60 + ) + + with self.assertRequests( + self.request_pdns_zone_update(self.my_domain.name), + self.request_pdns_zone_axfr(self.my_domain.name), + ): + response = self.client.get( + self.reverse("v1:dyndns12update"), + { + "hostname": f"{domain1},{domain2}", + "myip": "10.1.0.0/16", + f"myipv4:{domain1}": "192.168.1.1", + }, + ) + + self.assertStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data, "good") + + self.assertIP(subname="sub1", ipv4="192.168.1.1") + self.assertIP(subname="sub2", ipv4="10.1.0.2") + def test_update_multiple_with_one_being_already_up_to_date(self): # /nic/update?hostname=a.io,sub.a.io&myip=1.2.3.4 new_ip = "1.2.3.4" @@ -446,6 +571,20 @@ def test_update_same_domain_twice(self): self.assertIP(ipv4=new_ip) + def test_update_overwrite_with_invalid_subnet(self): + # /nic/update?hostname=a.io&myipv4:a.io=1.2.3.4/64 + domain1 = self.create_domain(owner=self.owner).name + + with self.assertRequests(): + response = self.client.get( + self.reverse("v1:dyndns12update"), + {"hostname": f"{domain1}", f"myipv4:{domain1}": "1.2.3.4/64"}, + ) + + self.assertContains( + response, "invalid subnet", status_code=status.HTTP_400_BAD_REQUEST + ) + def test_update_multiple_with_invalid_subnet(self): # /nic/update?hostname=sub1.a.io,sub2.a.io&myip=1.2.3.4/64 domain1 = "sub1." + self.my_domain.name diff --git a/api/desecapi/views/dyndns.py b/api/desecapi/views/dyndns.py index 10258d751..c289a9188 100644 --- a/api/desecapi/views/dyndns.py +++ b/api/desecapi/views/dyndns.py @@ -76,53 +76,18 @@ def _find_action(self, param_keys, separator) -> UpdateAction: as a fallback. Returns: - UpdateAction: A dataclass instance (`SetIPs`, `UpdateWithSubnet`, or `PreserveIPs`) - representing the action to be taken. + UpdateAction: A dataclass instance representing the action to be taken. """ # Check URL parameters for param_key in param_keys: try: - params = set( - filter( - lambda param: separator in param or param in ("", "preserve"), - map(str.strip, self.request.query_params[param_key].split(",")), - ) - ) + param_value = self.request.query_params[param_key] except KeyError: continue - if not params: - continue - try: - (param,) = params # unpacks if params has exactly one element - except ValueError: # more than one element - if params & {"", "preserve"}: - raise ValidationError( - detail=f'IP parameter "{param_key}" cannot have addresses and "preserve" or an empty value at the same time.', - code="inconsistent-parameter", - ) - if any("/" in param for param in params): - raise ValidationError( - detail=f'IP parameter "{param_key}" cannot use subnet notation with multiple addresses.', - code="multiple-subnet", - ) - else: # one element - match param: - case "": - return SetIPs(ips=[]) - case "preserve": - return PreserveIPs() - case str(x) if "/" in x: - try: - subnet = ip_network(param, strict=False) - return UpdateWithSubnet(subnet=subnet) - except ValueError as e: - raise ValidationError( - detail=f'IP parameter "{param_key}" is an invalid subnet: {e}', - code="invalid-subnet", - ) - - return SetIPs(ips=list(params)) + action = self._get_action_from_param(param_key, param_value, separator) + if action is not None: + return action # Check remote IP address client_ip = self.request.META.get("REMOTE_ADDR") @@ -132,6 +97,72 @@ def _find_action(self, param_keys, separator) -> UpdateAction: # give up return SetIPs(ips=[]) + @staticmethod + def _get_action_from_param( + param_key: str, param_value: str, separator: str + ) -> UpdateAction | None: + """ + Parses a single query parameter value to determine the DynDNS update action. + + This function is responsible for interpreting the `param_value` (which can be a single IP, + a comma-separated list of IPs, 'preserve', or a CIDR subnet) and converting it into + a structured UpdateAction dataclass. It also performs validation on the parameter's format. + + Args: + param_key: The name of the query parameter (e.g., 'myip', 'myipv4', 'myipv6', or a qname for extra actions). + Used for error messages. + param_value: The string value of the query parameter (e.g., '1.2.3.4', '1.2.3.4,5.6.7.8', + '192.168.1.0/24', 'preserve', or ''). + separator: The character used to distinguish IP versions (e.g., '.' for IPv4, ':' for IPv6). + + Returns: + An instance of SetIPs, UpdateWithSubnet, PreserveIPs, or None if no valid action can be + derived from the parameter (e.g., an IPv4 address was given, but IPv6 is required by the separator). + Returns SetIPs(ips=[]) if param_value is an empty string. + + Raises: + ValidationError: If param_value is invalid (e.g., 'preserve' with addresses) + """ + params = set( + filter( + lambda param: separator in param or param in ("", "preserve"), + map(str.strip, param_value.split(",")), + ) + ) + if not params: + return None + + try: + (param,) = params # unpacks if params has exactly one element + except ValueError: # more than one element + if params & {"", "preserve"}: + raise ValidationError( + detail=f'IP parameter "{param_key}" cannot have addresses and "preserve" or an empty value at the same time.', + code="inconsistent-parameter", + ) + if any("/" in param for param in params): + raise ValidationError( + detail=f'IP parameter "{param_key}" cannot use subnet notation with multiple addresses.', + code="multiple-subnet", + ) + else: # one element + match param: + case "": + return SetIPs(ips=[]) + case "preserve": + return PreserveIPs() + case str(x) if "/" in x: + try: + subnet = ip_network(param, strict=False) + return UpdateWithSubnet(subnet=subnet) + except ValueError as e: + raise ValidationError( + detail=f'IP parameter "{param_key}" is an invalid subnet: {e}', + code="invalid-subnet", + ) + + return SetIPs(ips=list(params)) + @staticmethod def _sanitize_qnames(qnames_str) -> set[str]: qnames = qnames_str.lower().split(",") @@ -188,11 +219,38 @@ def qnames(self) -> set[str]: } ) + @cached_property + def extra_qname_params(self) -> dict[str, dict[str, str]]: + """ + Parses query parameters of the form 'myipv4:qname' or 'myipv6:qname' + to extract additional qnames and their associated update arguments. + + Returns: + A dictionary where keys are qnames (e.g., 'sub.example.com') and values + are dictionaries mapping RR type ('A' or 'AAAA') to the raw query parameter + value (e.g., {'A': '1.2.3.4,5.6.7.8'} or {'AAAA': 'preserve'}). + """ + qnames = defaultdict(dict) + param_prefix_types = {"myipv4": "A", "myipv6": "AAAA"} + + for param, value in self.request.query_params.items(): + try: + param_prefix, param_suffix = param.split(":", 1) + except ValueError: + continue + type_ = param_prefix_types[param_prefix] + + for qname in self._sanitize_qnames(param_suffix): + qnames[qname][type_] = value + + return qnames + @cached_property def domain(self) -> Domain: + qnames = self.qnames | self.extra_qname_params.keys() qname_qs = ( Domain.objects.filter_qname(qname, owner=self.request.user) - for qname in self.qnames + for qname in qnames ) domains = ( Domain.objects.none() @@ -200,7 +258,7 @@ def domain(self) -> Domain: .all() ) - if len(domains) != len(self.qnames): + if len(domains) != len(qnames): # Some qname doesn't map to a domain metrics.get("desecapi_dynDNS12_domain_not_found").inc() raise NotFound("nohost") @@ -218,6 +276,26 @@ def domain(self) -> Domain: def subnames(self) -> list[str]: return [qname.rpartition(f".{self.domain.name}")[0] for qname in self.qnames] + @cached_property + def extra_actions(self) -> dict[tuple[str, str], UpdateAction]: + """ + Converts the raw string arguments from `extra_qname_params` into structured `UpdateAction` objects. + + Returns: + A dictionary where keys are `(type, subname)` tuples (e.g., ('A', 'sub')) + and values are `UpdateAction` instances (SetIPs, UpdateWithSubnet, PreserveIPs). + """ + return { + ( + type_, + qname.rpartition(f".{self.domain.name}")[0], + ): self._get_action_from_param( + qname, param_value, "." if type_ == "A" else ":" + ) + for qname, param_values_by_type in self.extra_qname_params.items() + for type_, param_value in param_values_by_type.items() + } + def get_serializer_context(self): return { **super().get_serializer_context(), @@ -226,8 +304,12 @@ def get_serializer_context(self): } def get_queryset(self): + subnames = [ + *self.subnames, + *[subname for (_, subname) in self.extra_actions.keys()], + ] return self.domain.rrset_set.filter( - subname__in=self.subnames, type__in=["A", "AAAA"] + subname__in=subnames, type__in=["A", "AAAA"] ).prefetch_related("records") @staticmethod @@ -261,6 +343,12 @@ def get(self, request, *args, **kwargs) -> Response: "A": self._find_action(["myip", "myipv4", "ip"], separator="."), "AAAA": self._find_action(["myipv6", "ipv6", "myip", "ip"], separator=":"), } + subname_actions = { + (type_, subname): action + for subname in self.subnames + for type_, action in actions.items() + } + subname_actions.update(self.extra_actions) data = [ { @@ -269,8 +357,7 @@ def get(self, request, *args, **kwargs) -> Response: "ttl": 60, "records": records, } - for subname in self.subnames - for type_, action in actions.items() + for (type_, subname), action in subname_actions.items() if (records := self._get_records(subname_records[subname], action)) is not None ] diff --git a/docs/dyndns/configure.rst b/docs/dyndns/configure.rst index 9faa6f290..4655d2a55 100644 --- a/docs/dyndns/configure.rst +++ b/docs/dyndns/configure.rst @@ -193,5 +193,11 @@ syntax.) For example, when using ddclient, you can edit ``ddclient.conf`` and se case the IPv4 address of both ``domain.org`` and ``sub.domain.org`` will be updated while preserving any (different) IPv6 addresses. +For Fritz!Box devices, an update URL could look like this: +``https://update.dedyn.io/?myipv4=&myipv6=&myipv6:host.=`` +This example updates the main domain with the router's global IPv4 and IPv6 +addresses, and additionally updates the subdomain `host.` with an IPv6 +LAN prefix. + If you try to update several subdomains by issuing multiple update requests, your update requests may be refused (see :ref:`rate-limits`). diff --git a/docs/dyndns/update-api.rst b/docs/dyndns/update-api.rst index a073e1f26..e8f63e7f9 100644 --- a/docs/dyndns/update-api.rst +++ b/docs/dyndns/update-api.rst @@ -98,6 +98,9 @@ If we cannot determine a hostname to update, the API returns a status code of ``400 Bad Request`` (if no hostname was given but multiple domains exist in the account) or ``404 Not Found`` (if the specified domain was not found). +For assigning different IP addresses to multiple hostnames in one dynDNS +update request, see `Per-hostname IP Address Specification`_ below. + Subdomains ---------- The dynDNS update API can also be used to update IP records for subdomains. @@ -109,6 +112,9 @@ Authentication. In this case, replace your authentication username with ``sub.yourdomain.dedyn.io``. Similarly, if you use the ``hostname`` query parameter, it needs to be set to the full domain name (including subdomain). +For assigning different IP addresses to multiple subdomains in one dynDNS +update request, see `Per-hostname IP Address Specification`_ below. + .. _determine-ip-addresses: Determine IP Address(es) @@ -160,6 +166,29 @@ The host part of the value is ignored. Note that using an encrypted connection (TLS) does *not* protect against this attack, as TLS does not protect the IP address. + +.. _ip-per-hostname: + +Per-hostname IP Address Specification +------------------------------------- +For updates involving multiple hostnames (see `Determine Hostname`_ above), it is +possible to specify IP addresses on a per-hostname basis. This is done by +appending the hostname to the IP parameter, separated by a colon. + +For example, to set the IPv4 address for ``sub.example.com`` to ``1.2.3.4``, +you would add ``myipv4:sub.example.com=1.2.3.4`` to the query string. +The ``myipv6`` parameter can be used in the same way. + +This allows updating multiple hostnames with different IP addresses in a single +request. If a per-hostname IP parameter is given, it takes precedence over the +general IP parameters (like ``myip``, ``myipv4``, ``myipv6``) for that specific +hostname. +Semantics is the same as for these general parameters; in particular, the +``preserve`` keyword and comma-separated lists of IP addresses are supported. + +All other hostnames in the request will be updated with the IP addresses from +the general parameters. All hostnames must belong to the same domain. + Update Response ``````````````` If successful, the server will return a response with status ``200 OK`` and @@ -228,3 +257,8 @@ Update multiple domains simultaneously:: curl "https://update.dedyn.io/?hostname=,&myip=1.2.3.4" \ --header "Authorization: Token " + +Update multiple domains with different IP addresses (preserves IPv6 address of subdomain):: + + curl "https://update.dedyn.io/?hostname=&myipv4=1.2.3.4&myipv4:=5.6.7.8" \ + --header "Authorization: Token "