services.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. from django.contrib.contenttypes.fields import GenericForeignKey
  2. from django.core.exceptions import ValidationError
  3. from django.db import models
  4. from django.utils.translation import gettext_lazy as _
  5. from ipam.choices import *
  6. from ipam.constants import *
  7. from netbox.models import PrimaryModel
  8. from netbox.models.features import ContactsMixin
  9. from utilities.data import array_to_string
  10. __all__ = (
  11. 'Service',
  12. 'ServiceTemplate',
  13. )
  14. class ServiceBase(models.Model):
  15. port_assignments = models.JSONField(
  16. verbose_name=_('port assignments'),
  17. default=list,
  18. blank=True,
  19. help_text=_('A list of protocol/port assignments, e.g. [{"protocol": "tcp", "port": 53}]')
  20. )
  21. _ports_lowest = models.PositiveIntegerField(
  22. null=True,
  23. blank=True,
  24. )
  25. class Meta:
  26. abstract = True
  27. def save(self, *args, **kwargs):
  28. # Compose port_assignments from any deprecated protocol/ports values assigned directly
  29. self._recompose_port_assignments()
  30. # On saving find the smallest port and save for default ordering
  31. self._ports_lowest = min(
  32. (assignment['port'] for assignment in self.port_assignments), default=None
  33. )
  34. update_fields = kwargs.get('update_fields')
  35. if update_fields is not None and '_ports_lowest' not in update_fields:
  36. kwargs['update_fields'] = list(update_fields) + ['_ports_lowest']
  37. super().save(*args, **kwargs)
  38. def __str__(self):
  39. return f'{self.name} ({self.port_list})'
  40. def clean(self):
  41. super().clean()
  42. # Compose port_assignments from any deprecated protocol/ports values assigned directly
  43. self._recompose_port_assignments()
  44. if not self.port_assignments:
  45. raise ValidationError({
  46. 'port_assignments': _("At least one protocol/port assignment must be defined.")
  47. })
  48. valid_protocols = ServiceProtocolChoices.values()
  49. for assignment in self.port_assignments:
  50. if not isinstance(assignment, dict) or set(assignment) != {'protocol', 'port'}:
  51. raise ValidationError({
  52. 'port_assignments': _("Each assignment must define exactly a protocol and a port.")
  53. })
  54. if assignment['protocol'] not in valid_protocols:
  55. raise ValidationError({
  56. 'port_assignments': _("Invalid protocol: {protocol}").format(protocol=assignment['protocol'])
  57. })
  58. port = assignment['port']
  59. if not isinstance(port, int) or not SERVICE_PORT_MIN <= port <= SERVICE_PORT_MAX:
  60. raise ValidationError({
  61. 'port_assignments': _("Invalid port number: {port}").format(port=port)
  62. })
  63. @property
  64. def protocol(self):
  65. """
  66. Deprecated backward-compatibility accessor. Returns the single protocol shared by all port
  67. assignments, or None if the service mixes protocols (or has no assignments).
  68. """
  69. protocols = {assignment['protocol'] for assignment in self.port_assignments}
  70. return protocols.pop() if len(protocols) == 1 else None
  71. @protocol.setter
  72. def protocol(self, value):
  73. # Deprecated: buffer the value for recomposition into port_assignments (see save()/clean())
  74. self._legacy_protocol = value
  75. @property
  76. def ports(self):
  77. """
  78. Deprecated backward-compatibility accessor. Returns a sorted list of all assigned port numbers.
  79. """
  80. return sorted({assignment['port'] for assignment in self.port_assignments})
  81. @ports.setter
  82. def ports(self, value):
  83. # Deprecated: buffer the value for recomposition into port_assignments (see save()/clean())
  84. self._legacy_ports = list(value) if value else []
  85. def _recompose_port_assignments(self):
  86. """
  87. If deprecated protocol and/or ports values were assigned directly (e.g. via bulk edit),
  88. rebuild port_assignments as the cartesian product of the effective protocols and ports.
  89. Missing values fall back to those already present in port_assignments.
  90. """
  91. has_protocol = hasattr(self, '_legacy_protocol')
  92. has_ports = hasattr(self, '_legacy_ports')
  93. if not (has_protocol or has_ports):
  94. return
  95. if has_protocol and self._legacy_protocol:
  96. protocols = [self._legacy_protocol]
  97. else:
  98. protocols = sorted({assignment['protocol'] for assignment in self.port_assignments})
  99. if has_ports:
  100. ports = self._legacy_ports
  101. else:
  102. ports = sorted({assignment['port'] for assignment in self.port_assignments})
  103. self.port_assignments = [
  104. {'protocol': protocol, 'port': port}
  105. for port in ports
  106. for protocol in protocols
  107. ]
  108. if has_protocol:
  109. del self._legacy_protocol
  110. if has_ports:
  111. del self._legacy_ports
  112. @property
  113. def port_list(self):
  114. # Group ports by protocol for compact display, e.g. "TCP/80, 443; UDP/53"
  115. protocol_labels = dict(ServiceProtocolChoices)
  116. grouped = {}
  117. for assignment in self.port_assignments:
  118. grouped.setdefault(assignment['protocol'], []).append(assignment['port'])
  119. return '; '.join(
  120. f'{protocol_labels.get(protocol, protocol)}/{array_to_string(sorted(ports))}'
  121. for protocol, ports in grouped.items()
  122. )
  123. class ServiceTemplate(ServiceBase, PrimaryModel):
  124. """
  125. A template for a Service to be applied to a device or virtual machine.
  126. """
  127. name = models.CharField(
  128. verbose_name=_('name'),
  129. max_length=100,
  130. unique=True
  131. )
  132. class Meta:
  133. ordering = ('name',)
  134. verbose_name = _('application service template')
  135. verbose_name_plural = _('application service templates')
  136. class Service(ContactsMixin, ServiceBase, PrimaryModel):
  137. """
  138. A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
  139. optionally be tied to one or more specific IPAddresses belonging to its parent.
  140. """
  141. parent_object_type = models.ForeignKey(
  142. to='contenttypes.ContentType',
  143. on_delete=models.PROTECT,
  144. related_name='+',
  145. )
  146. parent_object_id = models.PositiveBigIntegerField()
  147. parent = GenericForeignKey(
  148. ct_field='parent_object_type',
  149. fk_field='parent_object_id'
  150. )
  151. name = models.CharField(
  152. max_length=100,
  153. verbose_name=_('name')
  154. )
  155. ipaddresses = models.ManyToManyField(
  156. to='ipam.IPAddress',
  157. related_name='services',
  158. blank=True,
  159. verbose_name=_('IP addresses'),
  160. help_text=_("The specific IP addresses (if any) to which this application service is bound")
  161. )
  162. clone_fields = (
  163. 'port_assignments', 'description', 'parent', 'ipaddresses',
  164. )
  165. class Meta:
  166. indexes = (
  167. models.Index(fields=('_ports_lowest', 'id')), # Default ordering
  168. models.Index(fields=('parent_object_type', 'parent_object_id')),
  169. )
  170. ordering = ('_ports_lowest', 'id')
  171. verbose_name = _('application service')
  172. verbose_name_plural = _('application services')