Estructuras de Datos para Network Engineers 101: Hash Tables (Parte II)
Tags: hash, python, netmiko, networkautomation
Published: 2021-05-21 13:20:12

## Introducción
Según lo que vimos en la [primera parte](https://dev.to/jorgmassih/estructuras-de-datos-para-network-engineers-101-hash-tables-parte-i-5go1), deberíamos de ser hábiles para entender una Tabla de Hash, en la cual se basará la aplicación que desarrollaremos en este post.

Recuerda que queremos crear una aplicación que nos automatice la asociación de alguna interfaz de nuestro switch con una vlan especifica. A su vez, la vlan estará relacionada con una MAC-Address.

Normalmente en una oficina se tiene el siguiente escenario:
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bmjaubyvjhvvu9pqt6pe.png)

Los dispositivos que utilizamos en el día a día se conectan a un Switch de Acceso, en dicho equipos asociamos Vlans especificas con interfaces a las que están conectadas los dispositivos.

La idea de esta aplicación, es que el proceso de asignación a una Vlan cada vez que conectamos un dispositivo a una interfaz, se haga de manera automática. De igual forma cuando dicho dispositivo sea desconectado de la interfaz correspondiente.

## Alcance
Hay que tomar en cuenta que esta aplicación será desarrollada de la manera más básica posible, ya que sólo es una demostración de aplicación de una Estructura de Dato, por lo que hay ciertos features que no podrán ser cubiertos. Dicho esto, nuestro alcance será el siguiente:
- Cada interfaz sólo podrá tener una sola MAC-Address.
- La aplicación asumirá que la IP del dispositivo ya fue dada, por lo que no contempla el uso de DHCP, NTP, ni otro tipo de servicio.
- Sólo se abarcarán equipos Cisco y con el _cisco ios_ como sistema operativo.

