utils.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. from dataclasses import dataclass
  2. import netaddr
  3. from django.apps import apps
  4. from django.core.exceptions import ValidationError
  5. from django.db.models import BooleanField, F, Func, Q
  6. from django.utils.translation import gettext_lazy as _
  7. from .constants import *
  8. __all__ = (
  9. 'PORT_MAPPING_LOOKUPS',
  10. 'AvailableIPSpace',
  11. 'PortMappingMatch',
  12. 'add_available_vlans',
  13. 'add_requested_prefixes',
  14. 'annotate_ip_space',
  15. 'expand_port_mapping',
  16. 'get_next_available_prefix',
  17. 'group_port_mapping_rows',
  18. 'group_port_mappings',
  19. 'legacy_protocol_and_ports',
  20. 'normalize_port_mapping',
  21. 'port_mapping_q',
  22. 'rebuild_prefixes',
  23. 'sorted_int_ports',
  24. 'split_port_mapping',
  25. )
  26. @dataclass
  27. class AvailableIPSpace:
  28. """
  29. A representation of available IP space between two IP addresses/ranges.
  30. """
  31. size: int
  32. first_ip: str
  33. @property
  34. def title(self):
  35. if self.size == 1:
  36. return _('1 IP available')
  37. if self.size <= 65536:
  38. return _('{count} IPs available').format(count=self.size)
  39. return _('Many IPs available')
  40. def add_requested_prefixes(parent, prefix_list, show_available=True, show_assigned=True):
  41. """
  42. Return a list of requested prefixes using show_available, show_assigned filters. If available prefixes are
  43. requested, create fake Prefix objects for all unallocated space within a prefix.
  44. :param parent: Parent Prefix instance
  45. :param prefix_list: Child prefixes list (or queryset)
  46. :param show_available: Include available prefixes.
  47. :param show_assigned: Show assigned prefixes.
  48. """
  49. child_prefixes = []
  50. # Add available prefixes to the table if requested
  51. if prefix_list and show_available:
  52. Prefix = apps.get_model('ipam', 'Prefix')
  53. # Find all unallocated space, add fake Prefix objects to child_prefixes.
  54. # IMPORTANT: These are unsaved Prefix instances (pk=None). If this is ever changed to use
  55. # saved Prefix instances with real pks, bulk delete will fail for mixed-type selections
  56. # due to single-model form validation. See: https://github.com/netbox-community/netbox/issues/21176
  57. available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list])
  58. available_prefixes = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()]
  59. child_prefixes = child_prefixes + available_prefixes
  60. # Add assigned prefixes to the table if requested
  61. if prefix_list and show_assigned:
  62. child_prefixes = child_prefixes + list(prefix_list)
  63. # Sort child prefixes after additions
  64. child_prefixes.sort(key=lambda p: p.prefix)
  65. return child_prefixes
  66. def annotate_ip_space(prefix, *, ip_addresses=None, ip_ranges=None):
  67. """
  68. Return a prefix's child ranges and IPs interleaved with available space records.
  69. :param prefix: Parent Prefix instance
  70. :param ip_addresses: Child IP addresses queryset (defaults to all child IPs)
  71. :param ip_ranges: Child IP ranges queryset (defaults to all populated child ranges)
  72. """
  73. if ip_addresses is None:
  74. ip_addresses = prefix.get_child_ips()
  75. if ip_ranges is None:
  76. ip_ranges = prefix.get_child_ranges(mark_populated=True)
  77. # Compile child objects
  78. records = []
  79. records.extend([
  80. (iprange.start_address.ip, iprange) for iprange in ip_ranges
  81. ])
  82. records.extend([
  83. (ip.address.ip, ip) for ip in ip_addresses
  84. ])
  85. records = sorted(records, key=lambda x: x[0])
  86. # Determine the first & last valid IP addresses in the prefix
  87. first_ip_in_prefix, last_ip_in_prefix = prefix.usable_ip_bounds
  88. if not records:
  89. return [
  90. AvailableIPSpace(
  91. size=int(last_ip_in_prefix - first_ip_in_prefix + 1),
  92. first_ip=f'{first_ip_in_prefix}/{prefix.mask_length}'
  93. )
  94. ]
  95. output = []
  96. prev_ip = None
  97. # Account for any available IPs before the first real IP
  98. if records[0][0] > first_ip_in_prefix:
  99. output.append(AvailableIPSpace(
  100. size=int(records[0][0] - first_ip_in_prefix),
  101. first_ip=f'{first_ip_in_prefix}/{prefix.mask_length}'
  102. ))
  103. # Add IP ranges & addresses, annotating available space in between records
  104. for record in records:
  105. if prev_ip:
  106. # Annotate available space
  107. if (diff := int(record[0]) - int(prev_ip)) > 1:
  108. first_skipped = f'{prev_ip + 1}/{prefix.mask_length}'
  109. output.append(AvailableIPSpace(
  110. size=diff - 1,
  111. first_ip=first_skipped
  112. ))
  113. output.append(record[1])
  114. # Update the previous IP address
  115. if hasattr(record[1], 'end_address'):
  116. prev_ip = record[1].end_address.ip
  117. else:
  118. prev_ip = record[0]
  119. # Include any remaining available IPs
  120. if prev_ip < last_ip_in_prefix:
  121. output.append(AvailableIPSpace(
  122. size=int(last_ip_in_prefix - prev_ip),
  123. first_ip=f'{prev_ip + 1}/{prefix.mask_length}'
  124. ))
  125. return output
  126. def available_vlans_from_range(vlans, vlan_group, vid_range):
  127. """
  128. Create fake records for all gaps between used VLANs
  129. """
  130. min_vid = int(vid_range.lower) if vid_range else VLAN_VID_MIN
  131. max_vid = int(vid_range.upper) if vid_range else VLAN_VID_MAX
  132. if not vlans:
  133. return [{
  134. 'vid': min_vid,
  135. 'vlan_group': vlan_group,
  136. 'available': max_vid - min_vid
  137. }]
  138. prev_vid = min_vid - 1
  139. new_vlans = []
  140. for vlan in vlans:
  141. # Ignore VIDs outside the range
  142. if not min_vid <= vlan.vid < max_vid:
  143. continue
  144. # Annotate any available VIDs between the previous (or minimum) VID
  145. # and the current VID
  146. if vlan.vid - prev_vid > 1:
  147. new_vlans.append({
  148. 'vid': prev_vid + 1,
  149. 'vlan_group': vlan_group,
  150. 'available': vlan.vid - prev_vid - 1,
  151. })
  152. prev_vid = vlan.vid
  153. # Annotate any remaining available VLANs
  154. if prev_vid < max_vid - 1:
  155. new_vlans.append({
  156. 'vid': prev_vid + 1,
  157. 'vlan_group': vlan_group,
  158. 'available': max_vid - prev_vid - 1,
  159. })
  160. return new_vlans
  161. def add_available_vlans(vlans, vlan_group):
  162. """
  163. Create fake records for all gaps between used VLANs
  164. """
  165. new_vlans = []
  166. for vid_range in vlan_group.vid_ranges:
  167. new_vlans.extend(available_vlans_from_range(vlans, vlan_group, vid_range))
  168. vlans = list(vlans) + new_vlans
  169. vlans.sort(key=lambda v: v['vid'] if isinstance(v, dict) else v.vid)
  170. return vlans
  171. def rebuild_prefixes(vrf):
  172. """
  173. Rebuild the prefix hierarchy for all prefixes in the specified VRF (or global table).
  174. """
  175. Prefix = apps.get_model('ipam', 'Prefix')
  176. prefix_queryset = Prefix.objects.filter(vrf=vrf)
  177. def contains(parent, child):
  178. return child in parent and child != parent
  179. def push_to_stack(prefix):
  180. # Increment child count on parent nodes
  181. for n in stack:
  182. n['children'] += 1
  183. stack.append({
  184. 'pk': [prefix['pk']],
  185. 'prefix': prefix['prefix'],
  186. 'children': 0,
  187. })
  188. stack = []
  189. update_queue = []
  190. prefixes = prefix_queryset.order_by('prefix', 'pk').values('pk', 'prefix')
  191. # Iterate through all Prefixes in the table, growing and shrinking the stack as we go
  192. for p in prefixes:
  193. # Grow the stack if this is a child of the most recent prefix
  194. if not stack or contains(stack[-1]['prefix'], p['prefix']):
  195. push_to_stack(p)
  196. # Handle duplicate prefixes
  197. elif stack[-1]['prefix'] == p['prefix']:
  198. stack[-1]['pk'].append(p['pk'])
  199. # If this is a sibling or parent of the most recent prefix, pop nodes from the
  200. # stack until we reach a parent prefix (or the root)
  201. else:
  202. while stack and not contains(stack[-1]['prefix'], p['prefix']):
  203. node = stack.pop()
  204. for pk in node['pk']:
  205. update_queue.append(
  206. Prefix(pk=pk, _depth=len(stack), _children=node['children'])
  207. )
  208. push_to_stack(p)
  209. # Flush the update queue once it reaches 100 Prefixes
  210. if len(update_queue) >= 100:
  211. Prefix.objects.bulk_update(update_queue, ['_depth', '_children'])
  212. update_queue = []
  213. # Clear out any prefixes remaining in the stack
  214. while stack:
  215. node = stack.pop()
  216. for pk in node['pk']:
  217. update_queue.append(
  218. Prefix(pk=pk, _depth=len(stack), _children=node['children'])
  219. )
  220. # Final flush of any remaining Prefixes
  221. Prefix.objects.bulk_update(update_queue, ['_depth', '_children'])
  222. def get_next_available_prefix(ipset, prefix_size):
  223. """
  224. Given a prefix length, allocate the next available prefix from an IPSet.
  225. """
  226. for available_prefix in ipset.iter_cidrs():
  227. if prefix_size >= available_prefix.prefixlen:
  228. allocated_prefix = f"{available_prefix.network}/{prefix_size}"
  229. ipset.remove(allocated_prefix)
  230. return allocated_prefix
  231. return None
  232. #
  233. # Service port mappings
  234. #
  235. def split_port_mapping(mapping):
  236. """
  237. Split a ``protocol/port`` string (e.g. ``'tcp/80'``) into its ``(protocol, port)`` parts. A missing
  238. separator or port yields an empty string for that part, leaving validation to report the problem.
  239. """
  240. protocol, _sep, port = mapping.partition('/')
  241. return protocol, port
  242. def normalize_port_mapping(mapping):
  243. """
  244. Canonicalize a single ``protocol/port`` string as far as possible *without raising*: the protocol is
  245. lowercased and a numeric port loses any leading zeros, so ``'TCP/080'`` becomes ``'tcp/80'``. Anything
  246. unrecognized is returned unchanged, in which case it simply won't match a stored (always-canonical)
  247. mapping.
  248. This is the lookup-side counterpart to ``validate_port_mappings()``, which enforces the same
  249. canonical form on write but rejects bad input. Filtering must not 400 on an unknown protocol or a
  250. malformed pair — an empty result set is the right answer there — hence the separate, lenient variant.
  251. """
  252. # Imported lazily to avoid a circular import during settings load (ipam.choices reads
  253. # settings.FIELD_CHOICES), matching validate_port_mappings().
  254. from ipam.choices import ServiceProtocolChoices
  255. protocol, port = split_port_mapping(mapping)
  256. if not port or not port.isdigit():
  257. return mapping
  258. protocol = protocol.lower()
  259. if protocol not in ServiceProtocolChoices.values():
  260. return mapping
  261. return f'{protocol}/{int(port)}'
  262. def group_port_mappings(mappings):
  263. """
  264. Group a flat ``['tcp/80', 'tcp/443', 'udp/53']`` list into an ordered ``{protocol: [ports]}`` dict,
  265. preserving first-seen protocol order. Shared by the display property and the form widget so the
  266. ``protocol/port`` string is parsed in exactly one place.
  267. """
  268. grouped = {}
  269. for mapping in mappings:
  270. protocol, port = split_port_mapping(mapping)
  271. grouped.setdefault(protocol, []).append(port)
  272. return grouped
  273. def group_port_mapping_rows(mappings):
  274. """
  275. Group a flat ``['tcp/80', 'tcp/443', 'udp/53']`` list into per-protocol rows
  276. ``[{'protocol': 'tcp', 'ports': '80,443'}, {'protocol': 'udp', 'ports': '53'}]`` — the shape the
  277. port-mapping form widget renders, one row per protocol.
  278. """
  279. return [
  280. {'protocol': protocol, 'ports': ','.join(ports)}
  281. for protocol, ports in group_port_mappings(mappings).items()
  282. ]
  283. def sorted_int_ports(ports):
  284. """
  285. Sort a protocol's port strings numerically and return them as integers. Any entry that bypassed
  286. validation (a raw SQL write, a plugin, or an unmigrated row) and isn't a plain integer is skipped
  287. rather than raising, so a single malformed mapping degrades gracefully on API reads instead of
  288. raising a 500 — mirroring the tolerance of ``ServiceBase.port_mappings_list``.
  289. """
  290. return sorted(int(port) for port in ports if str(port).isdigit())
  291. def legacy_protocol_and_ports(mappings):
  292. """
  293. Collapse port mappings into the deprecated single-protocol ``(protocol, ports)`` representation.
  294. Single source of truth for the backward-compatibility contract shared by the REST serializers and
  295. the GraphQL types:
  296. * single protocol -> ``(protocol, [sorted int ports])``
  297. * no mappings -> ``(None, [])`` (representable as an empty legacy ports list)
  298. * multiple protocols -> ``(None, None)`` (not representable; ``ports=None`` signals "read
  299. port_mappings instead")
  300. * single protocol, but a port fails integer coercion (malformed raw/plugin data) -> ``(None, None)``
  301. (a subset would be plausible-but-wrong, so signal "not representable" rather than silently
  302. dropping the bad mapping)
  303. """
  304. grouped = group_port_mappings(mappings)
  305. if len(grouped) == 1:
  306. protocol, ports = next(iter(grouped.items()))
  307. int_ports = sorted_int_ports(ports)
  308. # If any port was dropped by coercion, the legacy single-protocol view can't faithfully
  309. # represent this service; signal "not representable" instead of returning a partial list.
  310. if len(int_ports) != len(ports):
  311. return None, None
  312. return protocol, int_ports
  313. return (None, []) if not grouped else (None, None)
  314. # Whitelisted SQL comparison operators for the port half of a mapping, keyed by the django-filter
  315. # lookup name. Only these five names are ever interpolated into SQL by PortMappingMatch, so the
  316. # operator can never originate from user input.
  317. PORT_MAPPING_LOOKUPS = {
  318. 'exact': '=',
  319. 'gt': '>',
  320. 'gte': '>=',
  321. 'lt': '<',
  322. 'lte': '<=',
  323. }
  324. # The port half of an unnested mapping, as an integer. Guarded by a numeric test so a malformed mapping
  325. # written outside the ORM (raw SQL, a plugin) evaluates to NULL — which no comparison matches — instead
  326. # of aborting the whole query with an invalid-input-syntax error. Mirrors the tolerance that
  327. # sorted_int_ports() and ServiceBase.port_mappings_list already apply on reads.
  328. _PORT_MAPPING_PORT_SQL = (
  329. "CASE WHEN split_part(port_mapping, '/', 2) ~ '^[0-9]+$' "
  330. "THEN split_part(port_mapping, '/', 2)::integer END"
  331. )
  332. class PortMappingMatch(Func):
  333. """
  334. A boolean expression which is true for services having at least one port mapping that satisfies the
  335. given protocol and port tests:
  336. EXISTS (
  337. SELECT 1 FROM unnest(port_mappings) AS port_mapping
  338. WHERE split_part(port_mapping, '/', 1) = ANY(<protocols>)
  339. AND <port> >= <value> AND <port> <= <value> ...
  340. )
  341. Testing every condition against the *same* unnested mapping is what keeps protocol and port
  342. correlated: a service exposing tcp/80 and udp/9999 must not match ``protocol=tcp&port__gt=1000``,
  343. and one exposing tcp/500 and tcp/5000 must not match ``port__gte=1000&port__lte=2000``.
  344. This is deliberately a sequential scan. GIN's ``array_ops`` opclass supports only ``=``, ``&&``,
  345. ``@>`` and ``<@``, so no array index can serve a range comparison, and the alternatives (a
  346. trigger-maintained denormalized column, or a related table) either cannot express the correlation or
  347. cost far more than the scan — measured at ~200 ms over 400k services and ~1 s over 2M.
  348. ``port_mapping_q()`` therefore reserves this for the cases an array overlap cannot express and uses
  349. the GIN-indexable overlap for exact protocol+port lookups.
  350. """
  351. output_field = BooleanField()
  352. def __init__(self, protocols=(), port_tests=()):
  353. """
  354. Args:
  355. protocols: protocol values to match, OR'd together.
  356. port_tests: ``(lookup, values)`` pairs, where ``lookup`` is a key of
  357. ``PORT_MAPPING_LOOKUPS``. Pairs are AND'd (and so must hold for one single mapping);
  358. the values within a pair are OR'd, matching how django-filter's multi-value filters
  359. combine ``?port=80&port=443``.
  360. """
  361. self.protocols = list(protocols or ())
  362. self.port_tests = [
  363. (lookup, list(values)) for lookup, values in (port_tests or ()) if values
  364. ]
  365. for lookup, _values in self.port_tests:
  366. if lookup not in PORT_MAPPING_LOOKUPS:
  367. raise ValueError(f"Unsupported port mapping lookup: {lookup}")
  368. super().__init__(F('port_mappings'))
  369. def as_sql(self, compiler, connection, **extra_context):
  370. mappings_sql, mappings_params = compiler.compile(self.source_expressions[0])
  371. conditions = []
  372. params = list(mappings_params)
  373. if self.protocols:
  374. conditions.append("split_part(port_mapping, '/', 1) = ANY(%s)")
  375. params.append(self.protocols)
  376. for lookup, values in self.port_tests:
  377. operator = PORT_MAPPING_LOOKUPS[lookup]
  378. conditions.append('({})'.format(
  379. ' OR '.join(f'{_PORT_MAPPING_PORT_SQL} {operator} %s' for _value in values)
  380. ))
  381. params.extend(values)
  382. if not conditions:
  383. # port_mapping_q() never builds an unconstrained match, but be explicit rather than emit an
  384. # EXISTS with an empty WHERE clause.
  385. return 'TRUE', []
  386. sql = (
  387. f"EXISTS (SELECT 1 FROM unnest({mappings_sql}) AS port_mapping "
  388. f"WHERE {' AND '.join(conditions)})"
  389. )
  390. return sql, params
  391. def port_mapping_q(protocols=(), port_tests=()):
  392. """
  393. Build a ``Q`` filtering services by protocol and/or port, correlated so that a combined query must
  394. be satisfied by a *single* mapping. See ``PortMappingMatch`` for the argument shapes.
  395. A lone exact port test reduces to a GIN-indexable array overlap on ``port_mappings``
  396. (``port_mappings && ['tcp/80', ...]`` — each element is one whole mapping, so an overlap means
  397. "shares any mapping"); for a port-only query each port is paired with every valid protocol to keep
  398. it a single overlap. Everything else — a protocol-only query, whose ports are unbounded and cannot
  399. be enumerated, and any range lookup, which no array index can serve — falls back to
  400. ``PortMappingMatch``. Shared by the FilterSet and the GraphQL filters.
  401. """
  402. # Imported lazily to avoid a circular import during settings load (ipam.choices reads
  403. # settings.FIELD_CHOICES), matching ipam.validators.
  404. from ipam.choices import ServiceProtocolChoices
  405. protocols = list(protocols or ())
  406. port_tests = [(lookup, list(values)) for lookup, values in (port_tests or ()) if values]
  407. if not protocols and not port_tests:
  408. return Q()
  409. if len(port_tests) == 1 and port_tests[0][0] == 'exact':
  410. # Every stored mapping's protocol is validated against ServiceProtocolChoices, so enumerating
  411. # the (small, fixed) protocol set covers all valid data for a port-only query.
  412. ports = port_tests[0][1]
  413. mapping_protocols = protocols or ServiceProtocolChoices.values()
  414. combos = [f'{protocol}/{port}' for protocol in mapping_protocols for port in ports]
  415. return Q(port_mappings__overlap=combos)
  416. return Q(PortMappingMatch(protocols=protocols, port_tests=port_tests))
  417. def expand_port_mapping(protocol, ports):
  418. """
  419. Expand a single protocol plus its ports into the model's flat ``['tcp/80', 'tcp/443', ...]`` tokens.
  420. ``ports`` may be a comma/range string (the form widget's format, e.g. ``('tcp', '80,443,8000-8010')``)
  421. or an already-expanded list of ports (e.g. set programmatically).
  422. An empty ``ports`` yields a single bare ``'protocol/'`` token so ``validate_port_mappings`` reports a
  423. clear "expected protocol/port" error (rather than ``parse_numeric_range`` raising a confusing
  424. 'Range "" is invalid'). An empty ``protocol`` raises a clear error rather than producing a ``'/80'``
  425. token that surfaces as "Invalid protocol:" with a blank value. Shared by the model form field so the
  426. protocol/port pairing is built in one place, and so every entry path gets the blank-protocol check.
  427. """
  428. # Imported lazily to avoid pulling the forms layer in at module load.
  429. from utilities.forms.utils import parse_numeric_range
  430. # No case-folding here: validate_port_mappings (which every token below flows through) matches the
  431. # protocol case-insensitively and stores the canonical value.
  432. protocol = (protocol or '').strip()
  433. if not protocol:
  434. # Ports given with no protocol would otherwise expand to '/80' and surface as a confusing
  435. # "Invalid protocol:" with a blank value. Report the real problem instead, in wording that fits
  436. # all entry paths that route through here (the form widget and CSV import).
  437. raise ValidationError(_("Each port mapping must specify a protocol."))
  438. if isinstance(ports, (list, tuple)):
  439. # Already-expanded ports are paired as-is; validate_port_mappings() checks each value's range.
  440. if not ports:
  441. return [f'{protocol}/']
  442. return [f'{protocol}/{port}' for port in ports]
  443. ports_str = (ports or '').strip()
  444. if not ports_str:
  445. return [f'{protocol}/']
  446. # parse_numeric_range validates each range against the port bounds (rejecting reversed and
  447. # out-of-range values before expansion), so a non-empty string always yields >=1 port.
  448. return [
  449. f'{protocol}/{port}'
  450. for port in parse_numeric_range(ports_str, min_value=SERVICE_PORT_MIN, max_value=SERVICE_PORT_MAX)
  451. ]