-
-
Notifications
You must be signed in to change notification settings - Fork 59
Allow overwriting IP address for specific (sub)domain in dyndns request #1141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -82,47 +82,13 @@ def _find_action(self, param_keys, separator) -> UpdateAction: | |
| # 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 +98,73 @@ 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 the parameter value is inconsistent (e.g., 'preserve' with addresses) | ||
| or if a subnet is malformed. | ||
| """ | ||
| 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,19 +221,52 @@ def qnames(self) -> set[str]: | |
| } | ||
| ) | ||
|
|
||
| @cached_property | ||
| def extra_qnames(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'}). | ||
| Multiple IP values for the same qname/type are concatenated with commas. | ||
| """ | ||
| qnames = defaultdict(dict) | ||
|
|
||
| for param, value in self.request.query_params.items(): | ||
| if param.startswith("myipv6:"): | ||
| type_ = "AAAA" | ||
| elif param.startswith("myipv4:"): | ||
| type_ = "A" | ||
| else: | ||
| continue | ||
|
|
||
| for qname in self._sanitize_qnames(param.split(":", 1)[1]): | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note for the reviewer: Since I'm reusing the existing
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's fine. The (I find that rather unexpected, and would think that the last value takes precedence.) |
||
| existing = qnames[qname].get(type_) | ||
| if existing is not None: | ||
| argument = f"{existing},{value}" | ||
| else: | ||
| argument = value | ||
| qnames[qname][type_] = argument | ||
|
|
||
| return qnames | ||
|
|
||
| @cached_property | ||
| def domain(self) -> Domain: | ||
| qnames = self.qnames | self.extra_qnames.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() | ||
| .union(*(qs.order_by("-name_length")[:1] for qs in qname_qs), all=True) | ||
| .all() | ||
| ) | ||
|
|
||
| if len(domains) != len(self.qnames): | ||
| if len(domains) != len(qnames): | ||
| metrics.get("desecapi_dynDNS12_domain_not_found").inc() | ||
| raise NotFound("nohost") | ||
|
|
||
|
|
@@ -218,6 +284,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_qnames` into structured `UpdateAction` objects. | ||
|
|
||
| Returns: | ||
| A dictionary where keys are `(RR_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, argument, "." if type_ == "A" else ":" | ||
| ) | ||
| for qname, arguments in self.extra_qnames.items() | ||
| for type_, argument in arguments.items() | ||
| } | ||
|
|
||
| def get_serializer_context(self): | ||
| return { | ||
| **super().get_serializer_context(), | ||
|
|
@@ -226,8 +312,12 @@ def get_serializer_context(self): | |
| } | ||
|
|
||
| def get_queryset(self): | ||
| subnames = [ | ||
| *self.subnames, | ||
| *[subname for (type_, 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 +351,13 @@ 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() | ||
| } | ||
| for (type_, subname), action in self.extra_actions.items(): | ||
| subname_actions[(type_, subname)] = action | ||
|
|
||
| data = [ | ||
| { | ||
|
|
@@ -269,8 +366,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 | ||
| ] | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.