1# SPDX-License-Identifier: Apache-2.0
2# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3import ast
4import re
5
6from dcim.models import DeviceType, ModuleType, Platform
7from django.core.exceptions import ValidationError
8from django.db import models
9from django.urls import reverse
10from netbox.models import NetBoxModel
11from taggit.managers import TaggableManager
12
13from .choices import BreakoutModeChoices
14
15_REDOS_PATTERN = re.compile(r"(\+\*|\*\+|\?\?|\)\s*[\+\*\?]\s*[\+\*\?]|\)\s*\{[^{}]+\}\s*[\+\*\?])")
16_TEMPLATE_FIELD = re.compile(r"\{([^{}]*)\}")
17
18
19def _expression_names_channel(field):
20 """Return True when the brace group *field* parses as an expression naming ``channel``.
21
22 Mirrors how ``evaluate_name_template`` reads a brace group — ``ast.parse`` plus a walk — so
23 ``{channel + 1}`` is caught here instead of failing at evaluation time. A group that is not an
24 expression at all is left to the plain-name check.
25 """
26 try:
27 tree = ast.parse(field.strip(), mode="eval")
28 except (SyntaxError, ValueError):
29 return False
30 return any(isinstance(node, ast.Name) and node.id == "channel" for node in ast.walk(tree))
31
32
33def _references_channel(template):
34 """Return True when *template* names ``{channel}`` in any spelling the engine can be handed.
35
36 Covers the plain form, the conversion and format-spec forms (``{channel!r}``, ``{channel:>2}``),
37 the nested-arithmetic one (``{{channel} + 1}``) and the identifier inside an arithmetic
38 expression (``{channel + 1}``). ``string.Formatter().parse()`` is not used here: it rejects the
39 plugin's own arithmetic templates as malformed field names.
40 """
41 for field in _TEMPLATE_FIELD.findall(template):
42 name = field.split("!", 1)[0].split(":", 1)[0].strip()
43 if name == "channel" or name.startswith(("channel.", "channel[")):
44 return True
45 if _expression_names_channel(field):
46 return True
47 return False
48
49
50def _has_unbalanced_braces(template):
51 """Return True when *template*'s braces do not pair up.
52
53 Nested groups are the plugin's arithmetic form (``{8 + ({x} - 1) * 2}``), so depth is counted
54 rather than matched pairwise.
55 """
56 depth = 0
57 for char in template:
58 if char == "{":
59 depth += 1
60 elif char == "}":
61 depth -= 1
62 if depth < 0:
63 return True
64 return depth != 0
65
66
67def _validate_module_type_pattern(pattern):
68 """Compile *pattern* and check for ReDoS-prone constructs.
69
70 Raises ``ValidationError`` targeting ``module_type_pattern`` if the
71 pattern is syntactically invalid or contains nested quantifiers.
72 Called from ``InterfaceNameRule.clean()`` to avoid duplicating the same
73 try/except + ReDoS guard in each branch.
74 """
75 try:
76 re.compile(pattern)
77 except re.error as e:
78 raise ValidationError({"module_type_pattern": f"Invalid regex pattern: {e}"})
79 if _REDOS_PATTERN.search(pattern):
80 raise ValidationError({"module_type_pattern": "Pattern contains potentially unsafe nested quantifiers."})
81
82
83def _validate_breakout_topology(breakout_mode, channel_count, parent_name_template, applies_to_device_interfaces=False):
84 """Check that the mode, the channel count and the parent template describe one topology.
85
86 Raises ``ValidationError`` blaming the field that makes the combination impossible. Shared by
87 the model's ``clean()`` and by ``RuleTestForm`` so the tester refuses exactly what a save would.
88 """
89 channelized = breakout_mode == BreakoutModeChoices.CHANNELIZED
90 if applies_to_device_interfaces:
91 # The device-level path renames existing interfaces; it never creates a family to name.
92 if channelized:
93 raise ValidationError({"breakout_mode": "Device-level interface rules cannot build a channelized family."})
94 if parent_name_template:
95 raise ValidationError(
96 {"parent_name_template": "Parent name template is not available for device-level interface rules."}
97 )
98 if parent_name_template:
99 if not channelized:
100 raise ValidationError(
101 {"parent_name_template": "Parent name template requires the channelized breakout mode."}
102 )
103 if _has_unbalanced_braces(parent_name_template):
104 # Parent template only: stray braces in name_template predate this and are already stored.
105 raise ValidationError(
106 {"parent_name_template": "Unbalanced braces — every '{' in the template needs a '}'."}
107 )
108 if _references_channel(parent_name_template):
109 raise ValidationError(
110 {"parent_name_template": "The parent interface has no channel number; remove {channel}."}
111 )
112 if channelized and not channel_count:
113 raise ValidationError({"channel_count": "A channelized rule must define at least one channel."})
114
115
116class InterfaceNameRule(NetBoxModel):
117 """Post-install interface rename rule for module types.
118
119 Handles cases where NetBox's position-based naming can't produce
120 the correct interface name, such as converter offset (CVR-X2-SFP)
121 or breakout transceivers (QSFP+ 4x10G).
122
123 The name_template uses Python str.format() syntax with these variables:
124 {slot} - Slot number from parent module bay position
125 {bay_position} - Position of the bay this module is installed into
126 {bay_position_num} - Numeric suffix of bay position (e.g., "swp1" → "1")
127 {parent_bay_position} - Position of the parent module's bay
128 {sfp_slot} - Sub-bay index within the parent module
129 {base} - Base interface name from NetBox position resolution
130 {channel} - Channel number (iterated for breakout)
131
132 Module type matching supports two modes:
133 - Exact: FK reference to a specific ModuleType (default)
134 - Regex: Pattern matched against ModuleType.model via re.fullmatch()
135
136 Scoping fields (all optional):
137 - parent_module_type: match only when installed inside this module type
138 - device_type: match only devices of this hardware model
139 - platform: match only devices running this software platform/OS
140 """
141
142 module_type = models.ForeignKey(
143 ModuleType,
144 on_delete=models.CASCADE,
145 null=True,
146 blank=True,
147 related_name="+",
148 verbose_name="Module Type",
149 help_text="The module type whose installation triggers this rename rule (exact match)",
150 )
151 module_type_pattern = models.CharField(
152 max_length=255,
153 blank=True,
154 default="",
155 verbose_name="Module Type Pattern",
156 help_text="Regex pattern to match module type model name (e.g. 'QSFP-DD-400G-.*'). "
157 "Uses Python re.fullmatch() — pattern must match the entire model name.",
158 )
159 module_type_is_regex = models.BooleanField(
160 default=False,
161 verbose_name="Use Regex Pattern",
162 help_text="When enabled, use regex pattern instead of exact module type FK",
163 )
164 parent_module_type = models.ForeignKey(
165 ModuleType,
166 on_delete=models.SET_NULL,
167 null=True,
168 blank=True,
169 related_name="+",
170 verbose_name="Parent Module Type",
171 help_text="If set, rule only applies when installed inside this parent module type",
172 )
173 device_type = models.ForeignKey(
174 DeviceType,
175 on_delete=models.SET_NULL,
176 null=True,
177 blank=True,
178 related_name="+",
179 verbose_name="Device Type",
180 help_text="If set, rule only applies to devices of this device type",
181 )
182 platform = models.ForeignKey(
183 Platform,
184 on_delete=models.SET_NULL,
185 null=True,
186 blank=True,
187 related_name="+",
188 verbose_name="Platform",
189 help_text="If set, rule only applies to devices running this software platform/OS",
190 )
191 name_template = models.CharField(
192 max_length=255,
193 help_text=(
194 "Interface name template expression, e.g. "
195 "'GigabitEthernet{slot}/{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}'"
196 ),
197 )
198 parent_name_template = models.CharField(
199 max_length=255,
200 blank=True,
201 default="",
202 verbose_name="Parent Name Template",
203 help_text=(
204 "Optional name template for the channelized parent interface, e.g. 'et-0/0/{bay_position}'. "
205 "Same variables as the name template, minus {channel}. Blank leaves the parent's current name."
206 ),
207 )
208 breakout_mode = models.CharField(
209 max_length=20,
210 choices=BreakoutModeChoices,
211 default=BreakoutModeChoices.FLAT,
212 verbose_name="Breakout Mode",
213 help_text=(
214 "Topology a breakout rule produces: 'flat' renames the base to the first channel and creates "
215 "the remaining channels as sibling interfaces; 'channelized' turns the base into a channelized "
216 "parent with one channel subinterface per channel (requires a NetBox that models channels)."
217 ),
218 )
219 channel_count = models.PositiveSmallIntegerField(
220 default=0,
221 help_text="Number of breakout channels (0 = no breakout). Creates this many interfaces per template.",
222 )
223 channel_start = models.PositiveSmallIntegerField(
224 default=0,
225 help_text="Starting channel number for breakout interfaces (e.g., 0 for Juniper; Cisco varies by model—check device docs)",
226 )
227 description = models.TextField(
228 blank=True,
229 help_text="Optional description or notes about this rule",
230 )
231 enabled = models.BooleanField(
232 default=True,
233 help_text="When disabled, this rule is ignored during module installation and Apply Rules operations.",
234 )
235 applies_to_device_interfaces = models.BooleanField(
236 default=False,
237 verbose_name="Applies to Device Interfaces",
238 help_text=(
239 "When enabled, this rule renames device-level interfaces (module=None) when the device "
240 "joins or changes position in a Virtual Chassis. "
241 "The Module Type field must be empty; the Module Type Pattern (if set) is used as a regex "
242 "to filter which interface names to rename."
243 ),
244 )
245
246 # Override inherited tags to avoid reverse accessor clash when co-installed
247 # with another plugin that has a model of the same name.
248 tags = TaggableManager(through="extras.TaggedItem", related_name="+")
249
250 def clean(self):
251 """Validate regex/FK mode exclusivity and required fields."""
252 super().clean()
253 if self.applies_to_device_interfaces:
254 # Device-level rules must not reference a module type
255 if self.module_type:
256 raise ValidationError({"module_type": "Module type must be empty for device-level interface rules."})
257 # module_type_pattern is an optional interface-name filter regex
258 if self.module_type_pattern:
259 _validate_module_type_pattern(self.module_type_pattern)
260 # Force regex mode off — module_type_is_regex has no meaning here
261 self.module_type_is_regex = False
262 elif self.module_type_is_regex:
263 if not self.module_type_pattern:
264 raise ValidationError({"module_type_pattern": "Regex pattern is required when regex mode is enabled."})
265 if self.module_type:
266 raise ValidationError({"module_type": "Cannot set both module type FK and regex pattern. Choose one."})
267 _validate_module_type_pattern(self.module_type_pattern)
268 else:
269 # Clear any stale pattern so it does not persist when switching modes
270 self.module_type_pattern = ""
271 if not self.module_type:
272 raise ValidationError({"module_type": "Module type is required when regex mode is disabled."})
273 _validate_breakout_topology(
274 self.breakout_mode,
275 self.channel_count,
276 self.parent_name_template,
277 self.applies_to_device_interfaces,
278 )
279
280 def get_absolute_url(self):
281 """Return the detail URL for this rule."""
282 return reverse("plugins:netbox_interface_name_rules:interfacenamerule_detail", args=[self.pk])
283
284 clone_fields = [
285 "module_type",
286 "module_type_pattern",
287 "module_type_is_regex",
288 "parent_module_type",
289 "device_type",
290 "platform",
291 "name_template",
292 "parent_name_template",
293 "breakout_mode",
294 "channel_count",
295 "channel_start",
296 "description",
297 "enabled",
298 "applies_to_device_interfaces",
299 ]
300
301 def get_breakout_mode_color(self):
302 """Return the badge colour NetBox renders the breakout mode with."""
303 return BreakoutModeChoices.colors.get(self.breakout_mode)
304
305 @property
306 def specificity_score(self) -> int:
307 """Numeric priority score — higher beats lower in rule lookup.
308
309 The engine selects rules in this order (``find_matching_rule``):
310
311 1. **Exact FK match** always outranks regex at any scope.
312 2. **Scope specificity** (more constraints = higher priority):
313 parent_module_type contributes 4 pts, device_type 2 pts, platform 1 pt.
314 This mirrors the candidate-iteration order in the engine.
315 3. **Regex pattern length** (longer = more specific string match).
316
317 Score layout:
318 - Exact FK rules: 1000 + scope (1000–1007)
319 - Regex rules: scope × 100 + len(pattern)
320 e.g. device-scoped 15-char pattern → 2×100+15 = 215
321 platform-scoped 2-char pattern → 1×100+2 = 102
322 Exact rules always outrank regex (max regex score with scope=7,
323 max_length=255 would be 7×100+255 = 955, so 1000 safely exceeds
324 any possible regex score).
325
326 Scope bit weights: parent_module_type=4, device_type=2, platform=1.
327 Two rules with the same score fall back to lowest pk (first created).
328 """
329 scope = (
330 (4 if self.parent_module_type_id else 0)
331 + (2 if self.device_type_id else 0)
332 + (1 if self.platform_id else 0)
333 )
334 if not self.module_type_is_regex:
335 return 1000 + scope
336 return scope * 100 + len(self.module_type_pattern)
337
338 @property
339 def specificity_label(self) -> str:
340 """Short human-readable description of what this rule matches."""
341 if self.applies_to_device_interfaces:
342 mode = f"iface-filter({len(self.module_type_pattern)})" if self.module_type_pattern else "iface-filter(*)"
343 else:
344 mode = "exact" if not self.module_type_is_regex else f"regex({len(self.module_type_pattern)})"
345 parts = []
346 if self.parent_module_type_id:
347 parts.append("parent")
348 if self.device_type_id:
349 parts.append("device")
350 if self.platform_id:
351 parts.append("platform")
352 scope = "+".join(parts) if parts else "global"
353 return f"{mode} / {scope}"
354
355 class Meta:
356 ordering = ["module_type__model", "pk"]
357 constraints = [
358 models.CheckConstraint(
359 condition=(
360 models.Q(applies_to_device_interfaces=True, module_type__isnull=True)
361 | models.Q(
362 applies_to_device_interfaces=False,
363 module_type_is_regex=True,
364 module_type__isnull=True,
365 module_type_pattern__gt="",
366 )
367 | models.Q(
368 applies_to_device_interfaces=False,
369 module_type_is_regex=False,
370 module_type__isnull=False,
371 )
372 ),
373 name="interfacenamerule_module_type_mode_check",
374 ),
375 models.UniqueConstraint(
376 fields=["module_type", "parent_module_type", "device_type", "platform"],
377 condition=models.Q(module_type_is_regex=False, applies_to_device_interfaces=False),
378 nulls_distinct=False,
379 name="interfacenamerule_unique_exact",
380 ),
381 models.UniqueConstraint(
382 fields=["module_type_pattern", "parent_module_type", "device_type", "platform"],
383 condition=models.Q(module_type_is_regex=True),
384 nulls_distinct=False,
385 name="interfacenamerule_unique_regex",
386 ),
387 models.UniqueConstraint(
388 fields=["module_type_pattern", "device_type", "platform"],
389 condition=models.Q(applies_to_device_interfaces=True),
390 nulls_distinct=False,
391 name="interfacenamerule_unique_device_iface",
392 ),
393 ]
394
395 def __str__(self):
396 if self.module_type_is_regex:
397 module = f"/{self.module_type_pattern}/"
398 else:
399 module = self.module_type.model if self.module_type else "?"
400 parent = f" in {self.parent_module_type.model}" if self.parent_module_type else ""
401 device = f" on {self.device_type.model}" if self.device_type else ""
402 platform = f" [{self.platform.name}]" if self.platform else ""
403 return f"{module}{parent}{device}{platform} → {self.name_template}"
404
405 csv_headers = [
406 "module_type",
407 "module_type_pattern",
408 "module_type_is_regex",
409 "parent_module_type",
410 "device_type",
411 "platform",
412 "name_template",
413 "parent_name_template",
414 "breakout_mode",
415 "channel_count",
416 "channel_start",
417 "description",
418 "enabled",
419 "applies_to_device_interfaces",
420 ]
421
422 def to_csv(self):
423 """Return a tuple of field values for CSV export (matches csv_headers order)."""
424 return (
425 self.module_type.model if self.module_type else "",
426 self.module_type_pattern,
427 self.module_type_is_regex,
428 self.parent_module_type.model if self.parent_module_type else "",
429 self.device_type.model if self.device_type else "",
430 self.platform.name if self.platform else "",
431 self.name_template,
432 self.parent_name_template,
433 self.breakout_mode,
434 self.channel_count,
435 self.channel_start,
436 self.description,
437 self.enabled,
438 self.applies_to_device_interfaces,
439 )
440
441 def to_yaml(self):
442 """Return a YAML document for this rule (used by NetBox's built-in Export)."""
443 import yaml
444
445 entry = {}
446 for header, value in zip(self.csv_headers, self.to_csv()):
447 if (value != "" and value is not None) or header in {"name_template"}:
448 entry[header] = value
449 return yaml.dump([entry], default_flow_style=False, allow_unicode=True, sort_keys=False)