Skip to content
Snippets Groups Projects
Commit 40677577 authored by Benjamin Bertrand's avatar Benjamin Bertrand
Browse files

Implement UI to register networks

parent 5a1b19c6
No related branches found
No related tags found
No related merge requests found
...@@ -28,6 +28,7 @@ from . import utils ...@@ -28,6 +28,7 @@ from . import utils
ICS_ID_RE = re.compile('[A-Z]{3}[0-9]{3}') ICS_ID_RE = re.compile('[A-Z]{3}[0-9]{3}')
HOST_NAME_RE = re.compile('^[a-z0-9\-]{2,20}$') 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()]) make_versioned(plugins=[FlaskUserPlugin()])
...@@ -415,6 +416,15 @@ class Network(db.Model): ...@@ -415,6 +416,15 @@ class Network(db.Model):
raise ValidationError(f'IP address {interface.ip} is not in range {self.first} - {self.last}') raise ValidationError(f'IP address {interface.ip} is not in range {self.first} - {self.last}')
return interface 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): def to_dict(self):
return { return {
'id': self.id, 'id': self.id,
...@@ -570,20 +580,63 @@ class NetworkScope(db.Model): ...@@ -570,20 +580,63 @@ class NetworkScope(db.Model):
name = db.Column(CIText, nullable=False, unique=True) name = db.Column(CIText, nullable=False, unique=True)
first_vlan = db.Column(db.Integer, nullable=False, unique=True) first_vlan = db.Column(db.Integer, nullable=False, unique=True)
last_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') networks = db.relationship('Network', backref='scope')
__table_args__ = (
sa.CheckConstraint('first_vlan < last_vlan', name='first_vlan_less_than_last_vlan'),
)
def __str__(self): def __str__(self):
return str(self.name) 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): def to_dict(self):
return { return {
'id': self.id, 'id': self.id,
'name': self.name, 'name': self.name,
'first_vlan': self.first_vlan, 'first_vlan': self.first_vlan,
'last_vlan': self.last_vlan, 'last_vlan': self.last_vlan,
'subnet': self.subnet, 'supernet': self.supernet,
} }
......
...@@ -10,7 +10,8 @@ This module defines the network blueprint forms. ...@@ -10,7 +10,8 @@ This module defines the network blueprint forms.
""" """
from flask_wtf import FlaskForm 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 from .. import utils, models
...@@ -26,6 +27,25 @@ class NoValidateSelectField(SelectField): ...@@ -26,6 +27,25 @@ class NoValidateSelectField(SelectField):
pass 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): class HostForm(FlaskForm):
name = StringField('Hostname', name = StringField('Hostname',
description='hostname must be 2-20 characters long and contain only letters, numbers and dash', description='hostname must be 2-20 characters long and contain only letters, numbers and dash',
......
...@@ -9,11 +9,12 @@ This module implements the network blueprint. ...@@ -9,11 +9,12 @@ This module implements the network blueprint.
:license: BSD 2-Clause, see LICENSE for more details. :license: BSD 2-Clause, see LICENSE for more details.
""" """
import ipaddress
import sqlalchemy as sa import sqlalchemy as sa
from flask import (Blueprint, render_template, jsonify, session, from flask import (Blueprint, render_template, jsonify, session,
redirect, url_for, request, flash, current_app) redirect, url_for, request, flash, current_app)
from flask_login import login_required from flask_login import login_required
from .forms import HostForm from .forms import HostForm, NetworkForm
from ..extensions import db from ..extensions import db
from ..decorators import login_groups_accepted from ..decorators import login_groups_accepted
from .. import models from .. import models
...@@ -81,7 +82,7 @@ def retrieve_hosts(): ...@@ -81,7 +82,7 @@ def retrieve_hosts():
return jsonify(data=data) return jsonify(data=data)
@bp.route('/_retrieve_available_ips/<network_id>') @bp.route('/_retrieve_available_ips/<int:network_id>')
@login_required @login_required
def retrieve_available_ips(network_id): def retrieve_available_ips(network_id):
try: try:
...@@ -92,3 +93,102 @@ def retrieve_available_ips(network_id): ...@@ -92,3 +93,102 @@ def retrieve_available_ips(network_id):
else: else:
data = [str(address) for address in network.available_ips()] data = [str(address) for address in network.available_ips()]
return jsonify(data=data) 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)
$(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
});
});
...@@ -19,6 +19,8 @@ ...@@ -19,6 +19,8 @@
{% elif path.startswith("/network") %} {% elif path.startswith("/network") %}
<a class="list-group-item list-group-item-action {{ is_active(path.startswith("/network/hosts")) }}" <a class="list-group-item list-group-item-action {{ is_active(path.startswith("/network/hosts")) }}"
href="{{ url_for('network.list_hosts') }}">Hosts</a> 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 %} {% endif %}
</div> </div>
</div> </div>
......
{% 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 %}
{% 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 %}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment