From 4067757720727b579717e5c29445a8575642553e Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand <benjamin.bertrand@esss.se> Date: Sat, 16 Dec 2017 00:18:05 +0100 Subject: [PATCH] Implement UI to register networks --- app/models.py | 57 +++++++++++- app/network/forms.py | 22 ++++- app/network/views.py | 104 +++++++++++++++++++++- app/static/js/networks.js | 83 +++++++++++++++++ app/templates/base-fluid.html | 2 + app/templates/network/create_network.html | 39 ++++++++ app/templates/network/networks.html | 35 ++++++++ 7 files changed, 337 insertions(+), 5 deletions(-) create mode 100644 app/static/js/networks.js create mode 100644 app/templates/network/create_network.html create mode 100644 app/templates/network/networks.html diff --git a/app/models.py b/app/models.py index 258b025..26be8db 100644 --- a/app/models.py +++ b/app/models.py @@ -28,6 +28,7 @@ from . import utils ICS_ID_RE = re.compile('[A-Z]{3}[0-9]{3}') HOST_NAME_RE = re.compile('^[a-z0-9\-]{2,20}$') +VLAN_NAME_RE = re.compile('^[A-Za-z0-9\-]{3,25}$') make_versioned(plugins=[FlaskUserPlugin()]) @@ -415,6 +416,15 @@ class Network(db.Model): raise ValidationError(f'IP address {interface.ip} is not in range {self.first} - {self.last}') return interface + @validates('vlan_name') + def validate_vlan_name(self, key, string): + """Ensure the name matches the required format""" + if string is None: + return None + if VLAN_NAME_RE.fullmatch(string) is None: + raise ValidationError('Vlan name shall match [A-Za-z0-9\-]{3,25}') + return string + def to_dict(self): return { 'id': self.id, @@ -570,20 +580,63 @@ class NetworkScope(db.Model): name = db.Column(CIText, nullable=False, unique=True) first_vlan = db.Column(db.Integer, nullable=False, unique=True) last_vlan = db.Column(db.Integer, nullable=False, unique=True) - subnet = db.Column(postgresql.CIDR, nullable=False, unique=True) + supernet = db.Column(postgresql.CIDR, nullable=False, unique=True) networks = db.relationship('Network', backref='scope') + __table_args__ = ( + sa.CheckConstraint('first_vlan < last_vlan', name='first_vlan_less_than_last_vlan'), + ) + def __str__(self): return str(self.name) + @property + def supernet_ip(self): + return ipaddress.ip_network(self.supernet) + + def prefix_range(self): + """Return the list of subnet prefix that can be used for this network scope""" + return list(range(self.supernet_ip.prefixlen + 1, 31)) + + def vlan_range(self): + """Return the list of vlan ids that can be assigned for this network scope + + The range is defined by the first and last vlan + """ + return range(self.first_vlan, self.last_vlan + 1) + + def used_vlans(self): + """Return the list of vlan ids in use + + The list is sorted + """ + return sorted(network.vlan_id for network in self.networks) + + def available_vlans(self): + """Return the list of vlan ids available""" + return [vlan for vlan in self.vlan_range() + if vlan not in self.used_vlans()] + + def used_subnets(self): + """Return the list of subnets in use + + The list is sorted + """ + return sorted(network.network_ip for network in self.networks) + + def available_subnets(self, prefix): + """Return the list of available subnets with the given prefix""" + return [str(subnet) for subnet in self.supernet_ip.subnets(new_prefix=prefix) + if subnet not in self.used_subnets()] + def to_dict(self): return { 'id': self.id, 'name': self.name, 'first_vlan': self.first_vlan, 'last_vlan': self.last_vlan, - 'subnet': self.subnet, + 'supernet': self.supernet, } diff --git a/app/network/forms.py b/app/network/forms.py index a8f2512..9f6a833 100644 --- a/app/network/forms.py +++ b/app/network/forms.py @@ -10,7 +10,8 @@ This module defines the network blueprint forms. """ from flask_wtf import FlaskForm -from wtforms import SelectField, StringField, TextAreaField, validators +from wtforms import (SelectField, StringField, TextAreaField, + IntegerField, BooleanField, validators) from .. import utils, models @@ -26,6 +27,25 @@ class NoValidateSelectField(SelectField): pass +class NetworkForm(FlaskForm): + scope_id = SelectField('Network Scope') + vlan_name = StringField('Vlan name', + description='hostname must be 3-25 characters long and contain only letters, numbers and dash', + validators=[validators.InputRequired(), + validators.Regexp(models.VLAN_NAME_RE)]) + vlan_id = NoValidateSelectField('Vlan id', choices=[]) + description = TextAreaField('Description') + prefix = NoValidateSelectField('Prefix', choices=[]) + address = NoValidateSelectField('Address', choices=[]) + first_ip = NoValidateSelectField('First IP', choices=[]) + last_ip = NoValidateSelectField('Last IP', choices=[]) + admin_only = BooleanField('Admin only') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.scope_id.choices = utils.get_model_choices(models.NetworkScope, attr='name') + + class HostForm(FlaskForm): name = StringField('Hostname', description='hostname 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 8254589..96c4711 100644 --- a/app/network/views.py +++ b/app/network/views.py @@ -9,11 +9,12 @@ This module implements the network blueprint. :license: BSD 2-Clause, see LICENSE for more details. """ +import ipaddress import sqlalchemy as sa from flask import (Blueprint, render_template, jsonify, session, redirect, url_for, request, flash, current_app) from flask_login import login_required -from .forms import HostForm +from .forms import HostForm, NetworkForm from ..extensions import db from ..decorators import login_groups_accepted from .. import models @@ -81,7 +82,7 @@ def retrieve_hosts(): return jsonify(data=data) -@bp.route('/_retrieve_available_ips/<network_id>') +@bp.route('/_retrieve_available_ips/<int:network_id>') @login_required def retrieve_available_ips(network_id): try: @@ -92,3 +93,102 @@ def retrieve_available_ips(network_id): else: data = [str(address) for address in network.available_ips()] return jsonify(data=data) + + +@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, + network.admin_only) + for network in models.Network.query.all()] + return jsonify(data=data) + + +@bp.route('/networks/create', methods=('GET', 'POST')) +@login_groups_accepted('admin', 'create') +def create_network(): + # Try to get the scope_id from the session + # to pre-fill the form with the same network scope + try: + scope_id = session['scope_id'] + except KeyError: + # No need to pass request.form when no extra keywords are given + form = NetworkForm() + else: + form = NetworkForm(request.form, scope_id=scope_id) + if form.validate_on_submit(): + scope_id = form.scope_id.data + network = models.Network(scope_id=scope_id, + vlan_name=form.vlan_name.data, + vlan_id=form.vlan_id.data, + description=form.description.data or None, + address=form.address.data, + first_ip=form.first_ip.data, + last_ip=form.last_ip.data, + admin_only=form.admin_only.data) + current_app.logger.debug(f'Trying to create: {network!r}') + db.session.add(network) + try: + db.session.commit() + except sa.exc.IntegrityError as e: + db.session.rollback() + current_app.logger.warning(f'{e}') + flash(f'{e}', 'error') + else: + flash(f'Network {network} created!', 'success') + # Save scope_id to the session to retrieve it after the redirect + session['scope_id'] = scope_id + return redirect(url_for('network.create_network')) + return render_template('network/create_network.html', form=form) + + +@bp.route('/_retrieve_vlan_and_prefix/<int:scope_id>') +@login_required +def retrieve_vlan_and_prefix(scope_id): + try: + scope = models.NetworkScope.query.get(scope_id) + except sa.exc.DataError: + current_app.logger.warning(f'Invalid scope_id: {scope_id}') + data = {'vlans': [], 'prefixes': []} + else: + data = {'vlans': [vlan_id for vlan_id in scope.available_vlans()], + 'prefixes': scope.prefix_range()} + return jsonify(data=data) + + +@bp.route('/_retrieve_subnets/<int:scope_id>/<int:prefix>') +@login_required +def retrieve_subnets(scope_id, prefix): + try: + scope = models.NetworkScope.query.get(scope_id) + except sa.exc.DataError: + current_app.logger.warning(f'Invalid scope_id: {scope_id}') + data = [] + else: + data = [subnet for subnet in scope.available_subnets(int(prefix))] + return jsonify(data=data) + + +@bp.route('/_retrieve_ips/<subnet>/<int:prefix>') +@login_required +def retrieve_ips(subnet, prefix): + try: + address = ipaddress.ip_network(f'{subnet}/{prefix}') + except ValueError: + current_app.logger.warning(f'Invalid address: {subnet}/{prefix}') + data = [] + else: + data = [str(ip) for ip in address.hosts()] + return jsonify(data=data) diff --git a/app/static/js/networks.js b/app/static/js/networks.js new file mode 100644 index 0000000..10a2d78 --- /dev/null +++ b/app/static/js/networks.js @@ -0,0 +1,83 @@ +$(document).ready(function() { + + function update_selectfield(field_id, data) { + var $field = $(field_id); + $field.empty(); + $.map(data, function(option, index) { + $field.append($("<option></option>").attr("value", option).text(option)); + }); + } + + function update_vlan_and_prefix() { + // Retrieve available vlans and subnet prefixes for the selected network scope + // and update the vlan_id and prefix select field + var scope_id = $("#scope_id").val(); + $.getJSON( + $SCRIPT_ROOT + "/network/_retrieve_vlan_and_prefix/" + scope_id, + function(json) { + update_selectfield("#vlan_id", json.data.vlans); + update_selectfield("#prefix", json.data.prefixes); + update_address(); + } + ); + } + + function update_address() { + // Retrieve available subnets for the selected network scope and prefix + // and update the address select field + var scope_id = $("#scope_id").val(); + var prefix = $("#prefix").val(); + $.getJSON( + $SCRIPT_ROOT + "/network/_retrieve_subnets/" + scope_id + "/" + prefix, + function(json) { + update_selectfield("#address", json.data); + update_first_and_last_ip(); + } + ); + } + + function update_first_and_last_ip() { + // Retrieve IPs for the selected subnet + // and update the first and last ip select field + var address = $("#address").val(); + $.getJSON( + $SCRIPT_ROOT + "/network/_retrieve_ips/" + address, + function(json) { + update_selectfield("#first_ip", json.data); + update_selectfield("#last_ip", json.data.slice().reverse()); + } + ); + } + + // Populate vlan_id and prefix select field on first page load + if( $("#scope_id").length ) { + update_vlan_and_prefix(); + } + + // Update vlan_id and prefix select field when changing network scope + $("#scope_id").on('change', function() { + update_vlan_and_prefix(); + }); + + // Update address select field when changing prefix + $("#prefix").on('change', function() { + update_address(); + }); + + // Update first and last ip select field when changing address + $("#address").on('change', function() { + update_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/base-fluid.html b/app/templates/base-fluid.html index 41851c2..0835929 100644 --- a/app/templates/base-fluid.html +++ b/app/templates/base-fluid.html @@ -19,6 +19,8 @@ {% elif path.startswith("/network") %} <a class="list-group-item list-group-item-action {{ is_active(path.startswith("/network/hosts")) }}" href="{{ url_for('network.list_hosts') }}">Hosts</a> + <a class="list-group-item list-group-item-action {{ is_active(path.startswith("/network/networks")) }}" + href="{{ url_for('network.list_networks') }}">Networks</a> {% endif %} </div> </div> diff --git a/app/templates/network/create_network.html b/app/templates/network/create_network.html new file mode 100644 index 0000000..deb4394 --- /dev/null +++ b/app/templates/network/create_network.html @@ -0,0 +1,39 @@ +{% extends "base-fluid.html" %} +{% from "_helpers.html" import render_field %} + +{% block title %}Networks - CSEntry{% endblock %} + +{% block main %} + <ul class="nav nav-tabs"> + <li class="nav-item"> + <a class="nav-link" href="{{ url_for('network.list_networks') }}">List networks</a> + </li> + <li class="nav-item"> + <a class="nav-link active" href="{{ url_for('network.create_network') }}">Register new network</a> + </li> + </ul> + + <br> + + <form id="networkForm" method="POST"> + {{ form.hidden_tag() }} + {{ render_field(form.scope_id) }} + {{ render_field(form.vlan_name) }} + {{ render_field(form.vlan_id) }} + {{ render_field(form.description) }} + {{ render_field(form.prefix) }} + {{ render_field(form.address) }} + {{ render_field(form.first_ip) }} + {{ render_field(form.last_ip) }} + {{ render_field(form.admin_only) }} + <div class="form-group row"> + <div class="col-sm-10"> + <button type="submit" class="btn btn-primary">Submit</button> + </div> + </div> + </form> +{%- endblock %} + +{% block csentry_scripts %} + <script src="{{ url_for('static', filename='js/networks.js') }}"></script> +{% endblock %} diff --git a/app/templates/network/networks.html b/app/templates/network/networks.html new file mode 100644 index 0000000..d4c98c1 --- /dev/null +++ b/app/templates/network/networks.html @@ -0,0 +1,35 @@ +{% extends "base-fluid.html" %} + +{% block title %}Networks - CSEntry{% endblock %} + +{% block main %} + <ul class="nav nav-tabs"> + <li class="nav-item"> + <a class="nav-link active" href="{{ url_for('network.list_networks') }}">List networks</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="{{ url_for('network.create_network') }}">Register new network</a> + </li> + </ul> + + <br> + + <table id="networks_table" class="table table-bordered table-hover table-sm"> + <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>Admin only</th> + </tr> + </thead> + </table> +{%- endblock %} + +{% block csentry_scripts %} + <script src="{{ url_for('static', filename='js/networks.js') }}"></script> +{% endblock %} -- GitLab