From d354ca71295d3a89f58bbf2b6df4a055140f1b6e Mon Sep 17 00:00:00 2001 From: molikuner Date: Sat, 19 Jul 2025 12:39:34 +0200 Subject: [PATCH 1/6] feat(api): Support updating multiple subdomains at once using dyndns API --- api/desecapi/models/records.py | 13 +- api/desecapi/tests/test_dyndns12update.py | 205 ++++++++++++++++++++++ api/desecapi/views/dyndns.py | 200 +++++++++++++++------ docs/dyndns/configure.rst | 28 ++- docs/dyndns/update-api.rst | 16 +- 5 files changed, 391 insertions(+), 71 deletions(-) diff --git a/api/desecapi/models/records.py b/api/desecapi/models/records.py index 87bda1ffb..6975d5ed5 100644 --- a/api/desecapi/models/records.py +++ b/api/desecapi/models/records.py @@ -56,24 +56,17 @@ ) -def replace_ip_subnet(queryset, subnet): +def replace_ip_subnet(records, subnet): """ - Fetches A or AAAA record contents from an RRset queryset (depending on the subnet's - address family) and returns them with their subnet bits replaced accordingly. + Takes a list of A or AAAA records and returns them with their subnet bits replaced accordingly. """ - subnet = ip_network(subnet, strict=False) - try: - records = queryset.get( - type={IPv4Network: "A", IPv6Network: "AAAA"}[type(subnet)] - ).records.all() - except ObjectDoesNotExist: - records = [] return [ str( ip_address(int(ip_address(record.content)) & int(subnet.hostmask)) # suffix + int(subnet.network_address) # prefix ) for record in records + if isinstance(subnet.network_address, type(ip_address(record.content))) ] diff --git a/api/desecapi/tests/test_dyndns12update.py b/api/desecapi/tests/test_dyndns12update.py index f74d2dfd7..6a6ed867e 100644 --- a/api/desecapi/tests/test_dyndns12update.py +++ b/api/desecapi/tests/test_dyndns12update.py @@ -161,6 +161,20 @@ def test_ddclient_dyndns2_v4_invalid(self): self.assertStatus(response, status.HTTP_400_BAD_REQUEST) self.assertIn("malformed", str(response.data)) + def test_ddclient_dyndns2_v4_valid_priority(self): + # /nic/update?system=dyndns&hostname=foobar.dedyn.io&myip=10.2.3.4asdf + params = { + "domain_name": self.my_domain.name, + "system": "dyndns", + "hostname": self.my_domain.name, + "myip": "invalid", + "ip": "10.4.2.1", + } + response = self.client.get(self.reverse("v1:dyndns12update"), params) + self.assertStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data, "good") + self.assertIP(ipv4="10.4.2.1") + def test_ddclient_dyndns2_v4_invalid_or_foreign_domain(self): # /nic/update?system=dyndns&hostname=<...>&myip=10.2.3.4 for name in [ @@ -296,6 +310,197 @@ def test_subnet(self): self.assertEqual(response.data, "good") self.assertIP(ipv6="2a01::3303:72dc:f412:7233", subname="foo") + def test_update_multiple_v4(self): + # /nic/update?hostname=a.io,sub.a.io&myip=1.2.3.4 + new_ip = "1.2.3.4" + domain1 = self.my_domain.name + domain2 = "sub." + self.my_domain.name + + with self.assertRequests( + self.request_pdns_zone_update(domain1), + self.request_pdns_zone_axfr(domain1), + ): + response = self.client.get( + self.reverse("v1:dyndns12update"), + {"hostname": f"{domain1},{domain2}", "myip": new_ip}, + ) + + self.assertStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data, "good") + + self.assertIP(ipv4=new_ip) + self.assertIP(subname="sub", ipv4=new_ip) + + 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" + domain1 = self.my_domain.name + domain2 = "sub." + self.my_domain.name + + with self.assertRequests( + self.request_pdns_zone_update(domain1), + self.request_pdns_zone_axfr(domain1), + ): + response = self.client_token_authorized.get( + self.reverse("v1:dyndns12update"), + {"username": f"{domain1},{domain2}", "myip": new_ip}, + ) + + self.assertStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data, "good") + + self.assertIP(ipv4=new_ip) + self.assertIP(subname="sub", ipv4=new_ip) + + def test_update_multiple_v4v6(self): + # /nic/update?hostname=a.io,sub.a.io&myip=1.2.3.4&myipv6=1::1 + new_ip4 = "1.2.3.4" + new_ip6 = "1::1" + domain1 = self.my_domain.name + domain2 = "sub." + domain1 + + with self.assertRequests( + self.request_pdns_zone_update(domain1), + self.request_pdns_zone_axfr(domain1), + ): + response = self.client.get( + self.reverse("v1:dyndns12update"), + { + "hostname": f"{domain1},{domain2}", + "myip": new_ip4, + "myipv6": new_ip6, + }, + ) + + self.assertStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data, "good") + + self.assertIP(ipv4=new_ip4, ipv6=new_ip6) + self.assertIP(subname="sub", ipv4=new_ip4, ipv6=new_ip6) + + def test_update_multiple_with_subnet(self): + # /nic/update?hostname=sub1.a.io,sub2.a.io&myip=10.1.0.0/16 + 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"}, + ) + + self.assertStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data, "good") + + self.assertIP(subname="sub1", ipv4="10.1.0.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" + domain1 = self.my_domain.name + domain2 = "sub." + domain1 + self.create_rr_set(self.my_domain, [new_ip], subname="sub", type="A", ttl=60) + + with self.assertRequests( + self.request_pdns_zone_update(domain1), + self.request_pdns_zone_axfr(domain1), + ): + response = self.client.get( + self.reverse("v1:dyndns12update"), + {"hostname": f"{domain1},{domain2}", "myip": new_ip}, + ) + + self.assertStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data, "good") + + self.assertIP(ipv4=new_ip) + self.assertIP(subname="sub", ipv4=new_ip) + + def test_update_same_domain_twice(self): + # /nic/update?hostname=foobar.dedyn.io,foobar.dedyn.io&myip=1.2.3.4 + new_ip = "1.2.3.4" + + 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"{self.my_domain.name},{self.my_domain.name}", + "myip": new_ip, + }, + ) + + self.assertStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data, "good") + + self.assertIP(ipv4=new_ip) + + 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 + domain2 = "sub2." + self.my_domain.name + + with self.assertRequests(): + response = self.client.get( + self.reverse("v1:dyndns12update"), + {"hostname": f"{domain1},{domain2}", "myip": "1.2.3.4/64"}, + ) + + self.assertContains( + response, "invalid subnet", status_code=status.HTTP_400_BAD_REQUEST + ) + + def test_update_multiple_with_subdomains_of_different_domains(self): + # /nic/update?hostname=a.io,b.io&myip=1.2.3.4 + domain1 = "sub1." + self.my_domain.name + domain2 = "sub2." + self.create_domain(owner=self.owner).name + + with self.assertRequests(): + response = self.client.get( + self.reverse("v1:dyndns12update"), + {"hostname": f"{domain1},{domain2}", "myip": "1.2.3.4"}, + ) + + self.assertContains( + response, + "Cannot update subdomains from more than one domain.", + status_code=status.HTTP_400_BAD_REQUEST, + ) + + def test_update_with_trailing_comma(self): + response = self.client_token_authorized.get( + self.reverse("v1:dyndns12update"), + {"host_id": f"{self.my_domain.name},", "myip": "1.2.3.4"}, + ) + + self.assertStatus(response, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.content, b"nohost") + + def test_update_with_partial_ownership(self): + with self.assertRequests(): + response = self.client.get( + self.reverse("v1:dyndns12update"), + { + "hostname": f"{self.my_domain.name},{self.other_domain.name}", + "myip": "1.2.3.4", + }, + ) + + self.assertStatus(response, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.content, b"nohost") + class SingleDomainDynDNS12UpdateTest(DynDNS12UpdateTest): NUM_OWNED_DOMAINS = 1 diff --git a/api/desecapi/views/dyndns.py b/api/desecapi/views/dyndns.py index 6949eab77..9a72e862a 100644 --- a/api/desecapi/views/dyndns.py +++ b/api/desecapi/views/dyndns.py @@ -1,5 +1,6 @@ import base64 import binascii +from collections import defaultdict from functools import cached_property from rest_framework import generics @@ -15,13 +16,41 @@ URLParamAuthentication, ) from desecapi.exceptions import ConcurrencyException -from desecapi.models import Domain, replace_ip_subnet +from desecapi.models import Domain, RR, replace_ip_subnet from desecapi.pdns_change_tracker import PDNSChangeTracker from desecapi.permissions import IsDomainOwner from desecapi.renderers import PlainTextRenderer from desecapi.serializers import RRsetSerializer +from dataclasses import dataclass +from ipaddress import ip_network, IPv4Network, IPv6Network + + +@dataclass +class SetIPs: + """Represents the action of setting specific IP addresses.""" + + ips: list[str] + + +@dataclass +class UpdateWithSubnet: + """Represents the action of updating IPs with a subnet.""" + + subnet: IPv4Network | IPv6Network + + +@dataclass +class PreserveIPs: + """Represents the action of leaving the IPs untouched.""" + + pass + + +UpdateAction = SetIPs | UpdateWithSubnet | PreserveIPs + + class DynDNS12UpdateView(generics.GenericAPIView): authentication_classes = ( TokenAuthentication, @@ -37,18 +66,36 @@ class DynDNS12UpdateView(generics.GenericAPIView): def throttle_scope_bucket(self): return self.domain.name - def _find_ip(self, param_keys, separator): + def _find_action(self, param_keys, separator) -> UpdateAction: + """ + Parses the request for IP parameters and determines the appropriate update action. + + This method checks a given list of parameter keys in the request URL. It handles + plain IP addresses, comma-separated lists of IPs, the "preserve" keyword, and + subnet notation (e.g., "10.0.0.0/24"). It also uses the client's remote IP + as a fallback. + + Returns: + UpdateAction: A dataclass instance (`SetIPs`, `UpdateWithSubnet`, or `PreserveIPs`) + representing the action to be taken. + """ # Check URL parameters for param_key in param_keys: try: - params = { - param.strip() - for param in self.request.query_params[param_key].split(",") - if separator in param or param.strip() in ("", "preserve") - } + params = set( + filter( + lambda param: separator in param or param in ("", "preserve"), + map(str.strip, self.request.query_params[param_key].split(",")), + ) + ) except KeyError: continue - if len(params) > 1: + 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" at the same time.', @@ -59,44 +106,55 @@ def _find_ip(self, param_keys, separator): detail=f'IP parameter "{param_key}" cannot use subnet notation with multiple addresses.', code="multiple-subnet", ) - if params: - params = list(params) - if len(params) == 1 and "/" in params[0]: - try: - params = replace_ip_subnet(self.get_queryset(), params[0]) - except ValueError as e: - raise ValidationError( - detail=f'IP parameter "{param_key}": {e}', - code="invalid-subnet", - ) - return [] if "" in params else params + 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)) # Check remote IP address client_ip = self.request.META.get("REMOTE_ADDR") if separator in client_ip: - return [client_ip] + return SetIPs(ips=[client_ip]) # give up - return [] + return SetIPs(ips=[]) + + @staticmethod + def _sanitize_qnames(qnames_str) -> set[str]: + qnames = qnames_str.lower().split(",") + return {name.strip() for name in qnames} @cached_property - def qname(self): + def qnames(self) -> set[str]: # hostname / host_id for param, reserved in { "hostname": ["", "YES"], "host_id": [], }.items(): try: - domain_name = self.request.query_params[param] + domain_names = self.request.query_params[param] except KeyError: pass else: - if domain_name not in reserved: - return domain_name.lower() + if domain_names not in reserved: + return self._sanitize_qnames(domain_names) # http basic auth username try: - domain_name = ( + domain_names = ( base64.b64decode( get_authorization_header(self.request) .decode() @@ -109,18 +167,19 @@ def qname(self): except (binascii.Error, IndexError, UnicodeDecodeError): pass else: - if domain_name and "@" not in domain_name: - return domain_name.lower() + if domain_names and "@" not in domain_names: + return self._sanitize_qnames(domain_names) # username parameter try: - return self.request.query_params["username"].lower() + domain_names = self.request.query_params["username"] + return self._sanitize_qnames(domain_names) except KeyError: pass # only domain associated with this user account try: - return self.request.user.domains.get().name + return {self.request.user.domains.get().name} except Domain.MultipleObjectsReturned: raise ValidationError( detail={ @@ -130,18 +189,34 @@ def qname(self): ) @cached_property - def domain(self): - try: - return Domain.objects.filter_qname( - self.qname, owner=self.request.user - ).order_by("-name_length")[0] - except (IndexError, ValueError, Domain.DoesNotExist): + def domain(self) -> Domain: + qname_qs = ( + Domain.objects.filter_qname(qname, owner=self.request.user) + for qname in self.qnames + ) + domains = ( + Domain.objects.none() + .union(*(qs.order_by("-name_length")[:1] for qs in qname_qs), all=True) + .all() + ) + + if len(domains) != len(self.qnames): metrics.get("desecapi_dynDNS12_domain_not_found").inc() raise NotFound("nohost") + if len({d.pk for d in domains}) > 1: + raise ValidationError( + detail={ + "detail": "Cannot update subdomains from more than one domain.", + "code": "cross-domain-update", + } + ) + + return domains[0] + @property - def subname(self): - return self.qname.rpartition(f".{self.domain.name}")[0] + def subnames(self) -> list[str]: + return [qname.rpartition(f".{self.domain.name}")[0] for qname in self.qnames] def get_serializer_context(self): return { @@ -152,26 +227,52 @@ def get_serializer_context(self): def get_queryset(self): return self.domain.rrset_set.filter( - subname=self.subname, type__in=["A", "AAAA"] - ) + subname__in=self.subnames, type__in=["A", "AAAA"] + ).prefetch_related("records") + + @staticmethod + def _get_records(records: list[RR], action: UpdateAction) -> list[str] | None: + """ + Determines the final list of IP address records based on the given action. - def get(self, request, *args, **kwargs): - instances = self.get_queryset().all() + Args: + records (list): A list of RR objects (for one RRset). + action (UpdateAction): The action to perform. - record_params = { - "A": self._find_ip(["myip", "myipv4", "ip"], separator="."), - "AAAA": self._find_ip(["myipv6", "ipv6", "myip", "ip"], separator=":"), + Returns: + list or None: A list of IP address strings, or None if the records should be preserved. + """ + match action: + case SetIPs(): + return action.ips + case UpdateWithSubnet(): + return replace_ip_subnet(records, action.subnet) + case PreserveIPs(): + return None + + def get(self, request, *args, **kwargs) -> Response: + instances = self.get_queryset() + + subname_records = defaultdict(list) + for rrset in instances: + subname_records[rrset.subname].extend(rrset.records.all()) + + actions = { + "A": self._find_action(["myip", "myipv4", "ip"], separator="."), + "AAAA": self._find_action(["myipv6", "ipv6", "myip", "ip"], separator=":"), } data = [ { "type": type_, - "subname": self.subname, + "subname": subname, "ttl": 60, - "records": ip_params, + "records": records, } - for type_, ip_params in record_params.items() - if "preserve" not in ip_params + for subname in self.subnames + for type_, action in actions.items() + if (records := self._get_records(subname_records[subname], action)) + is not None ] serializer = self.get_serializer(instances, data=data, many=True, partial=True) @@ -189,7 +290,6 @@ def get(self, request, *args, **kwargs): ): raise ConcurrencyException from e raise e - with PDNSChangeTracker(): serializer.save() diff --git a/docs/dyndns/configure.rst b/docs/dyndns/configure.rst index 7e1691720..9faa6f290 100644 --- a/docs/dyndns/configure.rst +++ b/docs/dyndns/configure.rst @@ -174,10 +174,24 @@ expected. Updating Multiple Domains ````````````````````````` -To update multiple domain or subdomains, it is best to designate one of them -as the main domain, and create CNAME records for the others, so that they act -as DNS aliases for the main domain. -You can use do that either via the web interface or the API. - -If you try to update several subdomains directly (by issuing multiple update -requests), your update requests may be refused (see :ref:`rate-limits`). +To have multiple domain names point to the same dynamic IP address, it is best +to designate one of them as the main domain for updates and create CNAME records +for the others. This way, the other domains will automatically resolve to the same +IP address as the main domain. + +Alternatively, for cases where a CNAME is not suitable (e.g. when the IPv4 address +is the same, but the IPv6 address differs), it is possible to update multiple +hostnames (such as ``example.com`` and ``sub.example.com``) in a single API call, +as long as they all belong to the same domain. It is not possible to update +hostnames belonging to different domains (e.g. ``example.com`` and ``example.org``) +in the same request. + +It's also possible to specify multiple (sub)domains for a single update request, +by separating them with a comma. (This requires your dynDNS client to accept this +syntax.) For example, when using ddclient, you can edit ``ddclient.conf`` and set +``domain.org,sub.domain.org&myipv6=preserve`` as the domain to update. In this +case the IPv4 address of both ``domain.org`` and ``sub.domain.org`` will be +updated while preserving any (different) IPv6 addresses. + +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 74a9011ff..3f3d4d557 100644 --- a/docs/dyndns/update-api.rst +++ b/docs/dyndns/update-api.rst @@ -73,7 +73,7 @@ the case, we suggest looking for another client. Determine Hostname ****************** To update your IP address in the DNS, our servers need to determine the -hostname you want to update. To determine the hostname, we try the following +hostname(s) you want to update. To determine them, we try the following steps until there is a match: - ``hostname`` query string parameter, unless it is set to ``YES`` (this @@ -88,6 +88,12 @@ steps until there is a match: - After successful authentication (no matter how), the only hostname that is associated with your user account (if not ambiguous). +You can either specify a single hostname, or a comma-separated list of hostnames +in order to update multiple subdomains in a single request. This works with any +of the above parameters; however, all hostnames must belong to the same domain. +The resulting updates are performed atomically (that is, they are either all +applied or they all fail). + 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). @@ -103,9 +109,6 @@ 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). -To update more than one domain name, please see -:ref:`updating-multiple-dyn-domains`. - .. _determine-ip-addresses: Determine IP Address(es) @@ -219,3 +222,8 @@ or option 2:: curl "https://update.dedyn.io/?hostname=&myipv4=1.2.3.4&myipv6=fd08::1234" \ --header "Authorization: Token " + +Update multiple domains simultaneously:: + + curl "https://update.dedyn.io/?hostname=,&myip=1.2.3.4" \ + --header "Authorization: Token " From 765c18744f9c3cb2d3a183222478ca0c93bbcd02 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Aug 2025 05:14:54 +0000 Subject: [PATCH 2/6] chore(deps): update requests requirement in /api Updates the requirements on [requests](https://github.com/psf/requests) to permit the latest version. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.32.4...v2.32.5) --- updated-dependencies: - dependency-name: requests dependency-version: 2.32.5 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- api/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/requirements.txt b/api/requirements.txt index 8dbd59253..33c7c4589 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -15,6 +15,6 @@ psycopg[binary]~=3.2.9 psl-dns~=1.1.1 pylibmc~=1.6.3 pyyaml~=6.0.2 -requests~=2.32.4 +requests~=2.32.5 responses~=0.25.8 uwsgi~=2.0.30 From 2e788f4e512fdca73f8653094323eb1fe02e341d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Sep 2025 06:19:28 +0000 Subject: [PATCH 3/6] chore(deps): update coverage requirement in /api Updates the requirements on [coverage](https://github.com/nedbat/coveragepy) to permit the latest version. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/7.10.4...7.10.6) --- updated-dependencies: - dependency-name: coverage dependency-version: 7.10.6 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- api/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/requirements.txt b/api/requirements.txt index 33c7c4589..a695d9208 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,6 +1,6 @@ captcha~=0.7.1 celery~=5.5.3 -coverage~=7.10.4 +coverage~=7.10.6 cryptography~=45.0.6 Django~=5.2.3 django-cors-headers~=4.7.0 From 84527c4301dc03f09ef2bb58641205000c49bc3b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 03:01:30 +0000 Subject: [PATCH 4/6] chore(deps): update cryptography requirement in /api Updates the requirements on [cryptography](https://github.com/pyca/cryptography) to permit the latest version. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/45.0.6...45.0.7) --- updated-dependencies: - dependency-name: cryptography dependency-version: 45.0.7 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- api/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/requirements.txt b/api/requirements.txt index a695d9208..9bbedc08b 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,7 +1,7 @@ captcha~=0.7.1 celery~=5.5.3 coverage~=7.10.6 -cryptography~=45.0.6 +cryptography~=45.0.7 Django~=5.2.3 django-cors-headers~=4.7.0 djangorestframework~=3.16.1 From 56b8f9b2277ef9a8b3bdf2c3d3791c80e95c2749 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Sep 2025 03:01:36 +0000 Subject: [PATCH 5/6] chore(deps): update psycopg[binary] requirement in /api Updates the requirements on [psycopg[binary]](https://github.com/psycopg/psycopg) to permit the latest version. - [Changelog](https://github.com/psycopg/psycopg/blob/master/docs/news.rst) - [Commits](https://github.com/psycopg/psycopg/compare/3.2.9...3.2.10) --- updated-dependencies: - dependency-name: psycopg[binary] dependency-version: 3.2.10 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- api/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/requirements.txt b/api/requirements.txt index 9bbedc08b..092033c3b 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -11,7 +11,7 @@ django-pgtrigger~=4.15.4 django-prometheus~=2.4.1 dnspython~=2.7.0 pyotp~=2.9.0 -psycopg[binary]~=3.2.9 +psycopg[binary]~=3.2.10 psl-dns~=1.1.1 pylibmc~=1.6.3 pyyaml~=6.0.2 From 7f753a9bdc43299cff15a8e9f019b01d2ee82ac5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Sep 2025 03:01:39 +0000 Subject: [PATCH 6/6] chore(deps): update django-cors-headers requirement in /api Updates the requirements on [django-cors-headers](https://github.com/adamchainz/django-cors-headers) to permit the latest version. - [Changelog](https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst) - [Commits](https://github.com/adamchainz/django-cors-headers/compare/4.7.0...4.8.0) --- updated-dependencies: - dependency-name: django-cors-headers dependency-version: 4.8.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- api/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/requirements.txt b/api/requirements.txt index 092033c3b..0608c551c 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -3,7 +3,7 @@ celery~=5.5.3 coverage~=7.10.6 cryptography~=45.0.7 Django~=5.2.3 -django-cors-headers~=4.7.0 +django-cors-headers~=4.8.0 djangorestframework~=3.16.1 django-celery-email~=3.0.0 django-netfields~=1.3.2