diff --git a/docs/additional-features/napalm.md b/docs/additional-features/napalm.md index 2efaa839e..2387bc8b7 100644 --- a/docs/additional-features/napalm.md +++ b/docs/additional-features/napalm.md @@ -29,7 +29,7 @@ GET /api/dcim/devices/1/napalm/?method=get_environment ## Authentication -By default, the [`NAPALM_USERNAME`](../configuration/optional-settings.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/optional-settings.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers. +By default, the [`NAPALM_USERNAME`](../configuration/dynamic-settings.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/dynamic-settings.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers. ``` $ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \ diff --git a/docs/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md new file mode 100644 index 000000000..765e1d0f1 --- /dev/null +++ b/docs/configuration/dynamic-settings.md @@ -0,0 +1,137 @@ +# Dynamic Configuration Settings + +These configuration parameters are primarily controlled via NetBox's admin interface (under Admin > Extras > Configuration Revisions). These setting may also be overridden in `configuration.py`; this will prevent them from being modified via the UI. + +--- + +## ALLOWED_URL_SCHEMES + +Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')` + +A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all of the default values as well (excluding those schemes which are not desirable). + +--- + +## BANNER_TOP + +## BANNER_BOTTOM + +Setting these variables will display custom content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set: + +```python +BANNER_TOP = 'Your banner text' +BANNER_BOTTOM = BANNER_TOP +``` + +--- + +## BANNER_LOGIN + +This defines custom content to be displayed on the login page above the login form. HTML is allowed. + +--- + +## ENFORCE_GLOBAL_UNIQUE + +Default: False + +By default, NetBox will permit users to create duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This behavior can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to True. + +--- + +## MAINTENANCE_MODE + +Default: False + +Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled. + +--- + +## MAPS_URL + +Default: `https://maps.google.com/?q=` (Google Maps) + +This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. + +--- + +## MAX_PAGE_SIZE + +Default: 1000 + +A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`. + +--- + +## NAPALM_USERNAME + +## NAPALM_PASSWORD + +NetBox will use these credentials when authenticating to remote devices via the supported [NAPALM integration](../additional-features/napalm.md), if installed. Both parameters are optional. + +!!! note + If SSH public key authentication has been set up on the remote device(s) for the system account under which NetBox runs, these parameters are not needed. + +--- + +## NAPALM_ARGS + +A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example: + +```python +NAPALM_ARGS = { + 'api_key': '472071a93b60a1bd1fafb401d9f8ef41', + 'port': 2222, +} +``` + +Some platforms (e.g. Cisco IOS) require an argument named `secret` to be passed in addition to the normal password. If desired, you can use the configured `NAPALM_PASSWORD` as the value for this argument: + +```python +NAPALM_USERNAME = 'username' +NAPALM_PASSWORD = 'MySecretPassword' +NAPALM_ARGS = { + 'secret': NAPALM_PASSWORD, + # Include any additional args here +} +``` + +--- + +## NAPALM_TIMEOUT + +Default: 30 seconds + +The amount of time (in seconds) to wait for NAPALM to connect to a device. + +--- + +## PAGINATE_COUNT + +Default: 50 + +The default maximum number of objects to display per page within each list of objects. + +--- + +## PREFER_IPV4 + +Default: False + +When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead. + +--- + +## RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + +Default: 22 + +Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`. + +--- + +## RACK_ELEVATION_DEFAULT_UNIT_WIDTH + +Default: 220 + +Default width (in pixels) of a unit within a rack elevation. diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 8b0c4121a..c568fc7f0 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -1,18 +1,21 @@ # NetBox Configuration -NetBox's local configuration is stored in `$INSTALL_ROOT/netbox/netbox/configuration.py`. An example configuration is provided as `configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file. +NetBox's local configuration is stored in `$INSTALL_ROOT/netbox/netbox/configuration.py`. An example configuration is provided as `configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file. While NetBox has many configuration settings, only a few of them must be defined at the time of installation: these are defined under "required settings" below. -While NetBox has many configuration settings, only a few of them must be defined at the time of installation. +Some configuration parameters may alternatively be defined either in `configuration.py` or within the administrative section of the user interface. Settings which are "hard-coded" in the configuration file take precedence over those defined via the UI. ## Configuration Parameters * [Required settings](required-settings.md) * [Optional settings](optional-settings.md) +* [Dynamic settings](dynamic-settings.md) ## Changing the Configuration -Configuration settings may be changed at any time. However, the WSGI service (e.g. Gunicorn) must be restarted before the changes will take effect: +The configuration file may be modified at any time. However, the WSGI service (e.g. Gunicorn) must be restarted before the changes will take effect: ```no-highlight $ sudo systemctl restart netbox ``` + +Configuration parameters which are set via the admin UI (those listed under "dynamic settings") take effect immediately. diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 88dd80918..3c1e24e9b 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -13,33 +13,6 @@ ADMINS = [ --- -## ALLOWED_URL_SCHEMES - -Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')` - -A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all of the default values as well (excluding those schemes which are not desirable). - ---- - -## BANNER_TOP - -## BANNER_BOTTOM - -Setting these variables will display custom content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set: - -```python -BANNER_TOP = 'Your banner text' -BANNER_BOTTOM = BANNER_TOP -``` - ---- - -## BANNER_LOGIN - -This defines custom content to be displayed on the login page above the login form. HTML is allowed. - ---- - ## BASE_PATH Default: None @@ -168,14 +141,6 @@ Email is sent from NetBox only for critical events or if configured for [logging --- -## ENFORCE_GLOBAL_UNIQUE - -Default: False - -By default, NetBox will permit users to create duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This behavior can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to True. - ---- - ## EXEMPT_VIEW_PERMISSIONS Default: Empty list @@ -299,30 +264,6 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u --- -## MAINTENANCE_MODE - -Default: False - -Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled. - ---- - -## MAPS_URL - -Default: `https://maps.google.com/?q=` (Google Maps) - -This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. - ---- - -## MAX_PAGE_SIZE - -Default: 1000 - -A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`. - ---- - ## MEDIA_ROOT Default: $INSTALL_ROOT/netbox/media/ @@ -339,57 +280,6 @@ Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Pr --- -## NAPALM_USERNAME - -## NAPALM_PASSWORD - -NetBox will use these credentials when authenticating to remote devices via the supported [NAPALM integration](../additional-features/napalm.md), if installed. Both parameters are optional. - -!!! note - If SSH public key authentication has been set up on the remote device(s) for the system account under which NetBox runs, these parameters are not needed. - ---- - -## NAPALM_ARGS - -A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example: - -```python -NAPALM_ARGS = { - 'api_key': '472071a93b60a1bd1fafb401d9f8ef41', - 'port': 2222, -} -``` - -Some platforms (e.g. Cisco IOS) require an argument named `secret` to be passed in addition to the normal password. If desired, you can use the configured `NAPALM_PASSWORD` as the value for this argument: - -```python -NAPALM_USERNAME = 'username' -NAPALM_PASSWORD = 'MySecretPassword' -NAPALM_ARGS = { - 'secret': NAPALM_PASSWORD, - # Include any additional args here -} -``` - ---- - -## NAPALM_TIMEOUT - -Default: 30 seconds - -The amount of time (in seconds) to wait for NAPALM to connect to a device. - ---- - -## PAGINATE_COUNT - -Default: 50 - -The default maximum number of objects to display per page within each list of objects. - ---- - ## PLUGINS Default: Empty @@ -423,30 +313,6 @@ Note that a plugin must be listed in `PLUGINS` for its configuration to take eff --- -## PREFER_IPV4 - -Default: False - -When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead. - ---- - -## RACK_ELEVATION_DEFAULT_UNIT_HEIGHT - -Default: 22 - -Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`. - ---- - -## RACK_ELEVATION_DEFAULT_UNIT_WIDTH - -Default: 220 - -Default width (in pixels) of a unit within a rack elevation. - ---- - ## REMOTE_AUTH_AUTO_CREATE_USER Default: `False` diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index b583d8b44..5e8138642 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -29,6 +29,14 @@ Both types of connection include SSID and authentication attributes. Additionall * Channel - A predefined channel within a standardized band * Channel frequency & width - Customizable channel attributes (e.g. for licensed bands) +#### Dynamic Configuration Updates ([#5883](https://github.com/netbox-community/netbox/issues/5883)) + +Some parameters of NetBox's configuration are now accessible via the admin UI. These parameters can be modified by an administrator and take effect immediately upon application: There is no need to restart NetBox. Additionally, each iteration of the dynamic configuration is preserved in the database, and can be restored by an administrator at any time. + +Dynamic configuration parameters may also still be defined within `configuration.py`, and the settings defined here take precedence over those defined via the user interface. + +For a complete list of supported parameters, please see the [dynamic configuration documentation](../configuration/dynamic-settings.md). + #### Conditional Webhooks ([#6238](https://github.com/netbox-community/netbox/issues/6238)) Webhooks now include a `conditions` field, which may be used to specify conditions under which a webhook triggers. For example, you may wish to generate outgoing requests for a device webhook only when its status is "active" or "staged". This can be done by declaring conditional logic in JSON: diff --git a/docs/rest-api/overview.md b/docs/rest-api/overview.md index 6a9235438..27a9b6a7e 100644 --- a/docs/rest-api/overview.md +++ b/docs/rest-api/overview.md @@ -308,7 +308,7 @@ Vary: Accept } ``` -The default page is determined by the [`PAGINATE_COUNT`](../configuration/optional-settings.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for: +The default page is determined by the [`PAGINATE_COUNT`](../configuration/dynamic-settings.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for: ``` http://netbox/api/dcim/devices/?limit=100 @@ -325,7 +325,7 @@ The response will return devices 1 through 100. The URL provided in the `next` a } ``` -The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/optional-settings.md#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request. +The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/dynamic-settings.md#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request. !!! warning Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database. diff --git a/mkdocs.yml b/mkdocs.yml index 9d9bb964a..52efdd656 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -51,6 +51,7 @@ nav: - Configuring NetBox: 'configuration/index.md' - Required Settings: 'configuration/required-settings.md' - Optional Settings: 'configuration/optional-settings.md' + - Dynamic Settings: 'configuration/dynamic-settings.md' - Core Functionality: - IP Address Management: 'core-functionality/ipam.md' - VLAN Management: 'core-functionality/vlans.md' diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d8c5a7771..a5f4ac5fe 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -13,6 +13,7 @@ from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, ) +from netbox.config import ConfigItem from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer from utilities.api import get_serializer_for_model @@ -229,10 +230,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): default=RackElevationDetailRenderChoices.RENDER_JSON ) unit_width = serializers.IntegerField( - default=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH + default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_WIDTH') ) unit_height = serializers.IntegerField( - default=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT') ) legend_width = serializers.IntegerField( default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 921ee3a99..3248891f2 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,7 +1,6 @@ import socket from collections import OrderedDict -from django.conf import settings from django.http import Http404, HttpResponse, HttpResponseForbidden from django.shortcuts import get_object_or_404 from drf_yasg import openapi @@ -21,6 +20,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.exceptions import ServiceUnavailable from netbox.api.metadata import ContentTypeMetadata from netbox.api.views import ModelViewSet +from netbox.config import get_config from utilities.api import get_serializer_for_model from utilities.utils import count_related, decode_dict from virtualization.models import VirtualMachine @@ -457,9 +457,12 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): napalm_methods = request.GET.getlist('method') response = OrderedDict([(m, None) for m in napalm_methods]) - username = settings.NAPALM_USERNAME - password = settings.NAPALM_PASSWORD - optional_args = settings.NAPALM_ARGS.copy() + + config = get_config() + username = config.NAPALM_USERNAME + password = config.NAPALM_PASSWORD + timeout = config.NAPALM_TIMEOUT + optional_args = config.NAPALM_ARGS.copy() if device.platform.napalm_args is not None: optional_args.update(device.platform.napalm_args) @@ -481,7 +484,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): hostname=host, username=username, password=password, - timeout=settings.NAPALM_TIMEOUT, + timeout=timeout, optional_args=optional_args ) try: diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 2b3b80d24..418944a4a 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1,7 +1,6 @@ from collections import OrderedDict import yaml -from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator @@ -15,6 +14,7 @@ from dcim.constants import * from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features +from netbox.config import ConfigItem from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField @@ -815,7 +815,7 @@ class Device(PrimaryModel, ConfigContextModel): @property def primary_ip(self): - if settings.PREFER_IPV4 and self.primary_ip4: + if ConfigItem('PREFER_IPV4')() and self.primary_ip4: return self.primary_ip4 elif self.primary_ip6: return self.primary_ip6 diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index a6d7f33af..0bc28acaa 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,6 +1,5 @@ from collections import OrderedDict -from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.models import ContentType @@ -15,6 +14,7 @@ from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG from extras.utils import extras_features +from netbox.config import get_config from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField @@ -373,8 +373,8 @@ class Rack(PrimaryModel): self, face=DeviceFaceChoices.FACE_FRONT, user=None, - unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH, - unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT, + unit_width=None, + unit_height=None, legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT, include_images=True, base_url=None @@ -393,6 +393,10 @@ class Rack(PrimaryModel): :param base_url: Base URL for links and images. If none, URLs will be relative. """ elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url) + if unit_width is None or unit_height is None: + config = get_config() + unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH + unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT return elevation.render(face, unit_width, unit_height, legend_width) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 8ea27b8a6..167dba95d 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -160,18 +160,11 @@ class DeviceTable(BaseTable): linkify=True, verbose_name='Type' ) - if settings.PREFER_IPV4: - primary_ip = tables.Column( - linkify=True, - order_by=('primary_ip4', 'primary_ip6'), - verbose_name='IP Address' - ) - else: - primary_ip = tables.Column( - linkify=True, - order_by=('primary_ip6', 'primary_ip4'), - verbose_name='IP Address' - ) + primary_ip = tables.Column( + linkify=True, + order_by=('primary_ip4', 'primary_ip6'), + verbose_name='IP Address' + ) primary_ip4 = tables.Column( linkify=True, verbose_name='IPv4 Address' diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index dae21c2c9..752c8c83d 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,10 +1,128 @@ from django.contrib import admin +from django.shortcuts import get_object_or_404, redirect +from django.template.response import TemplateResponse +from django.urls import path, reverse +from django.utils.html import format_html -from .models import JobResult +from netbox.config import get_config, PARAMS +from .forms import ConfigRevisionForm +from .models import ConfigRevision, JobResult + + +@admin.register(ConfigRevision) +class ConfigRevisionAdmin(admin.ModelAdmin): + fieldsets = [ + ('Rack Elevations', { + 'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'), + }), + ('IPAM', { + 'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'), + }), + ('Security', { + 'fields': ('ALLOWED_URL_SCHEMES',), + }), + ('Banners', { + 'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'), + }), + ('Pagination', { + 'fields': ('PAGINATE_COUNT', 'MAX_PAGE_SIZE'), + }), + ('NAPALM', { + 'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'), + }), + ('Miscellaneous', { + 'fields': ('MAINTENANCE_MODE', 'MAPS_URL'), + }), + ('Config Revision', { + 'fields': ('comment',), + }) + ] + form = ConfigRevisionForm + list_display = ('id', 'is_active', 'created', 'comment', 'restore_link') + ordering = ('-id',) + readonly_fields = ('data',) + + def get_changeform_initial_data(self, request): + """ + Populate initial form data from the most recent ConfigRevision. + """ + latest_revision = ConfigRevision.objects.last() + initial = latest_revision.data if latest_revision else {} + initial.update(super().get_changeform_initial_data(request)) + + return initial + + # Permissions + + def has_add_permission(self, request): + # Only superusers may modify the configuration. + return request.user.is_superuser + + def has_change_permission(self, request, obj=None): + # ConfigRevisions cannot be modified once created. + return False + + def has_delete_permission(self, request, obj=None): + # Only inactive ConfigRevisions may be deleted (must be superuser). + return request.user.is_superuser and ( + obj is None or not obj.is_active() + ) + + # List display methods + + def restore_link(self, obj): + if obj.is_active(): + return '' + return format_html( + 'Restore', + url=reverse('admin:extras_configrevision_restore', args=(obj.pk,)) + ) + restore_link.short_description = "Actions" + + # URLs + + def get_urls(self): + urls = [ + path('/restore/', self.admin_site.admin_view(self.restore), name='extras_configrevision_restore'), + ] + + return urls + super().get_urls() + + # Views + + def restore(self, request, pk): + # Get the ConfigRevision being restored + candidate_config = get_object_or_404(ConfigRevision, pk=pk) + + if request.method == 'POST': + candidate_config.activate() + self.message_user(request, f"Restored configuration revision #{pk}") + + return redirect(reverse('admin:extras_configrevision_changelist')) + + # Get the current ConfigRevision + config_version = get_config().version + current_config = ConfigRevision.objects.filter(pk=config_version).first() + + params = [] + for param in PARAMS: + params.append(( + param.name, + current_config.data.get(param.name, None), + candidate_config.data.get(param.name, None) + )) + + context = self.admin_site.each_context(request) + context.update({ + 'object': candidate_config, + 'params': params, + }) + + return TemplateResponse(request, 'admin/extras/configrevision/restore.html', context) # -# Reports +# Reports & scripts # @admin.register(JobResult) diff --git a/netbox/extras/forms/__init__.py b/netbox/extras/forms/__init__.py index 1584e2f51..b470650da 100644 --- a/netbox/extras/forms/__init__.py +++ b/netbox/extras/forms/__init__.py @@ -3,4 +3,5 @@ from .filtersets import * from .bulk_edit import * from .bulk_import import * from .customfields import * +from .config import * from .scripts import * diff --git a/netbox/extras/forms/config.py b/netbox/extras/forms/config.py new file mode 100644 index 000000000..fab6fdbd1 --- /dev/null +++ b/netbox/extras/forms/config.py @@ -0,0 +1,79 @@ +from django import forms +from django.conf import settings + +from netbox.config import get_config, PARAMS + +__all__ = ( + 'ConfigRevisionForm', +) + + +EMPTY_VALUES = ('', None, [], ()) + + +class FormMetaclass(forms.models.ModelFormMetaclass): + + def __new__(mcs, name, bases, attrs): + + # Emulate a declared field for each supported configuration parameter + param_fields = {} + for param in PARAMS: + field_kwargs = { + 'required': False, + 'label': param.label, + 'help_text': param.description, + } + field_kwargs.update(**param.field_kwargs) + param_fields[param.name] = param.field(**field_kwargs) + attrs.update(param_fields) + + return super().__new__(mcs, name, bases, attrs) + + +class ConfigRevisionForm(forms.BaseModelForm, metaclass=FormMetaclass): + """ + Form for creating a new ConfigRevision. + """ + class Meta: + widgets = { + 'comment': forms.Textarea(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Append current parameter values to form field help texts and check for static configurations + config = get_config() + for param in PARAMS: + value = getattr(config, param.name) + is_static = hasattr(settings, param.name) + if value: + help_text = f'
Current value: {value}' + if is_static: + help_text += ' (defined statically)' + elif value == param.default: + help_text += ' (default)' + self.fields[param.name].help_text += help_text + if is_static: + self.fields[param.name].disabled = True + + def save(self, commit=True): + instance = super().save(commit=False) + + # Populate JSON data on the instance + instance.data = self.render_json() + + if commit: + instance.save() + + return instance + + def render_json(self): + json = {} + + # Iterate through each field and populate non-empty values + for field_name in self.declared_fields: + if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES: + json[field_name] = self.cleaned_data[field_name] + + return json diff --git a/netbox/extras/migrations/0064_configrevision.py b/netbox/extras/migrations/0064_configrevision.py new file mode 100644 index 000000000..c3fce8abe --- /dev/null +++ b/netbox/extras/migrations/0064_configrevision.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0063_webhook_conditions'), + ] + + operations = [ + migrations.CreateModel( + name='ConfigRevision', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('comment', models.CharField(blank=True, max_length=200)), + ('data', models.JSONField(blank=True, null=True)), + ], + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index 84676453f..3cb6372be 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -1,12 +1,13 @@ from .change_logging import ObjectChange from .configcontexts import ConfigContext, ConfigContextModel from .customfields import CustomField -from .models import CustomLink, ExportTemplate, ImageAttachment, JobResult, JournalEntry, Report, Script, Webhook +from .models import * from .tags import Tag, TaggedItem __all__ = ( 'ConfigContext', 'ConfigContextModel', + 'ConfigRevision', 'CustomField', 'CustomLink', 'ExportTemplate', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 43af19f82..57615c0c5 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -1,9 +1,11 @@ import json import uuid +from django.contrib import admin from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache from django.core.validators import ValidationError from django.db import models from django.http import HttpResponse @@ -20,8 +22,8 @@ from netbox.models import BigIDModel, ChangeLoggedModel from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 - __all__ = ( + 'ConfigRevision', 'CustomLink', 'ExportTemplate', 'ImageAttachment', @@ -33,10 +35,6 @@ __all__ = ( ) -# -# Webhooks -# - @extras_features('webhooks') class Webhook(ChangeLoggedModel): """ @@ -181,10 +179,6 @@ class Webhook(ChangeLoggedModel): return json.dumps(context, cls=JSONEncoder) -# -# Custom links -# - @extras_features('webhooks') class CustomLink(ChangeLoggedModel): """ @@ -240,10 +234,6 @@ class CustomLink(ChangeLoggedModel): return reverse('extras:customlink', args=[self.pk]) -# -# Export templates -# - @extras_features('webhooks') class ExportTemplate(ChangeLoggedModel): content_type = models.ForeignKey( @@ -333,10 +323,6 @@ class ExportTemplate(ChangeLoggedModel): return response -# -# Image attachments -# - class ImageAttachment(BigIDModel): """ An uploaded image which is associated with an object. @@ -409,11 +395,6 @@ class ImageAttachment(BigIDModel): return None -# -# Journal entries -# - - @extras_features('webhooks') class JournalEntry(ChangeLoggedModel): """ @@ -463,36 +444,6 @@ class JournalEntry(ChangeLoggedModel): return JournalEntryKindChoices.CSS_CLASSES.get(self.kind) -# -# Custom scripts -# - -@extras_features('job_results') -class Script(models.Model): - """ - Dummy model used to generate permissions for custom scripts. Does not exist in the database. - """ - class Meta: - managed = False - - -# -# Reports -# - -@extras_features('job_results') -class Report(models.Model): - """ - Dummy model used to generate permissions for reports. Does not exist in the database. - """ - class Meta: - managed = False - - -# -# Job results -# - class JobResult(BigIDModel): """ This model stores the results from running a user-defined report. @@ -582,3 +533,66 @@ class JobResult(BigIDModel): func.delay(*args, job_id=str(job_result.job_id), job_result=job_result, **kwargs) return job_result + + +class ConfigRevision(models.Model): + """ + An atomic revision of NetBox's configuration. + """ + created = models.DateTimeField( + auto_now_add=True + ) + comment = models.CharField( + max_length=200, + blank=True + ) + data = models.JSONField( + blank=True, + null=True, + verbose_name='Configuration data' + ) + + def __str__(self): + return f'Config revision #{self.pk} ({self.created})' + + def __getattr__(self, item): + if item in self.data: + return self.data[item] + return super().__getattribute__(item) + + def activate(self): + """ + Cache the configuration data. + """ + cache.set('config', self.data, None) + cache.set('config_version', self.pk, None) + + @admin.display(boolean=True) + def is_active(self): + return cache.get('config_version') == self.pk + + +# +# Custom scripts & reports +# + +@extras_features('job_results') +class Script(models.Model): + """ + Dummy model used to generate permissions for custom scripts. Does not exist in the database. + """ + class Meta: + managed = False + + +# +# Reports +# + +@extras_features('job_results') +class Report(models.Model): + """ + Dummy model used to generate permissions for reports. Does not exist in the database. + """ + class Meta: + managed = False diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 4f09706be..9b37dd763 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -8,7 +8,7 @@ from django_prometheus.models import model_deletes, model_inserts, model_updates from netbox.signals import post_clean from .choices import ObjectChangeActionChoices -from .models import CustomField, ObjectChange +from .models import ConfigRevision, CustomField, ObjectChange from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook @@ -161,3 +161,15 @@ def run_custom_validators(sender, instance, **kwargs): validators = settings.CUSTOM_VALIDATORS.get(model_name, []) for validator in validators: validator(instance) + + +# +# Dynamic configuration +# + +@receiver(post_save, sender=ConfigRevision) +def update_config(sender, instance, **kwargs): + """ + Update the cached NetBox configuration when a new ConfigRevision is created. + """ + instance.activate() diff --git a/netbox/ipam/api/mixins.py b/netbox/ipam/api/mixins.py index c09494d48..552c77d57 100644 --- a/netbox/ipam/api/mixins.py +++ b/netbox/ipam/api/mixins.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction from django.shortcuts import get_object_or_404 @@ -9,6 +8,7 @@ from rest_framework.decorators import action from rest_framework.response import Response from ipam.models import * +from netbox.config import get_config from utilities.constants import ADVISORY_LOCK_KEYS from . import serializers @@ -160,12 +160,15 @@ class AvailableIPsMixin: # Determine the maximum number of IPs to return else: + config = get_config() + PAGINATE_COUNT = config.PAGINATE_COUNT + MAX_PAGE_SIZE = config.MAX_PAGE_SIZE try: - limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT)) + limit = int(request.query_params.get('limit', PAGINATE_COUNT)) except ValueError: - limit = settings.PAGINATE_COUNT - if settings.MAX_PAGE_SIZE: - limit = min(limit, settings.MAX_PAGE_SIZE) + limit = PAGINATE_COUNT + if MAX_PAGE_SIZE: + limit = min(limit, MAX_PAGE_SIZE) # Calculate available IPs within the parent ip_list = [] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 514e87a62..6a26f08c3 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -1,10 +1,9 @@ import netaddr -from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models -from django.db.models import F, Q +from django.db.models import F from django.urls import reverse from django.utils.functional import cached_property @@ -17,6 +16,7 @@ from ipam.fields import IPNetworkField, IPAddressField from ipam.managers import IPAddressManager from ipam.querysets import PrefixQuerySet from ipam.validators import DNSValidator +from netbox.config import get_config from utilities.querysets import RestrictedQuerySet from virtualization.models import VirtualMachine @@ -316,7 +316,7 @@ class Prefix(PrimaryModel): }) # Enforce unique IP space (if applicable) - if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): + if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_prefixes = self.get_duplicates() if duplicate_prefixes: raise ValidationError({ @@ -811,7 +811,7 @@ class IPAddress(PrimaryModel): }) # Enforce unique IP space (if applicable) - if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): + if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_ips = self.get_duplicates() if duplicate_ips and ( self.role not in IPADDRESS_ROLES_NONUNIQUE or diff --git a/netbox/netbox/api/pagination.py b/netbox/netbox/api/pagination.py index e34cb27d0..d89e32124 100644 --- a/netbox/netbox/api/pagination.py +++ b/netbox/netbox/api/pagination.py @@ -1,7 +1,8 @@ -from django.conf import settings from django.db.models import QuerySet from rest_framework.pagination import LimitOffsetPagination +from netbox.config import get_config + class OptionalLimitOffsetPagination(LimitOffsetPagination): """ @@ -9,6 +10,8 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): matching a query, but retains the same format as a paginated request. The limit can only be disabled if MAX_PAGE_SIZE has been set to 0 or None. """ + def __init__(self): + self.default_limit = get_config().PAGINATE_COUNT def paginate_queryset(self, queryset, request, view=None): @@ -40,11 +43,9 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): if limit < 0: raise ValueError() # Enforce maximum page size, if defined - if settings.MAX_PAGE_SIZE: - if limit == 0: - return settings.MAX_PAGE_SIZE - else: - return min(limit, settings.MAX_PAGE_SIZE) + MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE + if MAX_PAGE_SIZE: + return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE) return limit except (KeyError, ValueError): pass diff --git a/netbox/netbox/config/__init__.py b/netbox/netbox/config/__init__.py new file mode 100644 index 000000000..a9a93636c --- /dev/null +++ b/netbox/netbox/config/__init__.py @@ -0,0 +1,105 @@ +import logging +import threading + +from django.conf import settings +from django.core.cache import cache +from django.db.utils import DatabaseError + +from .parameters import PARAMS + +__all__ = ( + 'clear_config', + 'ConfigItem', + 'get_config', + 'PARAMS', +) + +_thread_locals = threading.local() + +logger = logging.getLogger('netbox.config') + + +def get_config(): + """ + Return the current NetBox configuration, pulling it from cache if not already loaded in memory. + """ + if not hasattr(_thread_locals, 'config'): + _thread_locals.config = Config() + logger.debug("Initialized configuration") + return _thread_locals.config + + +def clear_config(): + """ + Delete the currently loaded configuration, if any. + """ + if hasattr(_thread_locals, 'config'): + del _thread_locals.config + logger.debug("Cleared configuration") + + +class Config: + """ + Fetch and store in memory the current NetBox configuration. This class must be instantiated prior to access, and + must be re-instantiated each time it's necessary to check for updates to the cached config. + """ + def __init__(self): + self._populate_from_cache() + if not self.config or not self.version: + self._populate_from_db() + self.defaults = {param.name: param.default for param in PARAMS} + + def __getattr__(self, item): + + # Check for hard-coded configuration in settings.py + if hasattr(settings, item): + return getattr(settings, item) + + # Return config value from cache + if item in self.config: + return self.config[item] + + # Fall back to the parameter's default value + if item in self.defaults: + return self.defaults[item] + + raise AttributeError(f"Invalid configuration parameter: {item}") + + def _populate_from_cache(self): + """Populate config data from Redis cache""" + self.config = cache.get('config') or {} + self.version = cache.get('config_version') + if self.config: + logger.debug("Loaded configuration data from cache") + + def _populate_from_db(self): + """Cache data from latest ConfigRevision, then populate from cache""" + from extras.models import ConfigRevision + + try: + revision = ConfigRevision.objects.last() + if revision is None: + logger.debug("No previous configuration found in database; proceeding with default values") + return + logger.debug("Loaded configuration data from database") + except DatabaseError: + # The database may not be available yet (e.g. when running a management command) + logger.warning(f"Skipping config initialization (database unavailable)") + return + + revision.activate() + logger.debug("Filled cache with data from latest ConfigRevision") + self._populate_from_cache() + + +class ConfigItem: + """ + A callable to retrieve a configuration parameter from the cache. This can serve as a placeholder to defer + referencing a configuration parameter. + """ + def __init__(self, item): + self.item = item + + def __call__(self): + config = get_config() + return getattr(config, self.item) diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py new file mode 100644 index 000000000..8bf1d6dc5 --- /dev/null +++ b/netbox/netbox/config/parameters.py @@ -0,0 +1,140 @@ +from django import forms +from django.contrib.postgres.forms import SimpleArrayField + + +class ConfigParam: + + def __init__(self, name, label, default, description='', field=None, field_kwargs=None): + self.name = name + self.label = label + self.default = default + self.field = field or forms.CharField + self.description = description + self.field_kwargs = field_kwargs or {} + + +PARAMS = ( + + # Banners + ConfigParam( + name='BANNER_LOGIN', + label='Login banner', + default='', + description="Additional content to display on the login page" + ), + ConfigParam( + name='BANNER_TOP', + label='Top banner', + default='', + description="Additional content to display at the top of every page" + ), + ConfigParam( + name='BANNER_BOTTOM', + label='Bottom banner', + default='', + description="Additional content to display at the bottom of every page" + ), + + # IPAM + ConfigParam( + name='ENFORCE_GLOBAL_UNIQUE', + label='Globally unique IP space', + default=False, + description="Enforce unique IP addressing within the global table", + field=forms.BooleanField + ), + ConfigParam( + name='PREFER_IPV4', + label='Prefer IPv4', + default=False, + description="Prefer IPv4 addresses over IPv6", + field=forms.BooleanField + ), + + # Racks + ConfigParam( + name='RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', + label='Rack unit height', + default=22, + description="Default unit height for rendered rack elevations", + field=forms.IntegerField + ), + ConfigParam( + name='RACK_ELEVATION_DEFAULT_UNIT_WIDTH', + label='Rack unit width', + default=220, + description="Default unit width for rendered rack elevations", + field=forms.IntegerField + ), + + # Security + ConfigParam( + name='ALLOWED_URL_SCHEMES', + label='Allowed URL schemes', + default=( + 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', + 'xmpp', + ), + description="Permitted schemes for URLs in user-provided content", + field=SimpleArrayField, + field_kwargs={'base_field': forms.CharField()} + ), + + # Pagination + ConfigParam( + name='PAGINATE_COUNT', + label='Default page size', + default=50, + field=forms.IntegerField + ), + ConfigParam( + name='MAX_PAGE_SIZE', + label='Maximum page size', + default=1000, + field=forms.IntegerField + ), + + # NAPALM + ConfigParam( + name='NAPALM_USERNAME', + label='NAPALM username', + default='', + description="Username to use when connecting to devices via NAPALM" + ), + ConfigParam( + name='NAPALM_PASSWORD', + label='NAPALM password', + default='', + description="Password to use when connecting to devices via NAPALM" + ), + ConfigParam( + name='NAPALM_TIMEOUT', + label='NAPALM timeout', + default=30, + description="NAPALM connection timeout (in seconds)", + field=forms.IntegerField + ), + ConfigParam( + name='NAPALM_ARGS', + label='NAPALM arguments', + default={}, + description="Additional arguments to pass when invoking a NAPALM driver (as JSON data)", + field=forms.JSONField + ), + + # Miscellaneous + ConfigParam( + name='MAINTENANCE_MODE', + label='Maintenance mode', + default=False, + description="Enable maintenance mode", + field=forms.BooleanField + ), + ConfigParam( + name='MAPS_URL', + label='Maps URL', + default='https://maps.google.com/?q=', + description="Base URL for mapping geographic locations" + ), + +) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 03023740f..189e98d11 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -72,19 +72,6 @@ ADMINS = [ # ('John Doe', 'jdoe@example.com'), ] -# URL schemes that are allowed within links in NetBox -ALLOWED_URL_SCHEMES = ( - 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', -) - -# Optionally display a persistent banner at the top and/or bottom of every page. HTML is allowed. To display the same -# content in both banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP. -BANNER_TOP = '' -BANNER_BOTTOM = '' - -# Text to include on the login page above the login form. HTML is allowed. -BANNER_LOGIN = '' - # Base URL path if accessing NetBox within a directory. For example, if installed at https://example.com/netbox/, set: # BASE_PATH = 'netbox/' BASE_PATH = '' @@ -134,10 +121,6 @@ EMAIL = { 'FROM_EMAIL': '', } -# Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table -# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True. -ENFORCE_GLOBAL_UNIQUE = False - # Exempt certain models from the enforcement of view permissions. Models listed here will be viewable by all users and # by anonymous users. List models in the form `.`. Add '*' to this list to exempt all models. EXEMPT_VIEW_PERMISSIONS = [ @@ -175,17 +158,6 @@ LOGIN_REQUIRED = False # re-authenticate. (Default: 1209600 [14 days]) LOGIN_TIMEOUT = None -# Setting this to True will display a "maintenance mode" banner at the top of every page. -MAINTENANCE_MODE = False - -# The URL to use when mapping physical addresses or GPS coordinates -MAPS_URL = 'https://maps.google.com/?q=' - -# An API consumer can request an arbitrary number of objects =by appending the "limit" parameter to the URL (e.g. -# "?limit=1000"). This setting defines the maximum limit. Setting it to 0 or None will allow an API consumer to request -# all objects by specifying "?limit=0". -MAX_PAGE_SIZE = 1000 - # The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that # the default value of this setting is derived from the installed location. # MEDIA_ROOT = '/opt/netbox/netbox/media' @@ -203,20 +175,6 @@ MAX_PAGE_SIZE = 1000 # Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics' METRICS_ENABLED = False -# Credentials that NetBox will uses to authenticate to devices when connecting via NAPALM. -NAPALM_USERNAME = '' -NAPALM_PASSWORD = '' - -# NAPALM timeout (in seconds). (Default: 30) -NAPALM_TIMEOUT = 30 - -# NAPALM optional arguments (see https://napalm.readthedocs.io/en/latest/support/#optional-arguments). Arguments must -# be provided as a dictionary. -NAPALM_ARGS = {} - -# Determine how many objects to display per page within a list. (Default: 50) -PAGINATE_COUNT = 50 - # Enable installed plugins. Add the name of each plugin to the list. PLUGINS = [] @@ -229,14 +187,6 @@ PLUGINS = [] # } # } -# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to -# prefer IPv4 instead. -PREFER_IPV4 = False - -# Rack elevation size defaults, in pixels. For best results, the ratio of width to height should be roughly 10:1. -RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = 22 -RACK_ELEVATION_DEFAULT_UNIT_WIDTH = 220 - # Remote authentication support REMOTE_AUTH_ENABLED = False REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend' diff --git a/netbox/netbox/context_processors.py b/netbox/netbox/context_processors.py index d6dd67d99..74178ceb4 100644 --- a/netbox/netbox/context_processors.py +++ b/netbox/netbox/context_processors.py @@ -1,6 +1,7 @@ from django.conf import settings as django_settings from extras.registry import registry +from netbox.config import get_config def settings_and_registry(request): @@ -9,6 +10,7 @@ def settings_and_registry(request): """ return { 'settings': django_settings, + 'config': get_config(), 'registry': registry, 'preferences': request.user.config if request.user.is_authenticated else {}, } diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index a8f989a2a..8d03c6aee 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -11,11 +11,12 @@ from django.http import Http404, HttpResponseRedirect from django.urls import reverse from extras.context_managers import change_logging +from netbox.config import clear_config from netbox.views import server_error from utilities.api import is_api_request, rest_api_server_error -class LoginRequiredMiddleware(object): +class LoginRequiredMiddleware: """ If LOGIN_REQUIRED is True, redirect all non-authenticated users to the login page. """ @@ -114,7 +115,7 @@ class RemoteUserMiddleware(RemoteUserMiddleware_): return groups -class ObjectChangeMiddleware(object): +class ObjectChangeMiddleware: """ This middleware performs three functions in response to an object being created, updated, or deleted: @@ -144,7 +145,7 @@ class ObjectChangeMiddleware(object): return response -class APIVersionMiddleware(object): +class APIVersionMiddleware: """ If the request is for an API endpoint, include the API version as a response header. """ @@ -159,7 +160,20 @@ class APIVersionMiddleware(object): return response -class ExceptionHandlingMiddleware(object): +class DynamicConfigMiddleware: + """ + Store the cached NetBox configuration in thread-local storage for the duration of the request. + """ + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + clear_config() + return response + + +class ExceptionHandlingMiddleware: """ Intercept certain exceptions which are likely indicative of installation issues and provide helpful instructions to the user. diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 279b8c453..45475ef9a 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -11,6 +11,8 @@ from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import URLValidator +from netbox.config import PARAMS + # # Environment setup @@ -68,14 +70,8 @@ DATABASE = getattr(configuration, 'DATABASE') REDIS = getattr(configuration, 'REDIS') SECRET_KEY = getattr(configuration, 'SECRET_KEY') -# Set optional parameters +# Set static config parameters ADMINS = getattr(configuration, 'ADMINS', []) -ALLOWED_URL_SCHEMES = getattr(configuration, 'ALLOWED_URL_SCHEMES', ( - 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', -)) -BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '') -BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '') -BANNER_TOP = getattr(configuration, 'BANNER_TOP', '') BASE_PATH = getattr(configuration, 'BASE_PATH', '') if BASE_PATH: BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only @@ -90,30 +86,19 @@ DEBUG = getattr(configuration, 'DEBUG', False) DEVELOPER = getattr(configuration, 'DEVELOPER', False) DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs')) EMAIL = getattr(configuration, 'EMAIL', {}) -ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True) HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) LOGGING = getattr(configuration, 'LOGGING', {}) +LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) -MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False) -MAPS_URL = getattr(configuration, 'MAPS_URL', 'https://maps.google.com/?q=') -MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000) MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/') METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) -NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {}) -NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '') -NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30) -NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '') -PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) -LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) PLUGINS = getattr(configuration, 'PLUGINS', []) PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) -PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) -RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 22) -RACK_ELEVATION_DEFAULT_UNIT_WIDTH = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH', 220) +RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend') REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', []) @@ -127,7 +112,6 @@ REMOTE_AUTH_SUPERUSERS = getattr(configuration, 'REMOTE_AUTH_SUPERUSERS', []) REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', []) REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', []) REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|') -RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') @@ -141,6 +125,11 @@ STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {}) TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a') TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') +# Check for hard-coded dynamic config parameters +for param in PARAMS: + if hasattr(configuration, param.name): + globals()[param.name] = getattr(configuration, param.name) + # Validate update repo URL and timeout if RELEASE_CHECK_URL: validator = URLValidator( @@ -346,6 +335,7 @@ MIDDLEWARE = [ 'netbox.middleware.ExceptionHandlingMiddleware', 'netbox.middleware.RemoteUserMiddleware', 'netbox.middleware.LoginRequiredMiddleware', + 'netbox.middleware.DynamicConfigMiddleware', 'netbox.middleware.APIVersionMiddleware', 'netbox.middleware.ObjectChangeMiddleware', 'django_prometheus.middleware.PrometheusAfterMiddleware', @@ -466,7 +456,7 @@ REST_FRAMEWORK = { ), 'DEFAULT_VERSION': REST_FRAMEWORK_VERSION, 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', - 'PAGE_SIZE': PAGINATE_COUNT, + # 'PAGE_SIZE': PAGINATE_COUNT, 'SCHEMA_COERCE_METHOD_NAMES': { # Default mappings 'retrieve': 'read', @@ -565,23 +555,6 @@ RQ_QUEUES = { } -# -# NetBox internal settings -# - -# Pagination -if MAX_PAGE_SIZE and PAGINATE_COUNT > MAX_PAGE_SIZE: - raise ImproperlyConfigured( - f"PAGINATE_COUNT ({PAGINATE_COUNT}) must be less than or equal to MAX_PAGE_SIZE ({MAX_PAGE_SIZE}), if set." - ) -PER_PAGE_DEFAULTS = [ - 25, 50, 100, 250, 500, 1000 -] -if PAGINATE_COUNT not in PER_PAGE_DEFAULTS: - PER_PAGE_DEFAULTS.append(PAGINATE_COUNT) - PER_PAGE_DEFAULTS = sorted(PER_PAGE_DEFAULTS) - - # # Plugins # diff --git a/netbox/netbox/tests/test_config.py b/netbox/netbox/tests/test_config.py new file mode 100644 index 000000000..d3a0328b3 --- /dev/null +++ b/netbox/netbox/tests/test_config.py @@ -0,0 +1,53 @@ +from django.conf import settings +from django.core.cache import cache +from django.test import override_settings, TestCase + +from extras.models import ConfigRevision +from netbox.config import clear_config, get_config + + +# Prefix cache keys to avoid interfering with the local environment +CACHES = settings.CACHES +CACHES['default'].update({'KEY_PREFIX': 'TEST-'}) + + +class ConfigTestCase(TestCase): + + @override_settings(CACHES=CACHES) + def test_config_init_empty(self): + cache.clear() + + config = get_config() + self.assertEqual(config.config, {}) + self.assertEqual(config.version, None) + + clear_config() + + @override_settings(CACHES=CACHES) + def test_config_init_from_db(self): + CONFIG_DATA = {'BANNER_TOP': 'A'} + cache.clear() + + # Create a config but don't load it into the cache + configrevision = ConfigRevision.objects.create(data=CONFIG_DATA) + + config = get_config() + self.assertEqual(config.config, CONFIG_DATA) + self.assertEqual(config.version, configrevision.pk) + + clear_config() + + @override_settings(CACHES=CACHES) + def test_config_init_from_cache(self): + CONFIG_DATA = {'BANNER_TOP': 'B'} + cache.clear() + + # Create a config and load it into the cache + configrevision = ConfigRevision.objects.create(data=CONFIG_DATA) + configrevision.activate() + + config = get_config() + self.assertEqual(config.config, CONFIG_DATA) + self.assertEqual(config.version, configrevision.pk) + + clear_config() diff --git a/netbox/templates/admin/extras/configrevision/restore.html b/netbox/templates/admin/extras/configrevision/restore.html new file mode 100644 index 000000000..4a0eb81a6 --- /dev/null +++ b/netbox/templates/admin/extras/configrevision/restore.html @@ -0,0 +1,37 @@ +{% extends "admin/base_site.html" %} +{% load static %} + +{% block content %} +

Restore configuration #{{ object.pk }} from {{ object.created }}?

+ + + + + + + + + + + + {% for param, current, new in params %} + + + + + + + {% endfor %} + +
ParameterCurrent ValueNew Value
{{ param }}{{ current }}{{ new }}{% if current != new %}*{% endif %}
+ +
+ {% csrf_token %} +
+ + Cancel +
+
+{% endblock content %} + + diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 9575d4dcb..2770a6dc6 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -58,13 +58,13 @@ - {% if settings.BANNER_TOP %} + {% if config.BANNER_TOP %} {% endif %} - {% if settings.MAINTENANCE_MODE %} + {% if config.MAINTENANCE_MODE %} - {% if settings.BANNER_BOTTOM %} + {% if config.BANNER_BOTTOM %} {% endif %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index a17c505a9..7429aa4f5 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -100,7 +100,7 @@ {% if object.physical_address %} @@ -119,7 +119,7 @@ {% if object.latitude and object.longitude %} diff --git a/netbox/templates/inc/paginator.html b/netbox/templates/inc/paginator.html index c55203be3..8242ffcde 100644 --- a/netbox/templates/inc/paginator.html +++ b/netbox/templates/inc/paginator.html @@ -36,7 +36,7 @@ {% endfor %}
diff --git a/netbox/templates/login.html b/netbox/templates/login.html index 37cdd8e53..a01d75422 100644 --- a/netbox/templates/login.html +++ b/netbox/templates/login.html @@ -7,9 +7,9 @@
{# Login banner #} - {% if settings.BANNER_LOGIN %} + {% if config.BANNER_LOGIN %} {% endif %} diff --git a/netbox/users/views.py b/netbox/users/views.py index afee10eeb..ab17955e3 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -1,6 +1,5 @@ import logging -from django.conf import settings from django.contrib import messages from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash from django.contrib.auth.mixins import LoginRequiredMixin @@ -14,6 +13,7 @@ from django.utils.http import is_safe_url from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import View +from netbox.config import get_config from utilities.forms import ConfirmationForm from .forms import LoginForm, PasswordChangeForm, TokenForm from .models import Token @@ -53,7 +53,7 @@ class LoginView(View): # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's # last_login time upon authentication. - if settings.MAINTENANCE_MODE: + if get_config().MAINTENANCE_MODE: logger.warning("Maintenance mode enabled: disabling update of most recent login time") user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login') diff --git a/netbox/utilities/paginator.py b/netbox/utilities/paginator.py index e46af4b3e..4cc3ef601 100644 --- a/netbox/utilities/paginator.py +++ b/netbox/utilities/paginator.py @@ -1,8 +1,12 @@ -from django.conf import settings from django.core.paginator import Paginator, Page +from netbox.config import get_config + class EnhancedPaginator(Paginator): + default_page_lengths = ( + 25, 50, 100, 250, 500, 1000 + ) def __init__(self, object_list, per_page, orphans=None, **kwargs): @@ -10,9 +14,9 @@ class EnhancedPaginator(Paginator): try: per_page = int(per_page) if per_page < 1: - per_page = settings.PAGINATE_COUNT + per_page = get_config().PAGINATE_COUNT except ValueError: - per_page = settings.PAGINATE_COUNT + per_page = get_config().PAGINATE_COUNT # Set orphans count based on page size if orphans is None and per_page <= 50: @@ -25,6 +29,11 @@ class EnhancedPaginator(Paginator): def _get_page(self, *args, **kwargs): return EnhancedPage(*args, **kwargs) + def get_page_lengths(self): + if self.per_page not in self.default_page_lengths: + return sorted([*self.default_page_lengths, self.per_page]) + return self.default_page_lengths + class EnhancedPage(Page): @@ -57,17 +66,19 @@ def get_paginate_count(request): Return the lesser of the calculated value and MAX_PAGE_SIZE. """ + config = get_config() + if 'per_page' in request.GET: try: per_page = int(request.GET.get('per_page')) if request.user.is_authenticated: request.user.config.set('pagination.per_page', per_page, commit=True) - return min(per_page, settings.MAX_PAGE_SIZE) + return min(per_page, config.MAX_PAGE_SIZE) except ValueError: pass if request.user.is_authenticated: - per_page = request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT) - return min(per_page, settings.MAX_PAGE_SIZE) + per_page = request.user.config.get('pagination.per_page', config.PAGINATE_COUNT) + return min(per_page, config.MAX_PAGE_SIZE) - return min(settings.PAGINATE_COUNT, settings.MAX_PAGE_SIZE) + return min(config.PAGINATE_COUNT, config.MAX_PAGE_SIZE) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 1b5bb220d..9b510d9ed 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -14,6 +14,7 @@ from django.utils.html import strip_tags from django.utils.safestring import mark_safe from markdown import markdown +from netbox.config import get_config from utilities.forms import get_selected_values, TableConfigForm from utilities.utils import foreground_color @@ -44,7 +45,7 @@ def render_markdown(value): value = strip_tags(value) # Sanitize Markdown links - schemes = '|'.join(settings.ALLOWED_URL_SCHEMES) + schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES) pattern = fr'\[(.+)\]\((?!({schemes})).*:(.+)\)' value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE) diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py index 5b711056a..1171bd496 100644 --- a/netbox/utilities/tests/test_api.py +++ b/netbox/utilities/tests/test_api.py @@ -1,6 +1,5 @@ import urllib.parse -from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.test import Client, TestCase, override_settings from django.urls import reverse @@ -10,6 +9,7 @@ from dcim.models import Region, Site from extras.choices import CustomFieldTypeChoices from extras.models import CustomField from ipam.models import VLAN +from netbox.config import get_config from utilities.testing import APITestCase, disable_warnings @@ -137,7 +137,7 @@ class APIPaginationTestCase(APITestCase): def test_default_page_size(self): response = self.client.get(self.url, format='json', **self.header) - page_size = settings.PAGINATE_COUNT + page_size = get_config().PAGINATE_COUNT self.assertLess(page_size, 100, "Default page size not sufficient for data set") self.assertHttpStatus(response, status.HTTP_200_OK) diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py index b087b0867..5fce17a3a 100644 --- a/netbox/utilities/validators.py +++ b/netbox/utilities/validators.py @@ -1,9 +1,10 @@ import re -from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator +from netbox.config import get_config + class EnhancedURLValidator(URLValidator): """ @@ -19,7 +20,11 @@ class EnhancedURLValidator(URLValidator): r'(?::\d{2,5})?' # Port number r'(?:[/?#][^\s]*)?' # Path r'\Z', re.IGNORECASE) - schemes = settings.ALLOWED_URL_SCHEMES + + def __init__(self, schemes=None, **kwargs): + super().__init__(**kwargs) + if schemes is not None: + self.schemes = get_config().ALLOWED_URL_SCHEMES class ExclusionValidator(BaseValidator): diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index c614618c0..db2404546 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator @@ -9,6 +8,7 @@ from dcim.models import BaseInterface, Device from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features +from netbox.config import get_config from netbox.models import OrganizationalModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface @@ -340,7 +340,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): @property def primary_ip(self): - if settings.PREFER_IPV4 and self.primary_ip4: + if get_config().PREFER_IPV4 and self.primary_ip4: return self.primary_ip4 elif self.primary_ip6: return self.primary_ip6 diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 56ad88f1f..0a605267d 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -17,8 +17,6 @@ __all__ = ( 'VMInterfaceTable', ) -PRIMARY_IP_ORDERING = ('primary_ip4', 'primary_ip6') if settings.PREFER_IPV4 else ('primary_ip6', 'primary_ip4') - VMINTERFACE_BUTTONS = """ {% if perms.ipam.add_ipaddress %} @@ -136,7 +134,7 @@ class VirtualMachineTable(BaseTable): ) primary_ip = tables.Column( linkify=True, - order_by=PRIMARY_IP_ORDERING, + order_by=('primary_ip4', 'primary_ip6'), verbose_name='IP Address' ) tags = TagColumn(