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