DEV Community

loading...
Cover image for Estructuras de Datos para Network Engineers 101: Hash Tables (Parte II)

Estructuras de Datos para Network Engineers 101: Hash Tables (Parte II)

Jorge Abraham Massih Vargas
I love cats 😺 | I enjoy programing 💻 | I like networking and IT 💝 | I'm an automation enthusiast
・15 min read

Introducción

Según lo que vimos en la primera parte, 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

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: 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): 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 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, de igual manera con TTP, debes ir a la documentación 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 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 😜😆
Alt Text

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.

{
  "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>"
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

<group name='{{ interface }}' method='table'>
{{ interface }}     {{ ignore }}      {{ ignore }} {{ ignore }}  {{ status }}                  {{ ignore }}
</group>
Enter fullscreen mode Exit fullscreen mode

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.

<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>
Enter fullscreen mode Exit fullscreen mode

Let's go to code

Ahora vamos a cocinar 😎.

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.

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()])

Enter fullscreen mode Exit fullscreen mode

La propiedad _config nos devuelve el archivo de configuración en forma de un diccionario de Python.

    @property
    def _config(self):
        with open('config.json') as f:
            data = json.load(f)
        return data
Enter fullscreen mode Exit fullscreen mode

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.

    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
Enter fullscreen mode Exit fullscreen mode

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 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.

    @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
Enter fullscreen mode Exit fullscreen mode

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, 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. 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.

    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
Enter fullscreen mode Exit fullscreen mode

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 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".

    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)
Enter fullscreen mode Exit fullscreen mode

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 ( O(1)O(1) ). 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

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 llamada Set, recuerda que dijimos en el post de introducción 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 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

Nota: La complejidad de la operación Difference de B y A es de O(len(B))O(len(B)) . Se está utilizando la estructura Built-in de Python, puedes ver los tiempos de complejidad que emplea aquí.

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

    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)
Enter fullscreen mode Exit fullscreen mode

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.

    def run(self):
        print('running...')
        devices = self.devices.keys()
        while True:
            time.sleep(5)
            for device in devices:
                self.diff(device)
Enter fullscreen mode Exit fullscreen mode

Por lo que tendríamos:

if __name__ == '__main__':
    app = MBVlan()
    app.run()
Enter fullscreen mode Exit fullscreen mode

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

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

{
  "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"
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

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 O(n)O(n) , por lo que utilizar una Lista o un Array pudo haber elevado la complejidad hasta O(n3)O(n^3) , 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:

Discussion (0)