diff --git a/app/api/network.py b/app/api/network.py index 73c98c702b7c3f60e57f19667a3f44ca4ff1eb8e..2c8b3d4e2a58117dde385a51bcdad69b46603aec 100644 --- a/app/api/network.py +++ b/app/api/network.py @@ -73,6 +73,7 @@ def create_network(): :jsonparam address: vlan address :jsonparam first_ip: first IP of the allowed range :jsonparam last_ip: last IP of the allowed range + :jsonparam gateway: gateway IP :jsonparam scope: network scope name :jsonparam domain_id: (optional) primary key of the domain [default: scope domain] :jsonparam admin_only: (optional) boolean to restrict the network to admin users [default: False] @@ -87,6 +88,7 @@ def create_network(): "address", "first_ip", "last_ip", + "gateway", "scope", ), ) diff --git a/app/models.py b/app/models.py index 41a0ed6729287b2cc4d2addf67b36a1fa00169f9..c766762e6cd140ca463ba0418f088cda2a933f61 100644 --- a/app/models.py +++ b/app/models.py @@ -759,6 +759,7 @@ class Network(CreatedMixin, db.Model): address = db.Column(postgresql.CIDR, nullable=False, unique=True) first_ip = db.Column(postgresql.INET, nullable=False, unique=True) last_ip = db.Column(postgresql.INET, nullable=False, unique=True) + gateway = db.Column(postgresql.INET, nullable=False, unique=True) description = db.Column(db.Text) admin_only = db.Column(db.Boolean, nullable=False, default=False) scope_id = db.Column(db.Integer, db.ForeignKey("network_scope.id"), nullable=False) @@ -772,6 +773,7 @@ class Network(CreatedMixin, db.Model): sa.CheckConstraint("first_ip < last_ip", name="first_ip_less_than_last_ip"), sa.CheckConstraint("first_ip << address", name="first_ip_in_network"), sa.CheckConstraint("last_ip << address", name="last_ip_in_network"), + sa.CheckConstraint("gateway << address", name="gateway_in_network"), ) def __init__(self, **kwargs): @@ -825,11 +827,6 @@ class Network(CreatedMixin, db.Model): """Return the list of IP addresses available""" return [addr for addr in self.ip_range() if addr not in self.used_ips()] - @property - def gateway(self): - """Return the network gateway IP""" - return list(self.network_ip.hosts())[-1] - @staticmethod def ip_in_network(ip, address): """Ensure the IP is in the network @@ -897,7 +894,7 @@ class Network(CreatedMixin, db.Model): "netmask": str(self.netmask), "first_ip": self.first_ip, "last_ip": self.last_ip, - "gateway": str(self.gateway), + "gateway": self.gateway, "description": self.description, "admin_only": self.admin_only, "scope": utils.format_field(self.scope), diff --git a/app/network/forms.py b/app/network/forms.py index ab58a88308a8142737f730fb8f9b162b326430cb..fad2c3c44ccb1d1f3b3e46011ac10903ec667a50 100644 --- a/app/network/forms.py +++ b/app/network/forms.py @@ -117,6 +117,7 @@ class NetworkForm(CSEntryForm): address = NoValidateSelectField("Address", choices=[]) first_ip = NoValidateSelectField("First IP", choices=[]) last_ip = NoValidateSelectField("Last IP", choices=[]) + gateway = NoValidateSelectField("Gateway IP", choices=[]) domain_id = SelectField("Domain") admin_only = BooleanField("Admin only") diff --git a/app/network/views.py b/app/network/views.py index f69ff4319c49d25f97e3a679f4dd58688a18b6c2..8705dcaf326f2891c9f1413b05fb8efb3602edb9 100644 --- a/app/network/views.py +++ b/app/network/views.py @@ -579,27 +579,8 @@ def retrieve_first_available_ip(network_id): @bp.route("/networks") @login_required def list_networks(): - return render_template("network/networks.html") - - -@bp.route("/_retrieve_networks") -@login_required -def retrieve_networks(): - data = [ - ( - str(network.scope), - network.vlan_name, - network.vlan_id, - network.description, - network.address, - network.first_ip, - network.last_ip, - str(network.domain), - network.admin_only, - ) - for network in models.Network.query.all() - ] - return jsonify(data=data) + networks = models.Network.query.all() + return render_template("network/networks.html", networks=networks) @bp.route("/networks/create", methods=("GET", "POST")) @@ -624,6 +605,7 @@ def create_network(): address=form.address.data, first_ip=form.first_ip.data, last_ip=form.last_ip.data, + gateway=form.gateway.data, domain=models.Domain.query.get(form.domain_id.data), admin_only=form.admin_only.data, ) @@ -696,16 +678,28 @@ def retrieve_ips(subnet, prefix): address = ipaddress.ip_network(f"{subnet}/{prefix}") except ValueError: current_app.logger.warning(f"Invalid address: {subnet}/{prefix}") - data = {"ips": [], "first": "", "last": ""} + data = { + "ips": [], + "selected_first": "", + "selected_last": "", + "selected_gateway": "", + } else: hosts = [str(ip) for ip in address.hosts()] + # The gateway is set to the last IP by default + gateway = hosts[-1] if len(hosts) > 17: first = hosts[10] last = hosts[-6] else: first = hosts[0] - last = hosts[-1] - data = {"ips": hosts, "selected_first": first, "selected_last": last} + last = hosts[-2] + data = { + "ips": hosts, + "selected_first": first, + "selected_last": last, + "selected_gateway": gateway, + } return jsonify(data=data) diff --git a/app/static/js/networks.js b/app/static/js/networks.js index 986801fe64df6a61d286e6f6e81e3268297ca98c..58d1b1cbffeaf8cecd66569452346bcc1e4c44a2 100644 --- a/app/static/js/networks.js +++ b/app/static/js/networks.js @@ -24,12 +24,12 @@ $(document).ready(function() { $SCRIPT_ROOT + "/network/_retrieve_subnets/" + scope_id + "/" + prefix, function(json) { update_selectfield("#address", json.data.subnets, json.data.selected_subnet); - update_first_and_last_ip(); + update_gateway_first_and_last_ip(); } ); } - function update_first_and_last_ip() { + function update_gateway_first_and_last_ip() { // Retrieve IPs for the selected subnet // and update the first and last ip select field var address = $("#address").val(); @@ -38,6 +38,7 @@ $(document).ready(function() { function(json) { update_selectfield("#first_ip", json.data.ips, json.data.selected_first); update_selectfield("#last_ip", json.data.ips.slice().reverse(), json.data.selected_last); + update_selectfield("#gateway", json.data.ips, json.data.selected_gateway); } ); } @@ -59,17 +60,10 @@ $(document).ready(function() { // Update first and last ip select field when changing address $("#address").on('change', function() { - update_first_and_last_ip(); + update_gateway_first_and_last_ip(); }); var networks_table = $("#networks_table").DataTable({ - "ajax": function(data, callback, settings) { - $.getJSON( - $SCRIPT_ROOT + "/network/_retrieve_networks", - function(json) { - callback(json); - }); - }, "paging": false }); diff --git a/app/templates/network/create_network.html b/app/templates/network/create_network.html index 506eb1f88143f19e835f9ff469f717d33b5d5b6b..72cd6daec403f57df7aaf240d81c496f307a07be 100644 --- a/app/templates/network/create_network.html +++ b/app/templates/network/create_network.html @@ -14,6 +14,7 @@ {{ render_field(form.address) }} {{ render_field(form.first_ip) }} {{ render_field(form.last_ip) }} + {{ render_field(form.gateway) }} {{ render_field(form.domain_id) }} {{ render_field(form.admin_only) }} <div class="form-group row"> diff --git a/app/templates/network/networks.html b/app/templates/network/networks.html index 0ab11ff0a1fe869e6b9935ba8c34409ba543fc53..f28dd3ae0f797fb0bd4035e0a556aa2241bf2b70 100644 --- a/app/templates/network/networks.html +++ b/app/templates/network/networks.html @@ -21,17 +21,34 @@ <table id="networks_table" class="table table-bordered table-hover table-sm" cellspacing="0" width="100%"> <thead> <tr> - <th>Network scope</th> <th>Vlan name</th> <th>Vlan id</th> <th>Description</th> <th>Address</th> <th>First IP</th> <th>Last IP</th> + <th>Gateway</th> + <th>Network scope</th> <th>Domain</th> <th>Admin only</th> </tr> </thead> + <tbody> + {% for network in networks %} + <tr> + <td>{{ network.vlan_name }}</td> + <td>{{ network.vlan_id }}</td> + <td>{{ network.description }}</td> + <td>{{ network.address }}</td> + <td>{{ network.first_ip }}</td> + <td>{{ network.last_ip }}</td> + <td>{{ network.gateway }}</td> + <td>{{ network.scope }}</td> + <td>{{ network.domain }}</td> + <td>{{ network.admin_only }}</td> + </tr> + {% endfor %} + </tbody> </table> {%- endblock %} {%- endblock %} diff --git a/migrations/versions/f7d72e432f51_add_gateway_field.py b/migrations/versions/f7d72e432f51_add_gateway_field.py new file mode 100644 index 0000000000000000000000000000000000000000..0f87d594d83c6a9f8f64e8a64daea11139976367 --- /dev/null +++ b/migrations/versions/f7d72e432f51_add_gateway_field.py @@ -0,0 +1,50 @@ +"""Add gateway field + +Revision ID: f7d72e432f51 +Revises: 7c38e78b6de6 +Create Date: 2019-02-27 17:35:22.535126 + +""" +import ipaddress +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "f7d72e432f51" +down_revision = "7c38e78b6de6" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("network", sa.Column("gateway", postgresql.INET(), nullable=True)) + network = sa.sql.table( + "network", + sa.sql.column("id"), + sa.sql.column("address"), + sa.sql.column("gateway"), + ) + # Fill the gateway based on the network address + conn = op.get_bind() + res = conn.execute("SELECT id, address FROM network") + results = res.fetchall() + for result in results: + address = ipaddress.ip_network(result[1]) + hosts = list(address.hosts()) + # Use last IP by default + gateway = str(hosts[-1]) + op.execute( + network.update().where(network.c.id == result[0]).values(gateway=gateway) + ) + op.create_check_constraint( + op.f("ck_network_gateway_in_network"), "network", "gateway << address" + ) + op.create_unique_constraint(op.f("uq_network_gateway"), "network", ["gateway"]) + op.alter_column("network", "gateway", nullable=False) + + +def downgrade(): + op.drop_constraint(op.f("uq_network_gateway"), "network", type_="unique") + op.drop_constraint(op.f("ck_network_gateway_in_network"), "network", type_="check") + op.drop_column("network", "gateway") diff --git a/tests/functional/factories.py b/tests/functional/factories.py index cd1fdc1806b99c1d55fa7a46658a36281021130f..72fa05b748b5ed4102da790cef1312422b007b0b 100644 --- a/tests/functional/factories.py +++ b/tests/functional/factories.py @@ -144,6 +144,12 @@ class NetworkFactory(factory.alchemy.SQLAlchemyModelFactory): hosts = list(net.hosts()) return str(hosts[-5]) + @factory.lazy_attribute + def gateway(self): + net = ipaddress.ip_network(self.address) + hosts = list(net.hosts()) + return str(hosts[-1]) + class DeviceTypeFactory(factory.alchemy.SQLAlchemyModelFactory): class Meta: diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py index 1c2960714f106f8f68b706ee1b097c961427322a..294820165e8880b4f55fcad54a22d5f480dbb450 100644 --- a/tests/functional/test_api.py +++ b/tests/functional/test_api.py @@ -649,7 +649,7 @@ def test_create_network_auth_fail(client, session, user_token): def test_create_network(client, admin_token, network_scope_factory): scope = network_scope_factory(supernet="172.16.0.0/16") - # check that vlan_name, vlan_id, address, first_ip, last_ip and scope are mandatory + # check that vlan_name, vlan_id, address, first_ip, last_ip, gateway and scope are mandatory response = post(client, f"{API_URL}/network/networks", data={}, token=admin_token) check_response_message(response, "Missing mandatory field 'vlan_name'", 422) response = post( @@ -692,6 +692,20 @@ def test_create_network(client, admin_token, network_scope_factory): token=admin_token, ) check_response_message(response, "Missing mandatory field 'last_ip'", 422) + response = post( + client, + f"{API_URL}/network/networks", + data={ + "vlan_name": "network1", + "vlan_id": 1600, + "address": "172.16.1.0/24", + "first_ip": "172.16.1.10", + "last_ip": "172.16.1.250", + "scope": scope.name, + }, + token=admin_token, + ) + check_response_message(response, "Missing mandatory field 'gateway'", 422) data = { "vlan_name": "network1", @@ -699,6 +713,7 @@ def test_create_network(client, admin_token, network_scope_factory): "address": "172.16.1.0/24", "first_ip": "172.16.1.10", "last_ip": "172.16.1.250", + "gateway": "172.16.1.254", "scope": scope.name, } response = post(client, f"{API_URL}/network/networks", data=data, token=admin_token) @@ -726,6 +741,7 @@ def test_create_network(client, admin_token, network_scope_factory): assert response.get_json()["address"] == "172.16.1.0/24" assert response.get_json()["first_ip"] == "172.16.1.10" assert response.get_json()["last_ip"] == "172.16.1.250" + assert response.get_json()["gateway"] == "172.16.1.254" assert response.get_json()["netmask"] == "255.255.255.0" # Check that address and name shall be unique @@ -751,6 +767,7 @@ def test_create_network(client, admin_token, network_scope_factory): "address": "172.16.2.0/24", "first_ip": "172.16.2.10", "last_ip": "172.16.2.250", + "gateway": "172.16.2.254", "scope": scope.name, } response = post( @@ -769,6 +786,7 @@ def test_create_network(client, admin_token, network_scope_factory): "address": "172.16.5.0/24", "first_ip": "172.16.5.11", "last_ip": "172.16.5.250", + "gateway": "172.16.5.254", "description": "long description", "scope": scope.name, } @@ -790,6 +808,7 @@ def test_create_network_invalid_address(client, admin_token, network_scope): "address": "foo", "first_ip": "172.16.1.10", "last_ip": "172.16.1.250", + "gateway": "172.16.1.254", "scope": network_scope.name, } response = post(client, f"{API_URL}/network/networks", data=data, token=admin_token) @@ -818,6 +837,7 @@ def test_create_network_invalid_ip( "address": "192.168.0.0/24", "first_ip": address, "last_ip": "192.168.0.250", + "gateway": "192.168.0.254", "scope": network_scope.name, } response = post(client, f"{API_URL}/network/networks", data=data, token=admin_token) @@ -831,6 +851,7 @@ def test_create_network_invalid_ip( "address": "192.168.0.0/24", "first_ip": "192.168.0.250", "last_ip": address, + "gateway": "192.168.0.254", "scope": network_scope.name, } response = post(client, f"{API_URL}/network/networks", data=data, token=admin_token) @@ -847,6 +868,7 @@ def test_create_network_invalid_range(client, session, admin_token, network_scop "address": "172.16.1.0/24", "first_ip": "172.16.2.10", "last_ip": "172.16.1.250", + "gateway": "172.16.1.254", "scope": network_scope.name, } response = post(client, f"{API_URL}/network/networks", data=data, token=admin_token) @@ -860,6 +882,7 @@ def test_create_network_invalid_range(client, session, admin_token, network_scop "address": "172.16.1.0/24", "first_ip": "172.16.1.10", "last_ip": "172.16.5.250", + "gateway": "172.16.1.1", "scope": network_scope.name, } response = post(client, f"{API_URL}/network/networks", data=data, token=admin_token) @@ -873,6 +896,7 @@ def test_create_network_invalid_range(client, session, admin_token, network_scop "address": "172.16.1.0/24", "first_ip": "172.16.1.10", "last_ip": "172.16.1.9", + "gateway": "172.16.1.1", "scope": network_scope.name, } response = post(client, f"{API_URL}/network/networks", data=data, token=admin_token) diff --git a/tests/functional/test_models.py b/tests/functional/test_models.py index a35d2633877fffaaa9f84134d19abbfc2c7e14c6..18b55dd5ee89d76b63b40bcc1773451f0def405f 100644 --- a/tests/functional/test_models.py +++ b/tests/functional/test_models.py @@ -152,9 +152,9 @@ def test_network_available_and_used_ips(network_factory, interface_factory): def test_network_gateway(network_factory): - network = network_factory(address="192.168.0.0/24") - assert str(network.gateway) == "192.168.0.254" - network = network_factory(address="172.16.110.0/23") + network = network_factory(address="192.168.0.0/24", gateway="192.168.0.1") + assert str(network.gateway) == "192.168.0.1" + network = network_factory(address="172.16.110.0/23", gateway="172.16.111.254") assert str(network.gateway) == "172.16.111.254"