@@ -50,7 +50,6 @@ class PreserveIPs:
5050
5151UpdateAction = SetIPs | UpdateWithSubnet | PreserveIPs
5252
53-
5453class DynDNS12UpdateView (generics .GenericAPIView ):
5554 authentication_classes = (
5655 TokenAuthentication ,
@@ -82,47 +81,13 @@ def _find_action(self, param_keys, separator) -> UpdateAction:
8281 # Check URL parameters
8382 for param_key in param_keys :
8483 try :
85- params = set (
86- filter (
87- lambda param : separator in param or param in ("" , "preserve" ),
88- map (str .strip , self .request .query_params [param_key ].split ("," )),
89- )
90- )
84+ param_value = self .request .query_params [param_key ]
9185 except KeyError :
9286 continue
93- if not params :
94- continue
9587
96- try :
97- (param ,) = params # unpacks if params has exactly one element
98- except ValueError : # more than one element
99- if params & {"" , "preserve" }:
100- raise ValidationError (
101- detail = f'IP parameter "{ param_key } " cannot have addresses and "preserve" or an empty value at the same time.' ,
102- code = "inconsistent-parameter" ,
103- )
104- if any ("/" in param for param in params ):
105- raise ValidationError (
106- detail = f'IP parameter "{ param_key } " cannot use subnet notation with multiple addresses.' ,
107- code = "multiple-subnet" ,
108- )
109- else : # one element
110- match param :
111- case "" :
112- return SetIPs (ips = [])
113- case "preserve" :
114- return PreserveIPs ()
115- case str (x ) if "/" in x :
116- try :
117- subnet = ip_network (param , strict = False )
118- return UpdateWithSubnet (subnet = subnet )
119- except ValueError as e :
120- raise ValidationError (
121- detail = f'IP parameter "{ param_key } " is an invalid subnet: { e } ' ,
122- code = "invalid-subnet" ,
123- )
124-
125- return SetIPs (ips = list (params ))
88+ action = self ._get_action_from_param (param_key , param_value , separator )
89+ if action is not None :
90+ return action
12691
12792 # Check remote IP address
12893 client_ip = self .request .META .get ("REMOTE_ADDR" )
@@ -132,6 +97,71 @@ def _find_action(self, param_keys, separator) -> UpdateAction:
13297 # give up
13398 return SetIPs (ips = [])
13499
100+ @staticmethod
101+ def _get_action_from_param (param_key : str , param_value : str , separator : str ) -> UpdateAction | None :
102+ """
103+ Parses a single query parameter value to determine the DynDNS update action.
104+
105+ This function is responsible for interpreting the `param_value` (which can be a single IP,
106+ a comma-separated list of IPs, 'preserve', or a CIDR subnet) and converting it into
107+ a structured UpdateAction dataclass. It also performs validation on the parameter's format.
108+
109+ Args:
110+ param_key: The name of the query parameter (e.g., 'myip', 'myipv4', 'myipv6', or a qname for extra actions).
111+ Used for error messages.
112+ param_value: The string value of the query parameter (e.g., '1.2.3.4', '1.2.3.4,5.6.7.8',
113+ '192.168.1.0/24', 'preserve', or '').
114+ separator: The character used to distinguish IP versions (e.g., '.' for IPv4, ':' for IPv6).
115+
116+ Returns:
117+ An instance of SetIPs, UpdateWithSubnet, PreserveIPs, or None if no valid action can be
118+ derived from the parameter (e.g., an IPv4 address was given, but IPv6 is required by the separator).
119+ Returns SetIPs(ips=[]) if param_value is an empty string.
120+
121+ Raises:
122+ ValidationError: If the parameter value is inconsistent (e.g., 'preserve' with addresses)
123+ or if a subnet is malformed.
124+ """
125+ params = set (
126+ filter (
127+ lambda param : separator in param or param in ("" , "preserve" ),
128+ map (str .strip , param_value .split ("," ))
129+ )
130+ )
131+ if not params :
132+ return None
133+
134+ try :
135+ (param ,) = params # unpacks if params has exactly one element
136+ except ValueError : # more than one element
137+ if params & {"" , "preserve" }:
138+ raise ValidationError (
139+ detail = f'IP parameter "{ param_key } " cannot have addresses and "preserve" or an empty value at the same time.' ,
140+ code = "inconsistent-parameter" ,
141+ )
142+ if any ("/" in param for param in params ):
143+ raise ValidationError (
144+ detail = f'IP parameter "{ param_key } " cannot use subnet notation with multiple addresses.' ,
145+ code = "multiple-subnet" ,
146+ )
147+ else : # one element
148+ match param :
149+ case "" :
150+ return SetIPs (ips = [])
151+ case "preserve" :
152+ return PreserveIPs ()
153+ case str (x ) if "/" in x :
154+ try :
155+ subnet = ip_network (param , strict = False )
156+ return UpdateWithSubnet (subnet = subnet )
157+ except ValueError as e :
158+ raise ValidationError (
159+ detail = f'IP parameter "{ param_key } " is an invalid subnet: { e } ' ,
160+ code = "invalid-subnet" ,
161+ )
162+
163+ return SetIPs (ips = list (params ))
164+
135165 @staticmethod
136166 def _sanitize_qnames (qnames_str ) -> set [str ]:
137167 qnames = qnames_str .lower ().split ("," )
@@ -188,19 +218,52 @@ def qnames(self) -> set[str]:
188218 }
189219 )
190220
221+ @cached_property
222+ def extra_qnames (self ) -> dict [str , dict [str , str ]]:
223+ """
224+ Parses query parameters of the form 'myipv4:qname' or 'myipv6:qname'
225+ to extract additional qnames and their associated update arguments.
226+
227+ Returns:
228+ A dictionary where keys are qnames (e.g., 'sub.example.com') and values
229+ are dictionaries mapping RR type ('A' or 'AAAA') to the raw query parameter
230+ value (e.g., {'A': '1.2.3.4,5.6.7.8'} or {'AAAA': 'preserve'}).
231+ Multiple IP values for the same qname/type are concatenated with commas.
232+ """
233+ qnames = defaultdict (dict )
234+
235+ for param , value in self .request .query_params .items ():
236+ if param .startswith ("myipv6:" ):
237+ type_ = "AAAA"
238+ elif param .startswith ("myipv4:" ):
239+ type_ = "A"
240+ else :
241+ continue
242+
243+ for qname in self ._sanitize_qnames (param .split (":" , 1 )[1 ]):
244+ existing = qnames [qname ].get (type_ )
245+ if existing is not None :
246+ argument = f"{ existing } ,{ value } "
247+ else :
248+ argument = value
249+ qnames [qname ][type_ ] = argument
250+
251+ return qnames
252+
191253 @cached_property
192254 def domain (self ) -> Domain :
255+ qnames = self .qnames | self .extra_qnames .keys ()
193256 qname_qs = (
194257 Domain .objects .filter_qname (qname , owner = self .request .user )
195- for qname in self . qnames
258+ for qname in qnames
196259 )
197260 domains = (
198261 Domain .objects .none ()
199262 .union (* (qs .order_by ("-name_length" )[:1 ] for qs in qname_qs ), all = True )
200263 .all ()
201264 )
202265
203- if len (domains ) != len (self . qnames ):
266+ if len (domains ) != len (qnames ):
204267 metrics .get ("desecapi_dynDNS12_domain_not_found" ).inc ()
205268 raise NotFound ("nohost" )
206269
@@ -218,6 +281,25 @@ def domain(self) -> Domain:
218281 def subnames (self ) -> list [str ]:
219282 return [qname .rpartition (f".{ self .domain .name } " )[0 ] for qname in self .qnames ]
220283
284+ @cached_property
285+ def extra_actions (self ) -> dict [tuple [str , str ], UpdateAction ]:
286+ """
287+ Converts the raw string arguments from `extra_qnames` into structured `UpdateAction` objects.
288+
289+ Returns:
290+ A dictionary where keys are `(RR_type, subname)` tuples (e.g., ('A', 'sub'))
291+ and values are `UpdateAction` instances (SetIPs, UpdateWithSubnet, PreserveIPs).
292+ """
293+ return {
294+ (type_ , qname .rpartition (f".{ self .domain .name } " )[0 ]): self ._get_action_from_param (
295+ qname ,
296+ argument ,
297+ "." if type_ == "A" else ":"
298+ )
299+ for qname , arguments in self .extra_qnames .items ()
300+ for type_ , argument in arguments .items ()
301+ }
302+
221303 def get_serializer_context (self ):
222304 return {
223305 ** super ().get_serializer_context (),
@@ -226,8 +308,12 @@ def get_serializer_context(self):
226308 }
227309
228310 def get_queryset (self ):
311+ subnames = [
312+ * self .subnames ,
313+ * [subname for (type_ , subname ) in self .extra_actions .keys ()]
314+ ]
229315 return self .domain .rrset_set .filter (
230- subname__in = self . subnames , type__in = ["A" , "AAAA" ]
316+ subname__in = subnames , type__in = ["A" , "AAAA" ]
231317 ).prefetch_related ("records" )
232318
233319 @staticmethod
@@ -261,6 +347,13 @@ def get(self, request, *args, **kwargs) -> Response:
261347 "A" : self ._find_action (["myip" , "myipv4" , "ip" ], separator = "." ),
262348 "AAAA" : self ._find_action (["myipv6" , "ipv6" , "myip" , "ip" ], separator = ":" ),
263349 }
350+ subname_actions = {
351+ (type_ , subname ): action
352+ for subname in self .subnames
353+ for type_ , action in actions .items ()
354+ }
355+ for (type_ , subname ), action in self .extra_actions .items ():
356+ subname_actions [(type_ , subname )] = action
264357
265358 data = [
266359 {
@@ -269,8 +362,7 @@ def get(self, request, *args, **kwargs) -> Response:
269362 "ttl" : 60 ,
270363 "records" : records ,
271364 }
272- for subname in self .subnames
273- for type_ , action in actions .items ()
365+ for (type_ , subname ), action in subname_actions .items ()
274366 if (records := self ._get_records (subname_records [subname ], action ))
275367 is not None
276368 ]
0 commit comments