1# SPDX-License-Identifier: Apache-2.0
2# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3import re
4
5from dcim.models import DeviceType, ModuleType, Platform
6from django.core.exceptions import ValidationError
7from django.db import models
8from django.urls import reverse
9from netbox.models import NetBoxModel
10from taggit.managers import TaggableManager
11
12_REDOS_PATTERN = re.compile(r"(\+\*|\*\+|\?\?|\)\s*[\+\*\?]\s*[\+\*\?]|\)\s*\{[^{}]+\}\s*[\+\*\?])")
13
14
15def _validate_module_type_pattern(pattern):
16 """Compile *pattern* and check for ReDoS-prone constructs.
17
18 Raises ``ValidationError`` targeting ``module_type_pattern`` if the
19 pattern is syntactically invalid or contains nested quantifiers.
20 Called from ``InterfaceNameRule.clean()`` to avoid duplicating the same
21 try/except + ReDoS guard in each branch.
22 """
23 try:
24 re.compile(pattern)
25 except re.error as e:
26 raise ValidationError({"module_type_pattern": f"Invalid regex pattern: {e}"})
27 if _REDOS_PATTERN.search(pattern):
28 raise ValidationError({"module_type_pattern": "Pattern contains potentially unsafe nested quantifiers."})
29
30
31class InterfaceNameRule(NetBoxModel):
32 """Post-install interface rename rule for module types.
33
34 Handles cases where NetBox's position-based naming can't produce
35 the correct interface name, such as converter offset (CVR-X2-SFP)
36 or breakout transceivers (QSFP+ 4x10G).
37
38 The name_template uses Python str.format() syntax with these variables:
39 {slot} - Slot number from parent module bay position
40 {bay_position} - Position of the bay this module is installed into
41 {bay_position_num} - Numeric suffix of bay position (e.g., "swp1" → "1")
42 {parent_bay_position} - Position of the parent module's bay
43 {sfp_slot} - Sub-bay index within the parent module
44 {base} - Base interface name from NetBox position resolution
45 {channel} - Channel number (iterated for breakout)
46
47 Module type matching supports two modes:
48 - Exact: FK reference to a specific ModuleType (default)
49 - Regex: Pattern matched against ModuleType.model via re.fullmatch()
50
51 Scoping fields (all optional):
52 - parent_module_type: match only when installed inside this module type
53 - device_type: match only devices of this hardware model
54 - platform: match only devices running this software platform/OS
55 """
56
57 module_type = models.ForeignKey(
58 ModuleType,
59 on_delete=models.CASCADE,
60 null=True,
61 blank=True,
62 related_name="+",
63 verbose_name="Module Type",
64 help_text="The module type whose installation triggers this rename rule (exact match)",
65 )
66 module_type_pattern = models.CharField(
67 max_length=255,
68 blank=True,
69 default="",
70 verbose_name="Module Type Pattern",
71 help_text="Regex pattern to match module type model name (e.g. 'QSFP-DD-400G-.*'). "
72 "Uses Python re.fullmatch() — pattern must match the entire model name.",
73 )
74 module_type_is_regex = models.BooleanField(
75 default=False,
76 verbose_name="Use Regex Pattern",
77 help_text="When enabled, use regex pattern instead of exact module type FK",
78 )
79 parent_module_type = models.ForeignKey(
80 ModuleType,
81 on_delete=models.SET_NULL,
82 null=True,
83 blank=True,
84 related_name="+",
85 verbose_name="Parent Module Type",
86 help_text="If set, rule only applies when installed inside this parent module type",
87 )
88 device_type = models.ForeignKey(
89 DeviceType,
90 on_delete=models.SET_NULL,
91 null=True,
92 blank=True,
93 related_name="+",
94 verbose_name="Device Type",
95 help_text="If set, rule only applies to devices of this device type",
96 )
97 platform = models.ForeignKey(
98 Platform,
99 on_delete=models.SET_NULL,
100 null=True,
101 blank=True,
102 related_name="+",
103 verbose_name="Platform",
104 help_text="If set, rule only applies to devices running this software platform/OS",
105 )
106 name_template = models.CharField(
107 max_length=255,
108 help_text=(
109 "Interface name template expression, e.g. "
110 "'GigabitEthernet{slot}/{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}'"
111 ),
112 )
113 channel_count = models.PositiveSmallIntegerField(
114 default=0,
115 help_text="Number of breakout channels (0 = no breakout). Creates this many interfaces per template.",
116 )
117 channel_start = models.PositiveSmallIntegerField(
118 default=0,
119 help_text="Starting channel number for breakout interfaces (e.g., 0 for Juniper; Cisco varies by model—check device docs)",
120 )
121 description = models.TextField(
122 blank=True,
123 help_text="Optional description or notes about this rule",
124 )
125 enabled = models.BooleanField(
126 default=True,
127 help_text="When disabled, this rule is ignored during module installation and Apply Rules operations.",
128 )
129 applies_to_device_interfaces = models.BooleanField(
130 default=False,
131 verbose_name="Applies to Device Interfaces",
132 help_text=(
133 "When enabled, this rule renames device-level interfaces (module=None) when the device "
134 "joins or changes position in a Virtual Chassis. "
135 "The Module Type field must be empty; the Module Type Pattern (if set) is used as a regex "
136 "to filter which interface names to rename."
137 ),
138 )
139
140 # Override inherited tags to avoid reverse accessor clash when co-installed
141 # with another plugin that has a model of the same name.
142 tags = TaggableManager(through="extras.TaggedItem", related_name="+")
143
144 def clean(self):
145 """Validate regex/FK mode exclusivity and required fields."""
146 super().clean()
147 if self.applies_to_device_interfaces:
148 # Device-level rules must not reference a module type
149 if self.module_type:
150 raise ValidationError({"module_type": "Module type must be empty for device-level interface rules."})
151 # module_type_pattern is an optional interface-name filter regex
152 if self.module_type_pattern:
153 _validate_module_type_pattern(self.module_type_pattern)
154 # Force regex mode off — module_type_is_regex has no meaning here
155 self.module_type_is_regex = False
156 elif self.module_type_is_regex:
157 if not self.module_type_pattern:
158 raise ValidationError({"module_type_pattern": "Regex pattern is required when regex mode is enabled."})
159 if self.module_type:
160 raise ValidationError({"module_type": "Cannot set both module type FK and regex pattern. Choose one."})
161 _validate_module_type_pattern(self.module_type_pattern)
162 else:
163 # Clear any stale pattern so it does not persist when switching modes
164 self.module_type_pattern = ""
165 if not self.module_type:
166 raise ValidationError({"module_type": "Module type is required when regex mode is disabled."})
167
168 def get_absolute_url(self):
169 """Return the detail URL for this rule."""
170 return reverse("plugins:netbox_interface_name_rules:interfacenamerule_detail", args=[self.pk])
171
172 clone_fields = [
173 "module_type",
174 "module_type_pattern",
175 "module_type_is_regex",
176 "parent_module_type",
177 "device_type",
178 "platform",
179 "name_template",
180 "channel_count",
181 "channel_start",
182 "description",
183 "enabled",
184 "applies_to_device_interfaces",
185 ]
186
187 @property
188 def specificity_score(self) -> int:
189 """Numeric priority score — higher beats lower in rule lookup.
190
191 The engine selects rules in this order (``find_matching_rule``):
192
193 1. **Exact FK match** always outranks regex at any scope.
194 2. **Scope specificity** (more constraints = higher priority):
195 parent_module_type contributes 4 pts, device_type 2 pts, platform 1 pt.
196 This mirrors the candidate-iteration order in the engine.
197 3. **Regex pattern length** (longer = more specific string match).
198
199 Score layout:
200 - Exact FK rules: 1000 + scope (1000–1007)
201 - Regex rules: scope × 100 + len(pattern)
202 e.g. device-scoped 15-char pattern → 2×100+15 = 215
203 platform-scoped 2-char pattern → 1×100+2 = 102
204 Exact rules always outrank regex (max regex score with scope=7,
205 max_length=255 would be 7×100+255 = 955, so 1000 safely exceeds
206 any possible regex score).
207
208 Scope bit weights: parent_module_type=4, device_type=2, platform=1.
209 Two rules with the same score fall back to lowest pk (first created).
210 """
211 scope = (
212 (4 if self.parent_module_type_id else 0)
213 + (2 if self.device_type_id else 0)
214 + (1 if self.platform_id else 0)
215 )
216 if not self.module_type_is_regex:
217 return 1000 + scope
218 return scope * 100 + len(self.module_type_pattern)
219
220 @property
221 def specificity_label(self) -> str:
222 """Short human-readable description of what this rule matches."""
223 if self.applies_to_device_interfaces:
224 mode = f"iface-filter({len(self.module_type_pattern)})" if self.module_type_pattern else "iface-filter(*)"
225 else:
226 mode = "exact" if not self.module_type_is_regex else f"regex({len(self.module_type_pattern)})"
227 parts = []
228 if self.parent_module_type_id:
229 parts.append("parent")
230 if self.device_type_id:
231 parts.append("device")
232 if self.platform_id:
233 parts.append("platform")
234 scope = "+".join(parts) if parts else "global"
235 return f"{mode} / {scope}"
236
237 class Meta:
238 ordering = ["module_type__model", "pk"]
239 constraints = [
240 models.CheckConstraint(
241 check=(
242 models.Q(applies_to_device_interfaces=True, module_type__isnull=True)
243 | models.Q(
244 applies_to_device_interfaces=False,
245 module_type_is_regex=True,
246 module_type__isnull=True,
247 module_type_pattern__gt="",
248 )
249 | models.Q(
250 applies_to_device_interfaces=False,
251 module_type_is_regex=False,
252 module_type__isnull=False,
253 )
254 ),
255 name="interfacenamerule_module_type_mode_check",
256 ),
257 models.UniqueConstraint(
258 fields=["module_type", "parent_module_type", "device_type", "platform"],
259 condition=models.Q(module_type_is_regex=False, applies_to_device_interfaces=False),
260 nulls_distinct=False,
261 name="interfacenamerule_unique_exact",
262 ),
263 models.UniqueConstraint(
264 fields=["module_type_pattern", "parent_module_type", "device_type", "platform"],
265 condition=models.Q(module_type_is_regex=True),
266 nulls_distinct=False,
267 name="interfacenamerule_unique_regex",
268 ),
269 models.UniqueConstraint(
270 fields=["module_type_pattern", "device_type", "platform"],
271 condition=models.Q(applies_to_device_interfaces=True),
272 nulls_distinct=False,
273 name="interfacenamerule_unique_device_iface",
274 ),
275 ]
276
277 def __str__(self):
278 if self.module_type_is_regex:
279 module = f"/{self.module_type_pattern}/"
280 else:
281 module = self.module_type.model if self.module_type else "?"
282 parent = f" in {self.parent_module_type.model}" if self.parent_module_type else ""
283 device = f" on {self.device_type.model}" if self.device_type else ""
284 platform = f" [{self.platform.name}]" if self.platform else ""
285 return f"{module}{parent}{device}{platform} → {self.name_template}"
286
287 csv_headers = [
288 "module_type",
289 "module_type_pattern",
290 "module_type_is_regex",
291 "parent_module_type",
292 "device_type",
293 "platform",
294 "name_template",
295 "channel_count",
296 "channel_start",
297 "description",
298 "enabled",
299 "applies_to_device_interfaces",
300 ]
301
302 def to_csv(self):
303 """Return a tuple of field values for CSV export (matches csv_headers order)."""
304 return (
305 self.module_type.model if self.module_type else "",
306 self.module_type_pattern,
307 self.module_type_is_regex,
308 self.parent_module_type.model if self.parent_module_type else "",
309 self.device_type.model if self.device_type else "",
310 self.platform.name if self.platform else "",
311 self.name_template,
312 self.channel_count,
313 self.channel_start,
314 self.description,
315 self.enabled,
316 self.applies_to_device_interfaces,
317 )
318
319 def to_yaml(self):
320 """Return a YAML document for this rule (used by NetBox's built-in Export)."""
321 import yaml
322
323 entry = {}
324 for header, value in zip(self.csv_headers, self.to_csv()):
325 if (value != "" and value is not None) or header in {"name_template"}:
326 entry[header] = value
327 return yaml.dump([entry], default_flow_style=False, allow_unicode=True, sort_keys=False)