diff --git a/app/models.py b/app/models.py index 3b5749b63cdc3ec28999375a2031f8f09628b3dd..4478aa16a31ba6dab6eb32c1aff8cb92b105af95 100644 --- a/app/models.py +++ b/app/models.py @@ -469,8 +469,10 @@ class Network(CreatedMixin, db.Model): def validate_interfaces(self, key, interface): """Ensure the interface IP is in the network range""" addr, net = self.ip_in_network(interface.ip, self.address) - if addr < self.first or addr > self.last: - raise ValidationError(f'IP address {interface.ip} is not in range {self.first} - {self.last}') + # Admin user can create IP outside the defined range + if not utils.cse_current_user().is_admin: + if addr < self.first or addr > self.last: + raise ValidationError(f'IP address {interface.ip} is not in range {self.first} - {self.last}') return interface @validates('vlan_name') @@ -561,11 +563,15 @@ class Interface(CreatedMixin, db.Model): backref=db.backref('interfaces', lazy=True)) def __init__(self, **kwargs): - # Automatically convert network to an instance of Network if it was passed - # as a string - if 'network' in kwargs: + # Always set self.network and not self.network_id to call validate_interfaces + network_id = kwargs.pop('network_id', None) + if network_id is not None: + kwargs['network'] = Network.query.get(network_id) + elif 'network' in kwargs: + # Automatically convert network to an instance of Network if it was passed + # as a string kwargs['network'] = utils.convert_to_model(kwargs['network'], Network, 'vlan_name') - # WARNING! Setting self.network will call validates_interfaces in the Network class + # WARNING! Setting self.network will call validate_interfaces in the Network class # For the validation to work, self.ip must be set before! # Ensure that ip is passed before network try: diff --git a/app/network/forms.py b/app/network/forms.py index 755626abac6176bf3de2b5024192968448dffe21..268bbb3de3319205f1ecc061ac252b603bf961a1 100644 --- a/app/network/forms.py +++ b/app/network/forms.py @@ -9,6 +9,7 @@ This module defines the network blueprint forms. :license: BSD 2-Clause, see LICENSE for more details. """ +import ipaddress from flask_login import current_user from wtforms import (SelectField, StringField, TextAreaField, IntegerField, SelectMultipleField, BooleanField, validators) @@ -44,6 +45,19 @@ def validate_tags(form, field): raise validators.ValidationError(f'A gateway is already defined for network {network}: {existing_gateway}') +def ip_in_network(form, field): + """Check that the IP is in the network""" + network_id_field = form['network_id'] + network = models.Network.query.get(network_id_field.data) + ip = ipaddress.ip_address(field.data) + if ip not in network.network_ip: + raise validators.ValidationError(f'IP address {ip} is not in network {network.address}') + # Admin user can create IP outside the defined range + if current_user.is_authenticated and not current_user.is_admin: + if ip < network.first or ip > network.last: + raise validators.ValidationError(f'IP address {ip} is not in range {network.first} - {network.last}') + + class NoValidateSelectField(SelectField): """SelectField with no choices validation @@ -122,9 +136,14 @@ class HostForm(CSEntryForm): class InterfaceForm(CSEntryForm): host_id = SelectField('Host') network_id = SelectField('Network') - # The list of IPs is dynamically created on the browser side - # depending on the selected network - ip = NoValidateSelectField('IP', choices=[]) + ip = StringField( + 'IP address', + validators=[validators.InputRequired(), + validators.IPAddress(), + ip_in_network, + Unique(models.Interface, column='ip'), + ], + ) interface_name = StringField( 'Interface name', description='name must be 2-20 characters long and contain only letters, numbers and dash', diff --git a/app/network/views.py b/app/network/views.py index eefcadc79ac3fc566eadedf80f212125f9b7be56..258c680e67a0b252e375e177d61a986f47ec2aeb 100644 --- a/app/network/views.py +++ b/app/network/views.py @@ -290,16 +290,16 @@ def retrieve_hosts(): return jsonify(data=data) -@bp.route('/_retrieve_available_ips/<int:network_id>') +@bp.route('/_retrieve_first_available_ip/<int:network_id>') @login_required -def retrieve_available_ips(network_id): +def retrieve_first_available_ip(network_id): try: network = models.Network.query.get(network_id) except sa.exc.DataError: current_app.logger.warning(f'Invalid network_id: {network_id}') - data = [] + data = '' else: - data = [str(address) for address in network.available_ips()] + data = str(network.available_ips()[0]) return jsonify(data=data) diff --git a/app/static/js/hosts.js b/app/static/js/hosts.js index 6a4ec41bd890a49467488aab9d9014e9b9f9f7e3..62f5a591365413f8841e9ad1616002523a9eacbc 100644 --- a/app/static/js/hosts.js +++ b/app/static/js/hosts.js @@ -1,17 +1,13 @@ $(document).ready(function() { - function update_available_ips() { - // Retrieve available IPs for the selected network - // and update the IP select field + function set_default_ip() { + // Retrieve the first available IP for the selected network + // and update the IP field var network_id = $("#network_id").val(); $.getJSON( - $SCRIPT_ROOT + "/network/_retrieve_available_ips/" + network_id, + $SCRIPT_ROOT + "/network/_retrieve_first_available_ip/" + network_id, function(json) { - var $ip = $("#ip"); - $ip.empty(); - $.map(json.data, function(option, index) { - $ip.append($("<option></option>").attr("value", option).text(option)); - }); + $("#ip").val(json.data); } ); } @@ -33,18 +29,18 @@ $(document).ready(function() { } } - // Populate IP select field on first page load for: + // Set the default IP on first page load for: // - register new host // - add interface - // Do NOT replace the IPs on edit interface page load! + // Do NOT replace the IP on edit interface page load! // (we have to keep the existing IP) if( $("#hostForm").length || $("#interfaceForm").length ) { - update_available_ips(); + set_default_ip(); } - // Update IP select field when changing network + // Set the default IP when changing network $("#network_id").on('change', function() { - update_available_ips(); + set_default_ip(); }); // Enable / disable item_id field depending on type diff --git a/app/utils.py b/app/utils.py index 5c84920ee30722a282387d03fd6f60bdb6bdd869..b1913ab1487ec239e05bf63ff8e0114590809df4 100644 --- a/app/utils.py +++ b/app/utils.py @@ -21,13 +21,18 @@ from flask_login import current_user from flask_jwt_extended import get_current_user +def cse_current_user(): + """Return the current_user from flask_jwt_extended (API) or flask_login (web UI)""" + return get_current_user() or current_user + + def fetch_current_user_id(): """Retrieve the user_id from flask_jwt_extended (API) or flask_login (web UI)""" # Return None if we are outside of request context. if _app_ctx_stack.top is None or _request_ctx_stack.top is None: return None # Try to get the user from both flask_jwt_extended and flask_login - user = get_current_user() or current_user + user = cse_current_user() try: return user.id except AttributeError: diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py index 2f5fa75f8dee253b33ac88d0df72fb20e84311f2..e725def62f172971ea3acd202177111882e68d86 100644 --- a/tests/functional/test_api.py +++ b/tests/functional/test_api.py @@ -654,6 +654,26 @@ def test_create_interface_ip_not_in_network(client, network_factory, user_token) check_response_message(response, 'IP address 192.168.2.4 is not in network 192.168.1.0/24', 422) +def test_create_interface_ip_not_in_range(client, network_factory, user_token): + network = network_factory(address='192.168.1.0/24', first_ip='192.168.1.10', last_ip='192.168.1.250') + # IP address not in range + data = {'network': network.vlan_name, + 'ip': '192.168.1.4', + 'name': 'hostname'} + response = post(client, f'{API_URL}/network/interfaces', data=data, token=user_token) + check_response_message(response, 'IP address 192.168.1.4 is not in range 192.168.1.10 - 192.168.1.250', 422) + + +def test_create_interface_ip_not_in_range_as_admin(client, network_factory, admin_token): + network = network_factory(address='192.168.1.0/24', first_ip='192.168.1.10', last_ip='192.168.1.250') + # IP address not in range + data = {'network': network.vlan_name, + 'ip': '192.168.1.4', + 'name': 'hostname'} + response = post(client, f'{API_URL}/network/interfaces', data=data, token=admin_token) + assert response.status_code == 201 + + def test_get_macs(client, mac_factory, readonly_token): # Create some macs mac1 = mac_factory()