From d8fdf80d0d5613c4a1ddaf7bf12ac8548793a710 Mon Sep 17 00:00:00 2001
From: Benjamin Bertrand <benjamin.bertrand@esss.se>
Date: Fri, 9 Feb 2018 23:33:27 +0100
Subject: [PATCH] Generate random MAC for Virtual hosts

Fixes INFRA-197
---
 app/network/forms.py                        |  1 +
 app/network/views.py                        | 20 +++++++++++---
 app/settings.py                             |  4 +++
 app/static/js/hosts.js                      | 30 +++++++++++++++++++++
 app/templates/network/create_host.html      |  1 +
 app/templates/network/create_interface.html |  1 +
 app/utils.py                                | 11 ++++++++
 tests/functional/test_web.py                |  8 ++++++
 8 files changed, 72 insertions(+), 4 deletions(-)

diff --git a/app/network/forms.py b/app/network/forms.py
index b82c482..755626a 100644
--- a/app/network/forms.py
+++ b/app/network/forms.py
@@ -133,6 +133,7 @@ class InterfaceForm(CSEntryForm):
                     Unique(models.Interface),
                     starts_with_hostname],
         filters=[utils.lowercase_field])
+    random_mac = BooleanField('Random MAC', default=False)
     mac_address = StringField(
         'MAC',
         validators=[validators.Optional(),
diff --git a/app/network/views.py b/app/network/views.py
index 3f1fe56..eefcadc 100644
--- a/app/network/views.py
+++ b/app/network/views.py
@@ -32,15 +32,16 @@ def list_hosts():
 @bp.route('/hosts/create', methods=('GET', 'POST'))
 @login_groups_accepted('admin', 'create')
 def create_host():
+    kwargs = {'random_mac': True}
     # Try to get the network_id from the session
     # to pre-fill the form with the same network
     try:
         network_id = session['network_id']
     except KeyError:
-        # No need to pass request.form when no extra keywords are given
-        form = HostInterfaceForm()
+        pass
     else:
-        form = HostInterfaceForm(request.form, network_id=network_id)
+        kwargs['network_id'] = network_id
+    form = HostInterfaceForm(request.form, **kwargs)
     # Remove the host_id field inherited from the InterfaceForm
     # It's not used in this form
     del form.host_id
@@ -112,7 +113,9 @@ def edit_host(name):
 @login_groups_accepted('admin', 'create')
 def create_interface(hostname):
     host = models.Host.query.filter_by(name=hostname).first_or_404()
-    form = InterfaceForm(request.form, host_id=host.id, interface_name=host.name)
+    random_mac = host.type == 'Virtual'
+    form = InterfaceForm(request.form, host_id=host.id, interface_name=host.name,
+                         random_mac=random_mac)
     if form.validate_on_submit():
         # The total number of tags will always be quite small
         # It's more efficient to retrieve all of them in one query
@@ -153,6 +156,8 @@ def edit_interface(name):
                          interface_name=interface.name,
                          mac_address=mac_address,
                          cnames_string=cnames_string)
+    # Remove the random_mac field (not used when editing)
+    del form.random_mac
     ips = [interface.ip]
     ips.extend([str(address) for address in interface.network.available_ips()])
     form.ip.choices = utils.get_choices(ips)
@@ -441,3 +446,10 @@ def retrieve_domains():
     data = [(domain.name,)
             for domain in models.Domain.query.all()]
     return jsonify(data=data)
+
+
+@bp.route('/_generate_random_mac')
+@login_required
+def generate_random_mac():
+    data = {'mac': utils.random_mac()}
+    return jsonify(data=data)
diff --git a/app/settings.py b/app/settings.py
index 2812fae..9ec9c7d 100644
--- a/app/settings.py
+++ b/app/settings.py
@@ -58,3 +58,7 @@ NETWORK_DEFAULT_PREFIX = 24
 # (waiting for a real label to be assigned)
 # WARNING: This is defined here as a global settings but should not be changed!
 TEMPORARY_ICS_ID = 'ZZ'
+
+# CSENTRY MAC organizationally unique identifier
+# This is a locally administered address
+MAC_OUI = '02:42:42'
diff --git a/app/static/js/hosts.js b/app/static/js/hosts.js
index 4c4b976..6a4ec41 100644
--- a/app/static/js/hosts.js
+++ b/app/static/js/hosts.js
@@ -16,6 +16,23 @@ $(document).ready(function() {
     );
   }
 
+  // If random_mac is checked, generate a random address
+  // Empty the field otherwise
+  function fill_mac_address() {
+    if( $("#random_mac").prop("checked") ) {
+      $.getJSON(
+        $SCRIPT_ROOT + "/network/_generate_random_mac",
+        function(json) {
+          $("#mac_address").val(json.data.mac);
+          $("#mac_address").prop("readonly", true);
+        }
+      );
+    } else {
+      $("#mac_address").val("");
+      $("#mac_address").prop("readonly", false);
+    }
+  }
+
   // Populate IP select field on first page load for:
   // - register new host
   // - add interface
@@ -32,13 +49,16 @@ $(document).ready(function() {
 
   // Enable / disable item_id field depending on type
   // Item can only be assigned for physical hosts
+  // And check / uncheck random_mac checkbox
   $("#type").on('change', function() {
     var host_type = $(this).val();
     if( host_type == "Physical" ) {
       $("#item_id").prop("disabled", false);
+      $("#random_mac").prop("checked", false).change();
     } else {
       $("#item_id").val("");
       $("#item_id").prop("disabled", true);
+      $("#random_mac").prop("checked", true).change();
     }
   });
 
@@ -48,6 +68,16 @@ $(document).ready(function() {
     $("#interface_name").val(hostname);
   });
 
+  // Fill MAC address on page load
+  if( $("#random_mac").length ) {
+    fill_mac_address();
+  }
+
+  // Fill or clear MAC address depending on random_mac checkbox
+  $("#random_mac").on('change', function() {
+    fill_mac_address();
+  });
+
   // Force the first interface to have the hostname as name
   // This only applies to the hostForm (not when editing or adding interfaces)
   $("#hostForm input[name=interface_name]").prop("readonly", true);
diff --git a/app/templates/network/create_host.html b/app/templates/network/create_host.html
index 775a29b..1a2f35f 100644
--- a/app/templates/network/create_host.html
+++ b/app/templates/network/create_host.html
@@ -13,6 +13,7 @@
     {{ render_field(form.network_id) }}
     {{ render_field(form.ip) }}
     {{ render_field(form.interface_name, class_="text-lowercase") }}
+    {{ render_field(form.random_mac) }}
     {{ render_field(form.mac_address) }}
     {{ render_field(form.cnames_string) }}
     {{ render_field(form.tags) }}
diff --git a/app/templates/network/create_interface.html b/app/templates/network/create_interface.html
index 2da2cdb..85ed4ba 100644
--- a/app/templates/network/create_interface.html
+++ b/app/templates/network/create_interface.html
@@ -23,6 +23,7 @@
     {{ render_field(form.interface_name, class_="text-lowercase") }}
     {{ render_field(form.network_id) }}
     {{ render_field(form.ip) }}
+    {{ render_field(form.random_mac) }}
     {{ render_field(form.mac_address) }}
     {{ render_field(form.cnames_string) }}
     {{ render_field(form.tags) }}
diff --git a/app/utils.py b/app/utils.py
index ee68efe..5c84920 100644
--- a/app/utils.py
+++ b/app/utils.py
@@ -12,8 +12,10 @@ This module implements utility functions.
 import base64
 import datetime
 import io
+import random
 import sqlalchemy as sa
 import dateutil.parser
+from flask import current_app
 from flask.globals import _app_ctx_stack, _request_ctx_stack
 from flask_login import current_user
 from flask_jwt_extended import get_current_user
@@ -179,3 +181,12 @@ def parse_to_utc(string):
     # Convert to UTC and remove timezone
     d = d.astimezone(datetime.timezone.utc)
     return d.replace(tzinfo=None)
+
+
+def random_mac():
+    """Return a random MAC address"""
+    octets = [random.randint(0x00, 0xFF),
+              random.randint(0x00, 0xFF),
+              random.randint(0x00, 0xFF)]
+    octets = [f'{nb:02x}' for nb in octets]
+    return ':'.join((current_app.config['MAC_OUI'], *octets))
diff --git a/tests/functional/test_web.py b/tests/functional/test_web.py
index 644b41d..6da1cb8 100644
--- a/tests/functional/test_web.py
+++ b/tests/functional/test_web.py
@@ -11,6 +11,7 @@ This module defines basic web tests.
 """
 import json
 import pytest
+import re
 
 
 def get(client, url):
@@ -81,3 +82,10 @@ def test_retrieve_items(logged_client, item_factory):
     items = response.json['data']
     assert set(serial_numbers) == set(item[4] for item in items)
     assert len(items[0]) == 11
+
+
+def test_generate_random_mac(logged_client):
+    response = get(logged_client, '/network/_generate_random_mac')
+    mac = response.json['data']['mac']
+    assert re.match('^(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$', mac) is not None
+    assert mac.startswith('02:42:42')
-- 
GitLab