From ba6df87d10de7b92e8507566fce5d2eadcb224c2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Jan 2020 16:26:06 -0500 Subject: [PATCH 1/5] Move min/max prefix length validators to ipam.validators --- netbox/extras/scripts.py | 2 +- netbox/ipam/validators.py | 18 +++++++++++++++++- netbox/utilities/validators.py | 18 +----------------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index fed003bed..b213a9a11 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -16,7 +16,7 @@ from mptt.models import MPTTModel from ipam.formfields import IPFormField from utilities.exceptions import AbortTransaction -from utilities.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator +from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING from .forms import ScriptForm from .signals import purge_changelog diff --git a/netbox/ipam/validators.py b/netbox/ipam/validators.py index 960675643..f95e174a3 100644 --- a/netbox/ipam/validators.py +++ b/netbox/ipam/validators.py @@ -1,4 +1,20 @@ -from django.core.validators import RegexValidator +from django.core.validators import BaseValidator, RegexValidator + + +class MaxPrefixLengthValidator(BaseValidator): + message = 'The prefix length must be less than or equal to %(limit_value)s.' + code = 'max_prefix_length' + + def compare(self, a, b): + return a.prefixlen > b + + +class MinPrefixLengthValidator(BaseValidator): + message = 'The prefix length must be greater than or equal to %(limit_value)s.' + code = 'min_prefix_length' + + def compare(self, a, b): + return a.prefixlen < b DNSValidator = RegexValidator( diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py index fb7a5bba7..cfa733208 100644 --- a/netbox/utilities/validators.py +++ b/netbox/utilities/validators.py @@ -1,6 +1,6 @@ import re -from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator +from django.core.validators import _lazy_re_compile, URLValidator class EnhancedURLValidator(URLValidator): @@ -26,19 +26,3 @@ class EnhancedURLValidator(URLValidator): r'(?:[/?#][^\s]*)?' # Path r'\Z', re.IGNORECASE) schemes = AnyURLScheme() - - -class MaxPrefixLengthValidator(BaseValidator): - message = 'The prefix length must be less than or equal to %(limit_value)s.' - code = 'max_prefix_length' - - def compare(self, a, b): - return a.prefixlen > b - - -class MinPrefixLengthValidator(BaseValidator): - message = 'The prefix length must be greater than or equal to %(limit_value)s.' - code = 'min_prefix_length' - - def compare(self, a, b): - return a.prefixlen < b From aa56c020ab4939be6e3c56bcd904036fca1f2e56 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Jan 2020 16:33:34 -0500 Subject: [PATCH 2/5] Move prefix_validator() to ipam.validators --- netbox/ipam/fields.py | 9 ++------- netbox/ipam/validators.py | 6 ++++++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/netbox/ipam/fields.py b/netbox/ipam/fields.py index 72600d1b9..c08cce829 100644 --- a/netbox/ipam/fields.py +++ b/netbox/ipam/fields.py @@ -2,15 +2,10 @@ from django.core.exceptions import ValidationError from django.db import models from netaddr import AddrFormatError, IPNetwork -from . import lookups +from . import lookups, validators from .formfields import IPFormField -def prefix_validator(prefix): - if prefix.ip != prefix.cidr.ip: - raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr)) - - class BaseIPField(models.Field): def python_type(self): @@ -51,7 +46,7 @@ class IPNetworkField(BaseIPField): IP prefix (network and mask) """ description = "PostgreSQL CIDR field" - default_validators = [prefix_validator] + default_validators = [validators.prefix_validator] def db_type(self, connection): return 'cidr' diff --git a/netbox/ipam/validators.py b/netbox/ipam/validators.py index f95e174a3..879e20e6a 100644 --- a/netbox/ipam/validators.py +++ b/netbox/ipam/validators.py @@ -1,6 +1,12 @@ +from django.core.exceptions import ValidationError from django.core.validators import BaseValidator, RegexValidator +def prefix_validator(prefix): + if prefix.ip != prefix.cidr.ip: + raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr)) + + class MaxPrefixLengthValidator(BaseValidator): message = 'The prefix length must be less than or equal to %(limit_value)s.' code = 'max_prefix_length' From f41564b578dafb6d41f253acb847ae8b0c36e832 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Jan 2020 17:14:31 -0500 Subject: [PATCH 3/5] Introduce IPAddressVar and IPAddressWithMaskVar --- docs/additional-features/custom-scripts.md | 15 +++++++- netbox/extras/scripts.py | 43 ++++++++++++++++------ netbox/ipam/fields.py | 4 +- netbox/ipam/formfields.py | 24 +++++++++++- 4 files changed, 69 insertions(+), 17 deletions(-) diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md index c4dffb4b9..6fac5b63d 100644 --- a/docs/additional-features/custom-scripts.md +++ b/docs/additional-features/custom-scripts.md @@ -124,7 +124,7 @@ Arbitrary text of any length. Renders as multi-line text input field. Stored a numeric integer. Options include: -* `min_value:` - Minimum value +* `min_value` - Minimum value * `max_value` - Maximum value ### BooleanVar @@ -158,9 +158,20 @@ A NetBox object. The list of available objects is defined by the queryset parame An uploaded file. Note that uploaded files are present in memory only for the duration of the script's execution: They will not be save for future use. +### IPAddressVar + +An IPv4 or IPv6 address, without a mask. Returns a `netaddr.IPAddress` object. + +### IPAddressWithMaskVar + +An IPv4 or IPv6 address with a mask. Returns a `netaddr.IPNetwork` object which includes the mask. + ### IPNetworkVar -An IPv4 or IPv6 network with a mask. +An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two attributes are available to validate the provided mask: + +* `min_prefix_length` - Minimum length of the mask (default: none) +* `max_prefix_length` - Maximum length of the mask (default: none) ### Default Options diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index b213a9a11..bd7e864e1 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -14,10 +14,10 @@ from django.db import transaction from mptt.forms import TreeNodeChoiceField, TreeNodeMultipleChoiceField from mptt.models import MPTTModel -from ipam.formfields import IPFormField -from utilities.exceptions import AbortTransaction -from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator +from ipam.formfields import IPAddressFormField, IPNetworkFormField +from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING +from utilities.exceptions import AbortTransaction from .forms import ScriptForm from .signals import purge_changelog @@ -27,6 +27,8 @@ __all__ = [ 'ChoiceVar', 'FileVar', 'IntegerVar', + 'IPAddressVar', + 'IPAddressWithMaskVar', 'IPNetworkVar', 'MultiObjectVar', 'ObjectVar', @@ -48,15 +50,19 @@ class ScriptVariable: def __init__(self, label='', description='', default=None, required=True): - # Default field attributes - self.field_attrs = { - 'help_text': description, - 'required': required - } + # Initialize field attributes + if not hasattr(self, 'field_attrs'): + self.field_attrs = {} + if description: + self.field_attrs['help_text'] = description if label: self.field_attrs['label'] = label if default: self.field_attrs['initial'] = default + if required: + self.field_attrs['required'] = True + if 'validators' not in self.field_attrs: + self.field_attrs['validators'] = [] def as_field(self): """ @@ -196,17 +202,32 @@ class FileVar(ScriptVariable): form_field = forms.FileField +class IPAddressVar(ScriptVariable): + """ + An IPv4 or IPv6 address without a mask. + """ + form_field = IPAddressFormField + + +class IPAddressWithMaskVar(ScriptVariable): + """ + An IPv4 or IPv6 address with a mask. + """ + form_field = IPNetworkFormField + + class IPNetworkVar(ScriptVariable): """ An IPv4 or IPv6 prefix. """ - form_field = IPFormField + form_field = IPNetworkFormField + field_attrs = { + 'validators': [prefix_validator] + } def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs): super().__init__(*args, **kwargs) - self.field_attrs['validators'] = list() - # Optional minimum/maximum prefix lengths if min_prefix_length is not None: self.field_attrs['validators'].append( diff --git a/netbox/ipam/fields.py b/netbox/ipam/fields.py index c08cce829..456a7debc 100644 --- a/netbox/ipam/fields.py +++ b/netbox/ipam/fields.py @@ -3,7 +3,7 @@ from django.db import models from netaddr import AddrFormatError, IPNetwork from . import lookups, validators -from .formfields import IPFormField +from .formfields import IPNetworkFormField class BaseIPField(models.Field): @@ -33,7 +33,7 @@ class BaseIPField(models.Field): return str(self.to_python(value)) def form_class(self): - return IPFormField + return IPNetworkFormField def formfield(self, **kwargs): defaults = {'form_class': self.form_class()} diff --git a/netbox/ipam/formfields.py b/netbox/ipam/formfields.py index 2909a54b1..1ab0a8ce3 100644 --- a/netbox/ipam/formfields.py +++ b/netbox/ipam/formfields.py @@ -1,13 +1,33 @@ from django import forms from django.core.exceptions import ValidationError -from netaddr import IPNetwork, AddrFormatError +from netaddr import IPAddress, IPNetwork, AddrFormatError # # Form fields # -class IPFormField(forms.Field): +class IPAddressFormField(forms.Field): + default_error_messages = { + 'invalid': "Enter a valid IPv4 or IPv6 address (without a mask).", + } + + def to_python(self, value): + if not value: + return None + + if isinstance(value, IPAddress): + return value + + try: + return IPAddress(value) + except ValueError: + raise ValidationError('This field requires an IP address without a mask.') + except AddrFormatError: + raise ValidationError("Please specify a valid IPv4 or IPv6 address.") + + +class IPNetworkFormField(forms.Field): default_error_messages = { 'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).", } From b7e71f9f39ba3860b722ee70ee6ca58647943c78 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Jan 2020 17:48:03 -0500 Subject: [PATCH 4/5] Add tests for IP address vars --- netbox/extras/tests/test_scripts.py | 56 ++++++++++++++++++++++++++++- netbox/ipam/formfields.py | 11 ++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/netbox/extras/tests/test_scripts.py b/netbox/extras/tests/test_scripts.py index 26e12772f..6237d1d95 100644 --- a/netbox/extras/tests/test_scripts.py +++ b/netbox/extras/tests/test_scripts.py @@ -1,6 +1,6 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase -from netaddr import IPNetwork +from netaddr import IPAddress, IPNetwork from dcim.models import DeviceRole from extras.scripts import * @@ -186,6 +186,54 @@ class ScriptVariablesTest(TestCase): self.assertTrue(form.is_valid()) self.assertEqual(form.cleaned_data['var1'], testfile) + def test_ipaddressvar(self): + + class TestScript(Script): + + var1 = IPAddressVar() + + # Validate IP network enforcement + data = {'var1': '1.2.3'} + form = TestScript().as_form(data, None) + self.assertFalse(form.is_valid()) + self.assertIn('var1', form.errors) + + # Validate IP mask exclusion + data = {'var1': '192.0.2.0/24'} + form = TestScript().as_form(data, None) + self.assertFalse(form.is_valid()) + self.assertIn('var1', form.errors) + + # Validate valid data + data = {'var1': '192.0.2.1'} + form = TestScript().as_form(data, None) + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['var1'], IPAddress(data['var1'])) + + def test_ipaddresswithmaskvar(self): + + class TestScript(Script): + + var1 = IPAddressWithMaskVar() + + # Validate IP network enforcement + data = {'var1': '1.2.3'} + form = TestScript().as_form(data, None) + self.assertFalse(form.is_valid()) + self.assertIn('var1', form.errors) + + # Validate IP mask requirement + data = {'var1': '192.0.2.0'} + form = TestScript().as_form(data, None) + self.assertFalse(form.is_valid()) + self.assertIn('var1', form.errors) + + # Validate valid data + data = {'var1': '192.0.2.0/24'} + form = TestScript().as_form(data, None) + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['var1'], IPNetwork(data['var1'])) + def test_ipnetworkvar(self): class TestScript(Script): @@ -198,6 +246,12 @@ class ScriptVariablesTest(TestCase): self.assertFalse(form.is_valid()) self.assertIn('var1', form.errors) + # Validate host IP check + data = {'var1': '192.0.2.1/24'} + form = TestScript().as_form(data, None) + self.assertFalse(form.is_valid()) + self.assertIn('var1', form.errors) + # Validate valid data data = {'var1': '192.0.2.0/24'} form = TestScript().as_form(data, None) diff --git a/netbox/ipam/formfields.py b/netbox/ipam/formfields.py index 1ab0a8ce3..e8d171d7f 100644 --- a/netbox/ipam/formfields.py +++ b/netbox/ipam/formfields.py @@ -1,5 +1,6 @@ from django import forms from django.core.exceptions import ValidationError +from django.core.validators import validate_ipv4_address, validate_ipv6_address from netaddr import IPAddress, IPNetwork, AddrFormatError @@ -19,6 +20,16 @@ class IPAddressFormField(forms.Field): if isinstance(value, IPAddress): return value + # netaddr is a bit too liberal with what it accepts as a valid IP address. For example, '1.2.3' will become + # IPAddress('1.2.0.3'). Here, we employ Django's built-in IPv4 and IPv6 address validators as a sanity check. + try: + validate_ipv4_address(value) + except ValidationError: + try: + validate_ipv6_address(value) + except ValidationError: + raise ValidationError("Invalid IPv4/IPv6 address format: {}".format(value)) + try: return IPAddress(value) except ValueError: From 72d1fe0cd773d062f4f573e17a4163fef3006e3f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Jan 2020 17:49:03 -0500 Subject: [PATCH 5/5] Changelog for #3509 --- docs/release-notes/version-2.7.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 971d0a60c..befa9c58f 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -3,6 +3,7 @@ ## Enhancements * [#3310](https://github.com/netbox-community/netbox/issues/3310) - Pre-select site/rack for B side when creating a new cable +* [#3509](https://github.com/netbox-community/netbox/issues/3509) - Add IP address variables for custom scripts ## Bug Fixes