## Módulos a utilizar
Esta aplicación se soportará de dos módulos esenciales:
- [Netmiko](https://pypi.org/project/netmiko/): Simplifica las conexiones a equipos de Red. Estaremos utilizando este módulo para conectarnos por SSH a los equipos para automatizar tareas y extraer información de sus configuraciones.

- [Text Template Parser (TTP)](https://ttp.readthedocs.io/en/latest/Overview.html): Este es un módulo que permite parsear de manera rápida data semi-estructurada mediante plantillas o templates. Estaremos utilizando este módulo para sacar la información útil de la data extraída de los equipos. Por ejemplo, si corremos el comando `show ip interface brief`, este nos devolverá una tabla, para poder extraer los valores útiles de esa tabla (IPs asignadas, estado de la interfaz, entre otras cosas) podemos auxiliarnos de [Regular Expressions](https://es.wikipedia.org/wiki/Expresi%C3%B3n_regular) o RegEx; Este módulo facilita la extracción de valores ya que crea el RegEx a partir de una plantilla en donde se le muestra como pueden venir los datos.

> _**Nota**: Este no es un tutorial acerca de estos módulos, por lo que si querrás saber más sobre como usar Netmiko, te refiero a su [documentación para empezar a usaro](https://pynet.twb-tech.com/blog/automation/netmiko.html), de igual manera con TTP, debes ir a la [documentación](https://ttp.readthedocs.io/en/latest/Quick%20start.html) para aprender a usarlo a fondo._

## Funcionamiento
Cuando se conecta un dispositivo a nuestro switch, este guarda su MAC-Address en una tabla; nuestra aplicación se mantendrá atenta a la tabla de MACs, y cuando aparezca una nueva, se busca en la Base de Datos, si está ahí, automáticamente se configura dicha interfaz con la Vlan que aparece en la Base de Datos para dicha MAC, también, se utiliza la funcionalidad de [Port Security](https://packetlife.net/blog/2010/may/3/port-security/) para asegurarse que que no se conecten más dispositivos que los autorizados a dicho puerto.

Cuando un dispositivo se desconecte del switch, la interfaz se apagará, la aplicación estará atenta a dichas interfaces para restaurarlas a su estado por defecto y esta pueda ser utilizada por otro dispositivo que se conecte.

> _**Nota**: Por defecto las interfaces contienen la vlan 1, asumiremos que esta será nuestra vlan de Black-hole para dispositivos no identificados._

Ya sabes como es esto, tu no me entiendes, y yo te dejo una imagen que te explica mejor :stuck_out_tongue_winking_eye::satisfied:
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/w34pcp8f5qr44e7j39hb.png)

## Base de Datos
En este caso, para simplicidad, utilizaremos un archivo de configuración como Base de Datos, en dicho archivo pondremos los datos de las MAC Address y en que Vlans las queremos.

A continuación un ejemplo de la estructura que diseñé para que la aplicación pueda leer dicho archivo sin problemas.

> _**Nota**: los Keys o Values que estén entre caracteres `<>` están hábiles para ser cambiados._

```json
{
  "hosts": {
    "<hostname>": {
      "deviceType": "cisco_ios",
      "username": "<device-username>",
      "password": "<device-password>",
      "secret": "<enable-secret>",
      "macs": {
        "<mac1>": {
          "vlan": "<vlan-id>"
        },
        "<mac2>": {
          "vlan": "<vlan-id>"
        },
        "<mac3>": {
          "vlan": "<vlan-id>"
        },
        "<mac4>": {
          "vlan": "<vlan-id>"
        }
      }
    }
  }
}
```
Más adelante veremos como quedó el que utilizaré para la demostración.

## Archivos de comandos de configuración
Los comandos de configuraciones de los equipos pudieran estar en el código, sin embargo, ponerlos en archivos aparte y extraer los comandos de ahí a la hora de utilizarlos es una solución más elegante. A continuación los 2 archivos de configuración que utilizaré.

`create_vlan.conf`: Estos son los comandos para asociar una interfaz a una Vlan cuando encontremos una MAC nueva en la tabla y que esté en la Base de Datos.
```
vlan {vlan}
interface {interface}
switchport mode access
switchport access vlan {vlan}
switchport port-security
switchport port-security maximum 1
switchport port-security violation protect
switchport port-security mac-address {mac}
end
```

`restore_interface.conf`: Estos son los comandos para volver una interfaz a su estado por defecto cuando se desconecte un dispositivo y la aplicación lo detecte.
```
interface {interface}
no switchport port-security
no switchport port-security violation protect
no switchport port-security mac-address
switchport access vlan 1
no switchport nonegotiate
no switchport mode access
end
```

## Templates
Acordamos en que utilizaríamos un modulo para extraer la información de los switches, este módulo requiere del uso de plantillas que modelen como vendrá la información. Este no es un tutorial del uso de Text Template Parser (puedo hacer uno en el futuro si lo piden), para eso te dejé la documentación más arriba. Si no quieres leer, puedes quedar con la idea abstracta de lo que hace el módulo, después de todo, lo importante es entender como incorporamos la estructura a nuestro código.

A continuación los templates que utilicé:

`interfaces.xml`: Plantilla de la estructura de la tabla dada por el comando `show ip interface brief`, el cual utilizaremos para saber si las interfaces están prendidas o apagadas.
```xml
<group name='{{ interface }}' method='table'>
{{ interface }}     {{ ignore }}      {{ ignore }} {{ ignore }}  {{ status }}                  {{ ignore }}
</group>
```

`mactable.xml`: Plantilla de la estructura de la tabla dada por el comando `show mac address-table`, el cual utilizaremos para saber si hay una MAC Address en la tabla sin inspeccionar.
```xml
<group name="{{ mac }}" method="table">
   {{ vlan | DIGIT }}    {{ mac }}    {{ type }}     {{ port }}
   {{ vlan | DIGIT }}    {{ mac }}    {{ type }}      {{ port }}
  {{ vlan | DIGIT }}    {{ mac }}    {{ type }}      {{ port }}
  {{ vlan | DIGIT }}    {{ mac }}    {{ type }}     {{ port }}
</group>
```

## Let's go to code
Ahora vamos a cocinar :sunglasses:.

Creamos una clase llama MBVlan (adivina el nombre) que será la que representará todas las funcionalidades de nuestra aplicación. En el método `__init__` instanciamos nuestra clase. En ese momento creamos un atributo que guarda el estado inicial del estado de las interfaces, es decir: si están _up_ o _down_. La idea es que cada vez que se detecte un cambio en el estado de las interfaces, este atributo se actualice. En cuanto al método que se usa `get_interfaces`, hablaremos de eso más adelante.

```python
from netmiko import ConnectHandler
from ttp import ttp
import json
import time


class MBVlan:
    def __init__(self):
        self.devices = dict([(device, {'interfaces': self.get_interfaces(device)}) for device in
                             self._config['hosts'].keys()])

```

La propiedad `_config` nos devuelve el archivo de configuración en forma de un diccionario de Python.
```python
    @property
    def _config(self):
        with open('config.json') as f:
            data = json.load(f)
        return data
```

Hay dos métodos importantes para la interacción con los equipos, el primero es `_get_device_config`, este nos devuelve la configuración del archivo de configuracion para ese dispositivo o switch. El otro método es `_get_device_conn`, el cual gestiona una conexión mediante Netmiko con los dispositivos de red, en este caso son los switches. Fijémonos que se auxilia con el método mencionado anteriormente para obtener los parámetros de conexión estipulados en el archivo de configuración.
```python
    def _get_device_config(self, device_id: str):
        config = self._config['hosts']
        config = config[device_id]
        return config

    def _get_device_conn(self, device_name):
        device_conf = self._get_device_config(device_name)
        conn = ConnectHandler(device_type=device_conf['deviceType'], host=device_name,
                              username=device_conf['username'],
                              password=device_conf['password'],
                              secret=device_conf['secret'])
        return conn
```

Tenemos dos métodos estáticos (que he colocado dentro de la clase para no crear otro archivo) que nos servirán para cargar contenido en archivos. El primero, `open_conf` nos abre los [archivos de comandos configuración](#archivos-de-comandos-de-configuraci%C3%B3n) más arriba. El segundo, `_load_template` nos abre los archivos `xml` que contienen las plantillas a parsear para extraer la información útil entre toda la que nos muestran los equipos.

```python
    @staticmethod
    def open_conf(filename: str):
        with open(f'commands/{filename}.conf', mode='r') as file:
            conf_template = file.read()

            return conf_template

    @staticmethod
    def load_template(name):
        with open(f'./templates/{name}.xml') as f:
            template = f.read()

        return template
```
El método que se encarga de cargar las plantilla es de suma importancia porque nos ahorra espacio en nuestro archivo de código, este es usado en los métodos que te voy a mostrar a continuación, `get_interfaces` y `get_tables`, los cuales utilizan los módulos que mencionamos al principio para extraer información de los equipos (en este caso los switches) y luego extraer la información que es útil, ya que es mucho texto que necesitamos procesar para entender el estado de la red. Hay algo importante que mencionar sobre [TTP](#m%C3%B3dulos-a-utilizar), y es que devuelve la data en un diccionario, recuerda que esta es la propia Tabla de Hash de Python, esto nos facilitará acceder a cualquier dato de una manera bastante rápida, tal como lo describimos en la [primera parte](https://dev.to/jorgmassih/estructuras-de-datos-para-network-engineers-101-hash-tables-parte-i-5go1). Notarás que se hace uso del _statement with_ en esta porción de código, si no sabes para que se usa te recomiendo leer [esto](https://www.educative.io/edpresso/the-with-statement-in-python).

```python
    def get_interfaces(self, device_id):
        # Crea una nueva connección con netmiko
        with self._get_device_conn(device_id) as conn:
            template = self.load_template('interfaces')

            interfaces = conn.send_command('show ip interface brief')

            # Usa la plantilla que describe el comando 'show ip interface brief'
            # para parsear la data que devuelve a la salida.
            parser = ttp(interfaces, template)
            parser.parse()

            # Aísla la salida dada por el switch y elimina del diccionario
            # el key "Interface", el cual es innecesario porque es el head
            # de la tabla obtenida.
            interfaces = parser.result()[0][0]
            interfaces.pop('Interface')
            return interfaces

    def get_table(self, device_id: str, table_name: str):
        # Crea una nueva connección con netmiko
        with self._get_device_conn(device_id) as conn:
            template = self.load_template(table_name)

            # Usa la plantilla que describe la salida del comando para
            # la tabla de mac address y devuelve la data usable
            mac_table = conn.send_command('show mac address-table')
            parser = ttp(mac_table, template)
            parser.parse()
            # Aisla la salida
            mac_table = parser.result()[0][0]

            return mac_table
```
Debes recordar, que acorde con el diagrama de flujo que viste al principio, habían dos operaciones sumamente importantes, una de ellas asignaba la vlan correspondiente a la interfaz del nuevo dispositivo conectado al switch, esto lo hacemos mediante el método `_update_interfaces`, el cual recibe en un diccionario la información de los hosts registrados en el [archivo de Base de Datos](#base-de-datos) y que están actualmente conectados algún equipo de la red, se itera sobre el diccionario y se  este diccionario también contiene informaciones como que tenemos que actualizar mediante la asignación de una Vlan y la activación del feature _Port Security_. La función de dicho método es sencilla, este busca todas las interfaces en el diccionario y luego carga el archivo de comandos en el dispositivo al que corresponde dicha interfaz mediante el uso de Netmiko. 

La otra operación importante revisaba las interfaces que recién se habían apagado por desconexión del host, de ser el caso, dicha interfaz volvía a su estado por defecto, es decir, vlan por defecto y funcionalidades de _Port Security_ deshabilitadas. Todo esto lo hacemos mediante el método `_restore_interfaces`.

> _**Nota**: Para lograr saber que interfaz se apagó recientemente, la aplicación monitorea la tabla de interfaces cada cierto tiempo y guarda el estado de esta, cuando se detecta un cambio, es porque una interfaz pasó de tener un estado "up" a "down"_.

```python
    def _update_interface(self, device_id: str, hosts: dict):
        # Crea una nueva connección con netmiko
        with self._get_device_conn(device_id) as conn:
            conf_template = self.open_conf('create_vlan')

            # Itera sobre el diccionario de los hosts en el archivo 
            # de Base de Datos
            for legal_host in hosts:
                # Agrega las variables de cada host a la platilla de comandos 
                commands = conf_template.format(vlan=hosts[legal_host]['vlan'], interface=hosts[legal_host]['port'], 
                                                mac=legal_host).split('\n')[
                           :-1]
                conn.send_config_set(commands)

    def _restore_interfaces(self, device, interfaces: set):
        # Crea una nueva connección con netmiko
        with self._get_device_conn(device) as conn:
            conf_template = self.open_conf('restore_interface')

            # Itera sobre las interfaces a restaurar valores por defecto
            for interface in interfaces:
                # Agrega las variables de cada host a la platilla de comandos  
                commands = conf_template.format(interface=interface).split('\n')[:-1]
                conn.send_config_set(commands)
```

Ya casi para terminar la explicación de todo el código, existe un método que es como la cereza del pastel, se llama `diff`, este se encarga de notar los cambios de estados en los dispositivos, es decir, cambios en la tabla de MAC-Address y en el estado de las Interfaces. Este método es el que llama a los métodos `_update_interface` y `_restore_interfaces` cuando detecta cambios.

En pocas palabras podemos decir que este método es el Core de todo el proyecto, de hecho, el diagrama de flujo del principio del post es una descripción a nivel general de este método.

Debido a que la información se representa en una Tabla de Hash, no es tan costoso a nivel de procesamiento hacer operaciones con esta, ya que sólo se necesita acceder al _Key_ correspondiente para tener el _Value_ deseado en un tiempo constante ({% katex inline %}O(1){% endkatex %}). Te pongo un ejemplo para la detección del cambio en la tabla de MACs de un switch, esta vez utilizaré una imagen:
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ypq8i5stbw1erw72uos8.png)

Con respecto a la detección de cambio de estado de las interfaces, debemos recordar que cuando la clase `MBVlan` es instanciada, se crea el atributo `devices` con el estado de todas las interfaces de todos los equipos (espero que te acuerdes de eso), y cada vez que se llama al método `diff`, dicho atributo es actualizado. La siguiente imagen muestra la actuación del método `diff` en los cambios de estado de las interfaces.

Para hacer la diferenciación entre las interfaces que siguen encendidas y las que se apagaron, se utiliza la Estructura de Datos basada en [Teoría de Conjuntos](https://edu.gcfglobal.org/es/los-conjuntos/clases-de-conjuntos/1/) llamada [Set](https://recursospython.com/guias-y-manuales/conjuntos-sets/), recuerda que dijimos en el post de [introducción](https://dev.to/jorgmassih/estructura-de-datos-para-network-engineers-101-introduccion-4j0k) que íbamos a hablar de esta estructura, pues llegó el momento. Sucede que, es lo mimo que una Tabla de Hash, pero el _Value_ siempre será `None`, y, ya que en una Tabla de Hash el Key nunca está en 2 posiciones al mismo tiempo, es imposible que se repita, por lo que podemos tener una estructura donde se almacenen elementos (que en este caso son los _Keys_ de la Tabla de Hash) y que no se repitan. Ingenioso, ¿no?

Esta estructura permite hacer operaciones parecidas a las que describe la Teoría de Conjuntos; para nuestra implementación utilizamos la operación de [Diferencia de Conjuntos](https://www.sangakoo.com/es/temas/diferencia-de-conjuntos) para ver los cambios entre los estados de interfaces en caché mediante el atributo `devices` y los estados actuales obtenidos mediante el método `get_interfaces`. Por el momento, los cambios de estado de las interfaces de _down_ a _up_ no nos interesan (aunque pudieran eficientizar la aplicación, pero añadirían más complejidad), por lo que los filtraremos para ignorarlos en el resultado final. ¿Confundido? Es que he explicado mucho, pero aquí un una imagen que te aclarará el proceso:
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0zfh3gq0mwic2yo3wm0x.png)

> _**Nota**: La complejidad de la operación `Difference` de B y A es de {% katex inline %} O(len(B)) {% endkatex %}. Se está utilizando la estructura Built-in de Python, puedes ver los tiempos de complejidad que emplea [aquí](https://wiki.python.org/moin/TimeComplexity#set)_.

A continuación te dejo con la porción de código que describe la intención del método `diff`:

```python
    def diff(self, device):
        new_macs = dict()

        switch_table = self.get_table(device, 'mactable')
        file_table = self._get_device_config(device)['macs']

        cached_int_status = self.devices[device]['interfaces']
        current_int_status = self.get_interfaces(device)

        # Obtiene la diferencia entre el estado actual de las int
        # y el que se tiene almacenado
        int_off = set(item[0] for item in current_int_status.items() if item[1]['status'] == 'down') - set(
            item[0] for item in cached_int_status.items() if item[1]['status'] == 'down')

        # Si existe interfaces apagadas recientemente, las restaura a su estado inicial
        if int_off:
            self._restore_interfaces(device, int_off)

        # Actualiza el estado de las interfaces
        self.devices[device]['interfaces'] = current_int_status

        # Busca nuevos hosts conectados al switch que esten en el archivo de BD
        for mac in switch_table:
            if file_table.get(mac) and switch_table[mac]['vlan'] != file_table[mac]['vlan']:
                new_macs.update({mac: {**file_table[mac], 'port': switch_table[mac]['port']}})

        # Si hay nuevos hosts, entonces actualiza sus interfaces
        if new_macs:
            self._update_interfaces(device, new_macs)
```

> _**Nota**: La actualización del atributo `devices` pudiera optimizarse y hacerse sólo cuando se detecte un cambio, sin embargo, al igual que otras cosas en la implementación, se ha dejado como está para poder simplificarla y que sea más entendible_.

Por último, si lo anterior fue la cereza en el helado, esto es la mermelada; se ha creado un método para correr la aplicación, de modo que a la hora de instanciar la clase, el siguiente paso es llamar a este método, se trata de `run`, el cual no hace más que iterar sobre todos dispositivos disponibles en nuestro archivo de base de datos `config.json` y llamar el método `diff` con cada dispositivo. Esto se hace a una frecuencia de muestreo deseada, yo puse 5 segundos en el ejemplo para fines demostrativos, pero, mientras más alta es la frecuencia de muestreo (tiempo bajo), será más fácil para nuestra aplicación detectar los cambio en nuestros equipos de red o switches.

```python
    def run(self):
        print('running...')
        devices = self.devices.keys()
        while True:
            time.sleep(5)
            for device in devices:
                self.diff(device)
```

Por lo que tendríamos:
```python
if __name__ == '__main__':
    app = MBVlan()
    app.run()
```

## Demostración y Conclusión
#### Escenario
La topología siguiente describe el escenario que utilizaremos para la prueba, donde tendremos dos switches de acceso, los cuales se conectan por una red de administración con el servidor que está corriendo el script. La configuración de estos switches solo contempla la de las interfaces de administración y el protocolo de SSH para que el módulo de Netmiko tenga acceso a ellos.
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cw2s5mu56iolz5trr0d4.PNG)

Cualquiera de los hosts que están en el diagrama deben de poder conectarse a cualquiera de los dos switches y ser asignados automáticamente a la Vlan correspondiente.

#### Archivo de configuración
Este es el archivo que utilicé como Base de Datos:
`config.json`
```json
{
  "hosts": {
    "192.168.56.154": {
      "deviceType": "cisco_ios",
      "username": "admin",
      "password": "admin",
      "secret": "admin",
      "macs": {
        "0050.7966.6800": {
          "vlan": "10"
        },
        "0050.7966.6801": {
          "vlan": "20"
        },
        "0050.7966.6802": {
          "vlan": "10"
        },
        "0050.7966.6803": {
          "vlan": "30"
        },
        "0050.7966.6804": {
          "vlan": "40"
        },
        "0050.7966.6805": {
          "vlan": "40"
        },
        "0050.7966.6806": {
          "vlan": "30"
        }
      }
    },
    "192.168.56.155": {
      "deviceType": "cisco_ios",
      "username": "admin",
      "password": "admin",
      "secret": "admin",
      "macs": {
        "0050.7966.6800": {
          "vlan": "10"
        },
        "0050.7966.6801": {
          "vlan": "20"
        },
        "0050.7966.6802": {
          "vlan": "10"
        },
        "0050.7966.6803": {
          "vlan": "30"
        },
        "0050.7966.6804": {
          "vlan": "40"
        },
        "0050.7966.6805": {
          "vlan": "40"
        },
        "0050.7966.6806": {
          "vlan": "30"
        }
      }
    }
  }
}
```

#### Look at the demo
Aquí un pequeño video del resultado, quiero hacer dos aclaraciones al respecto; la primera es que hice algunos cortes al video para acortar su duración, la segunda aclaración es que, una vez los hosts tienen una IP asignada de forma manual (debido a que en el escenario presentado no hay servidor DHCP), estos necesitan enviar tráfico hacia el switch para que este pueda aprender sus direcciones MACs, por lo que en el video se ve cuando se da ping hacia una IP que no existe en la red para forzar el aprendizaje de la MAC Address por parte del Switch, en un ambiente de producción de la vida real esto no es necesario.

{% vimeo 552423841 %}

#### Conclusión
La Tabla de Hash fue clave para el desarrollo de esta aplicación, vemos como en algunas partes de la misma utilizamos algoritmos que corren en una complejidad lineal de {% katex inline %}O(n){% endkatex %}, por lo que utilizar una Lista o un Array pudo haber elevado la complejidad hasta {% katex inline %}O(n^3){% endkatex %}, sin embargo, las operaciones de esta estructura entregan mejor complejidad, lo que da la oportunidad a nuestra aplicación de tener mejor rendimiento.

Por otra parte, tocamos la automatización, la cual es una herramienta muy poderosa, y más cuando se combina con las estructuras de datos adecuadas, y por eso es muy importante conocerlas todas ellas. Ahora te toca a ti ¿Qué piensas automatizar en tu red?¿Qué estructura utilizarías?

Aquí te dejo el proyecto completo:
{% replit @Jorgmassih/mac-based-vlan-ver001 %}

<a href="https://www.buymeacoffee.com/jorgmassih"><img src="https://img.buymeacoffee.com/button-api/?text=Buy me a coffee here&emoji=⌨️&slug=jorgmassih&button_colour=5F7FFF&font_colour=ffffff&font_family=Lato&outline_colour=000000&coffee_colour=FFDD00"></a>